3.0.0

v3 is a major revision focused on security, performance and maintainability.

It corrects or reworks several earlier decisions and establishes a more solid, stable fundation for the future.

Breaking Changes

  • Templates are escaped at output

    • New html tag, raw() and trustedRaw(). In an html... template, every ${} is HTML-escaped, so data from any source renders as text and can't inject markup or data-event/data-component attributes. Nested html results are inserted as-is. Unquoted attribute values (title=${x}) are quoted automatically, so a value with spaces can't add attributes. raw() inserts HTML unescaped: at render it's parsed, stripped of Nutin's binding attributes and sanitized as nodes, so injected markup can't call component methods or mount children; in tag position (<input ${raw('checked')}>) it keeps only safe attributes; trustedRaw() keeps them, for markup you wrote yourself (the new markdown feature uses it).
    • data-optional="${value}" still removes its element when value is null/undefined: in that attribute, html writes them as the literal strings the check looks for. Everywhere else they render as empty.
    • html checks URL attributes (href, src, action, formaction, poster, background, xlink:href) at every trust level, trusted included: a value from data that makes one a javascript: URL is replaced with about:invalid#nutin-blocked, with a warning in development. At normal/strict such a link now keeps an inert href instead of losing the attribute. A scheme written in the template itself (href="/p/${id}") is left alone. New SecurityHelper.isScriptUrl().
    • raw() inside <textarea>/<title> is inserted as text (entities decoded, tags shown as written, < escaped so it can't close the element). This also stops a raw('</textarea>…') from breaking out of the element in String() of the template. Nested html and trustedRaw() there are unchanged.
    • String() of a template with raw() no longer corrupts sources containing $&, $1, … (they were read as replacement patterns).
    • data-event tokens now pass raw values instead of HTML-escaped ones. Removed SecurityHelper.sanitizeInputElement().
    • The default normal trust level now also strips srcdoc attributes, SVG <animate>/<set> elements that rewrite a URL attribute and javascript: URLs. data: URLs are still stripped under strict only.
    • normal also strips <style>, <link>, <base> and <meta> elements, SVG <script>, and data:/javascript: documents in <iframe>/<object>/<embed>. Move <style> blocks from templates into the component's .scss. style="…" attributes are kept.
    • Rendering inserts the sanitized nodes directly instead of assigning the sanitized string to innerHTML. New SecurityHelper.sanitizeToFragment(); sanitizeTemplate() still returns a string.
    • Migration: tag each template/templateFn with html (import { html } from core), drop .join('') on mapped html results, and wrap markup you trust in raw(). The build warns about every untagged template that contains ${}, since its values are no longer escaped anywhere.
  • HttpClient URLs, bodies and responses

    • URLs: endpoints are joined onto the base URL as URLs (not strings) and rejected before fetch when they leave its origin or path. Before, ${baseUrl}${endpoint} let an endpoint like @other.host/x send the request and its default headers (e.g. Authorization) to another host. The base URL's own query is kept. Without a base URL, a relative endpoint now resolves against the page's origin, and default headers only go to that origin. Only http:/https: URLs are accepted. Error messages leave out query strings.
    • Encoded / and \ (%2F, %5C) in a path are rejected, since servers often decode them into separators; list APIs that expect them in the new trustedAPIs option.
    • Bodies: FormData, Blob, URLSearchParams, binary data and streams are sent as they are (they used to be sent as {}); 0/false/'' are sent (they were dropped). Content-Type: application/json is only set for JSON bodies, so GET/DELETE no longer trigger a CORS preflight. Headers merge case-insensitively, and request interceptors get a Headers object.
    • Responses: 204/205 and empty bodies resolve to undefined (they threw); +json types are parsed (they were returned as text). Response interceptors get a clone, so reading its body no longer breaks the request.
    • Redirects: a followed redirect that ends outside the base URL (or, without one, on another origin than requested) now throws instead of returning that origin's response, and response interceptors don't see it. Browsers strip Authorization on cross-origin redirects but forward custom headers (e.g. X-Api-Key), so APIs using those should pass redirect: 'error'.
  • Framework types live in a global Nutin namespace

    • The public types are now declared in src/core/internals.d.ts, inside declare namespace Nutin, and no longer exported from core/index. Use them without an import: Nutin.ComponentConfig, Nutin.BaseComponentOptions, Nutin.ComponentOptions, Nutin.ComponentProps, Nutin.ViewOptions, Nutin.CatalogConfig, Nutin.CatalogItemConfig, Nutin.CatalogItemObject, Nutin.CatalogItemPrimitive, Nutin.Template, Nutin.TrustLevel, Nutin.Routes, Nutin.RouteGuard, Nutin.RouteConfig, Nutin.ViewFactory, Nutin.RouteMatch, Nutin.Router, Nutin.EventBus, Nutin.HttpClient, Nutin.HttpClientOptions, Nutin.RequestConfig, Nutin.QueryValue, Nutin.HttpMethod, Nutin.Language, Nutin.Translations, Nutin.PipeFunction, Nutin.GlobalMountable, Nutin.GlobalConfig, Nutin.RegisterGlobalsOptions, Nutin.Subscription, and the event maps (Nutin.EventMap, Nutin.EventKey, Nutin.AppEventMap…).
    • IRouter, IEventBus, IHttpClient, IHttpClientOptions and IRequestConfig lose their I prefix: Nutin.Router, Nutin.EventBus, Nutin.HttpClient, Nutin.HttpClientOptions, Nutin.RequestConfig.
    • Only the name Nutin is global, so these types can no longer collide with lib.dom (as NavigationEventMap did) or with other packages' global types.
    • Migration: remove these names from your import { … } from '…/core/index.js' lines and prefix them with Nutin.. In src/app/globals.d.ts (not touched by nutin-update), wrap your declare interface AppEventMap { … } in declare namespace Nutin { interface AppEventMap { … } }: until then, emitting an app event is a type error. Classes and functions (Component, View, html, AppRouter, …) are still imported as before.
  • No teardown on page unload

    • Services no longer register a beforeunload listener, and the generated main.ts no longer calls Service.destroyAll() there. That teardown ran before the page was actually left: a page restored by Back from the back/forward cache, or kept after a cancelled "Leave site?" prompt, came back with every service disposed.
    • dispose(), MyService.destroy() and Service.destroyAll() now all run registerCleanup callbacks; before, only dispose() did.
    • Migration: delete the window.addEventListener('beforeunload', …Service.destroyAll()…) block from src/app/main.ts, along with its Service import if nothing else uses it.
  • data-event only cancels navigation defaults

    • A data-event handler now calls preventDefault() for link clicks (<a href>), form submit events and submit-button clicks inside a form, whether or not it takes arguments. Call @event's preventDefault() in your handler for any other default you relied on.
    • TokenHelper.resolve() no longer calls preventDefault().
  • nutin new: -p replaces -pm

    • The package manager flag is now -p, --package-manager, and only accepts npm, yarn, pnpm or bun. Any other value used to end up in the install command and in the generated scripts. -pm was a multi-letter short flag that newer Commander versions reject.
  • Typed emit() payloads and getInstance() arguments

    • AppEventBus.emit(event, data) requires the payload its Nutin.EventMap entry declares; emit('navigate') no longer compiles. An event declared undefined takes none. The framework's payload-less events (reload, before-render, after-render, before-destroy, after-destroy) are now declared undefined instead of {}.
    • MyService.getInstance(...args) checks its arguments against the service's constructor and returns the service's type. A service without its own constructor accepts no arguments. Service's own constructor takes none.
    • Migration: in src/app/globals.d.ts, declare payload-less app events as undefined instead of {} (or pass {} when emitting them); fix the type errors reported for getInstance() calls.

Features

  • JS-only projects

    • nutin-new --js-only generates a plain JavaScript project. TypeScript remains the default and recommended option, and documentation only covers TypeScript.
  • New markdown feature (nutin-add markdown)

    • Compiles Markdown folders listed in nutin.config.js's markdownSources.sourceFolders into /generated/<name>.json manifests at build time.
    • Rendered by a MarkdownView with navigation and a table of contents. Each folder gets a route from markdownRoutes() (spread into appRoutes), and its manifest is loaded on first visit (loadAll() preloads every manifest).
    • The markdown feature's routes are lazy by default: markdownRoutes({ view }) accepts a factory returning a Promise.
    • Adding a folder only takes a config change. Supports YAML frontmatter, hub (table of contents) files, sections in routes (/docs/:section?/:slug?) and internal links between pages.
    • Duplicate slugs and broken links fail the build; content is validated before building. A page listed more than once in a folder's hub files (twice, or in two hubs) fails the build too. Hub entries may use -, * or + bullets; any other list item under # Table of Contents is ignored with a warning giving its line. Broken internal Markdown links are all reported at once per page.
    • Heading ids turn accents into plain letters (Été → ete) and give a heading repeated on a page a -1, -2, ... suffix. Table of contents entries, page descriptions and hub descriptions are plain text: a description is the first paragraph or list item, without Markdown marks or HTML tags.
    • A folder whose manifest can't be loaded shows "This page couldn't be loaded." (new loadError locale key) instead of its empty state, and is tried again on the next visit.
    • Customizable from your own files: markdownRoutes({ view }) builds each folder's view; MarkdownView takes navComponent, contentComponent, landingComponent (your own classes) and onContentRendered(element, page) (e.g. syntax highlighting), and its getters are protected. The nav, content and landing components take their template as an optional third constructor argument, and their render helpers are exported.
    • Easy to restyle: the feature's styles have no specificity (:where()), so any app rule wins, and sizes are custom properties on .markdown-view (--markdown-gap, --markdown-nav-width, --markdown-toc-width, --markdown-line-height).
    • landing folder option: the bare route shows an index of the folder (hub title, description, groups and pages; with sectionInPath, a landing per section) instead of its first page, prerendered and in the sitemap with generateSEOFiles. It's styled like the folder's pages (.markdown-landing and .markdown-landing__body share the content styles).
    • ogImage folder option: the default og:image of the folder's SEO pages.
    • Without hub files, subfolders are groups in the navigation, titled after the folder (01-getting-started/ → "Getting started"); a page's frontmatter group overrides it.
    • In-app navigation sets document.title to the page's title, like its prerendered SEO page.
    • With generateSEOFiles: true, every page is prerendered at its own URL with its title, description and og:image (new frontmatter ogImage), and listed in sitemap.xml, with no config/seo.json entries needed. A seo.json route for the same URL overrides the generated one; seo: false on a folder opts it out. Descriptions taken from a page's first paragraph are plain text (no [link](url) or ** marks).
    • i18n: with i18n: true, a folder with one subfolder per language (markdown-content/en/, markdown-content/fr/) is compiled to one manifest per language (/generated/<name>.<lang>.json). The default language defines the pages; a missing translation shows the default-language page and is listed in a build warning, and a page that only exists in a translation fails the build. Links carry the /<lang> prefix, a language change reloads the current page in the new language, and with generateSEOFiles every language gets its own SEO page with hreflang alternates. The feature's UI texts are translatable through src/app/markdown/locales/<lang>.json.
    • docker:build works with the markdown feature: the Dockerfile copies every markdownSources folder into the image build (folder names with spaces included). A folder outside the project is rejected with a clear error.
  • Keeping children across re-renders

    • Children can now survive their parent's re-render. Give a single child a keep value in registerChildren(), or a catalog a keepBy(item, index): while that value stays the same (and, for catalogs, the item is shallow-equal), the child keeps its instance and DOM — its factory isn't called and it isn't re-rendered. Its focus, caret, scroll position and unsaved input are kept. A changed or removed value still destroys and recreates it, and children without one are recreated as before.
    • A kept child is moved into its new placeholder with Element.moveBefore(), so it never leaves the document: iframes don't reload, CSS animations keep running and custom elements don't get disconnectedCallback. Where moveBefore() isn't supported (Safari), it is re-inserted, and focus, caret and scroll are restored.
    • New onKept() lifecycle hook, called on a kept child instead of a render.
  • Code splitting

    • The client bundle is now built with esbuild's code splitting: each dynamic import() becomes a chunk fetched only the first time it runs instead of being inlined in bundle.js.
  • Lazy routes

    • A route's view factory may return a Promise: '/reports': () => import('./views/reports/reports.view.js').then((m) => new m.ReportsView()). New type Nutin.ViewFactory.
    • If a chunk fails to load, the router logs it and does a full page load of the target URL. A URL that fails again within 10 seconds is only logged.
    • Don't use lazy routes for the default route (/) (or the 404, to a lesser extent).
  • Focus moves to the new view after navigation

    • After an in-app navigation (link, Navigation.navigateTo(), back/forward, guard redirect, /404), the router focuses the view's <h1>, else the <h1> in <main>, else the view itself while announcing the page title in a polite live region. Keyboard and screen-reader users used to be left at the top of the page with no signal that it had changed.
    • An element without a tabindex gets tabindex="-1" and no focus ring while focused (it isn't interactive), both removed on blur. The page isn't scrolled to it. The first load and Navigation.reload() don't move focus.
  • data-event handlers are checked

    • TypeScript builds check every handler named in a component's or view's template (data-event="click:_save") against its class: a typo fails tsc, reported at the template's line with TypeScript's "Did you mean" suggestion. The check references the handlers from the class in dist-build's copy only, for tsc, and is removed before bundling. Handlers named with ${} aren't checked, and a handler used by a subclass's template must be protected.
    • Template-only handlers now count as used, so new apps' tsconfig.json enables noUnusedLocals. Existing apps can add it (nutin-update lists it as a difference).
    • At runtime, a data-event whose handler doesn't exist logs a console.warn naming the component and the method (development builds only). It used to be ignored silently.
  • Views can set their document title

    • New View.documentTitle(): return a title (e.g. the article shown) and the router uses it for document.title after each navigation, before config/seo.json's title, the <viewName>.title translation and viewName.
  • nutin-update reports package.json/tsconfig.json drift

    • The update summary now lists each scripts/devDependencies entry and tsconfig.json option that differs from what the installed nutin generates. They are listed only: nothing is written.

Changes

  • i18n links are localized at render. A root-relative <a href="/docs"> is rendered as /en/docs/ (current language, trailing slash as in the canonical URL), so prerendered SEO pages and copied links point at the real page. They used to link to /docs, which served the bare SPA shell with a 200 (no title, canonical or hreflang) as a duplicate of /en/docs/. External, relative, already-prefixed and file links are left as written; i18n-off builds don't include the code.

  • Docker: unprefixed paths redirect with i18n. The docker feature's nginx config 301-redirects every unprefixed, extension-less path (/docs → /en/docs) to the default language, not only /, so old and external links stop getting a duplicate shell. /health is now an exact-match location so the redirect can't catch it. Language codes in config/languages.json must be letters, digits and -.

  • Prerendered SEO pages drop Nutin's binding attributes. data-event, data-i18n, data-component, data-catalog, data-bind, data-pipe and data-pipe-source are removed from the HTML written by generateSEOFiles: the app renders the page again when it starts, so nothing ever binds them. They're removed on the DOM, so text showing them (code samples) is kept, as are classes, styles and other data-* attributes. On nutin.dev: −10% HTML, −6% brotli. SecurityHelper.stripBindingsFrom(root), the in-place version of stripBindings(markup), is now exported.

  • .html templates are type-checked. The build now merges .html templates into their components and views before running tsc (it ran on src/, where they were still __TEMPLATE_PLACEHOLDER__), so ${task.nmae} in an .html template fails the build like it does in an inline one. Errors are reported at the .html file and line. An app upgrading may see errors that were always there. Builds otherwise produce the same output: templates are still minified, after type-checking. tsc now runs on dist-build's copies through a generated dist-build/tsconfig.json that extends yours (removed at the end of the build).

  • Core's static helper classes are now modules of functions. The 16 helpers made only of static methods (DomHelper, SecurityHelper, TokenHelper, NavigationManager, HttpBuilder, …) export plain functions, imported as namespaces (import * as DomHelper from './helpers/dom.helper.js'). esbuild can now shorten their names and drop what's unused: a fresh app's bundle went from ~34 KB to ~30 KB minified (~10 KB → ~9 KB brotli). Their former private static members are no longer exported.

    • Calls are unchanged: TokenHelper.registerCustomToken(), SecurityHelper.escapeHtml(), NavigationManager.replaceState() work as before (core/index.ts exports them as namespaces).
    • Code that subclassed a helper, reassigned one of its members, or spied on it in tests (spyOn(NavigationManager, 'replaceState')) must change: module namespaces are read-only. Spy on what the function calls instead (e.g. window.history.replaceState), or check its effect.
    • nutin-update merges the helper files you edited, like any other core file.
  • Node 24 required. The CLI and generated apps now declare engines.node: ">=24" (was >=22), and the SEO prerender bundle targets node24. The Docker feature's image and the CI workflows already used Node 24. Upgraded apps: set engines.node in package.json by hand (nutin-update doesn't manage it).

  • CLI dependencies upgraded. handlebars 4.7.10 (fixes a critical advisory), inquirer 14 (drops tmp, flagged high), commander 15, fs-extra 11.4. npm audit is clean. With commander 15, extra arguments are rejected (nutin update extra errors instead of ignoring extra).

  • src/core files import each other directly, not through core/index.ts. The barrel made circular imports (e.g. Component → index.ts → BaseComponent → index.ts), which a single bundle tolerated but code splitting doesn't: once core code is shared between chunks, a class could be evaluated before the class it extends (Class extends value undefined). Apps keep importing from core/index.ts. Internally, ViewRenderManager.renderNewView() now takes the view instance instead of its factory.

  • nutin-update merges files you edited. A file you changed that also changed upstream used to be left at the old version, with a two-way diff in NUTIN-UPDATE-REPORT.md. The files around it were updated, so the project often didn't build until you merged by hand, and nothing said so. Now your file and the new version are merged three-way against the old one (git merge-file):

    • Edits that don't overlap are merged cleanly.
    • Overlapping ones get diff3 conflict markers (yours / the original nutin version / the new one), and the update says the project won't build until they're resolved. This means nutin-update now writes to files you edited.
    • Files left with markers are recorded in .nutin-meta.json (unmergedFiles). Running nutin-update again refuses to update while any are left and lists them, instead of saying "Already up to date".
    • NUTIN-UPDATE-REPORT.md is now a short list of merged, conflicting and binary files, without diffs.
    • nutin-update needs git for this (the binary only, not a repository). Without it, it stops before changing anything.
    • Before writing anything, nutin-update checks that the project folder is committed: no changes and no untracked files (ignored files don't count), in a git repository. Otherwise it lists them and stops. So an update can always be undone with git checkout -- . && git clean -fd, which it prints when done. New --allow-dirty flag to skip the check.
  • The router keeps every path on the site. A path passed to Navigation.navigateTo() that combined a dot segment and a backslash (/./\host, /a/..\/host) was normalized to //host, which the browser reads as another host. The 404 view rendered, then pushState threw a SecurityError; and when the route's view failed to load (a lazy route after a deploy), the full page load went to that host. Paths are now collapsed again after parsing, so they always stay on the current origin (/host). This matters when an app navigates to a path it didn't write (e.g. a ?next= parameter).

  • Apps without the router build. The route check only runs when app code imports routes.ts (or generateSEOFiles is on, since prerendering uses appRoutes), and index.html only needs an id="app" element in those cases. An app that mounts its components into its own elements (a widget in an existing page, a browser extension popup) needs neither. A mount target that isn't found (a selector matching nothing, or a null element) now logs a console.error in development instead of leaving the page silently empty.

  • build:prod runs node tools/builder/builder.js --prod instead of NODE_ENV=production node …, which Windows shells don't understand. NODE_ENV=production still works, so existing package.json scripts keep working.

  • The dev server (serve, dev) listens on 127.0.0.1 only, the address it prints, instead of every network interface.

  • dev, serve, serve:prod and serve:only accept a port: npm run dev -- --port 3000, npm run serve -- --port=3000, or PORT=3000 npm run dev. Precedence is --port > PORT > 9090 (default). Invalid ports fail fast with a clear error.

  • live-server is replaced by a built-in dev server (tools/dev/serve.js, node:http, no dependency). live-server hasn't been released since 2019 and pulled in outdated dependencies. The new server logs its URL and fails with a --port hint when the port is busy (live-server silently moved to a random port). Prerendered SEO pages are served from their folder's index.html. Page navigations without a matching file get the SPA shell, even when the URL contains a dot (/users/john.doe used to 404); a missing asset gets a 404. dev reloads open pages after each successful rebuild (Server-Sent Events, via an external script so a CSP without 'unsafe-inline' still allows it). serve/serve:prod no longer live-reload. Existing apps can remove live-server from their devDependencies.

  • New apps get esbuild 0.28, jsdom 30 and chokidar 5 (they were on 0.25, 26 and 4). Existing apps keep their versions, and the updated tools work with both.

  • New TypeScript apps use TypeScript 7 (^7.0.2, the native compiler).

    • The build's route check (validate-routes.js) no longer imports typescript, since TypeScript 7 has no JavaScript API. It now uses esbuild for both TS and JS apps and reports the line of each duplicate route. A duplicate key anywhere in routes.ts is now reported, not only inside appRoutes. The CLI keeps its own pinned TypeScript 5.9.3 for --js-only generation; that version is separate from your app's.
    • The generated tsconfig.json no longer sets baseUrl (removed in TypeScript 7) or the empty paths.
    • The global NavigationEventMap declaration in core/internals.d.ts is now Nutin.RouterEventMap. lib.dom now has its own NavigationEventMap (the Navigation API), and the two merged into conflicting navigate types.
    • To upgrade an existing app: run nutin-update, set "typescript": "^7.0.2" in devDependencies, and remove baseUrl and the empty paths from tsconfig.json. The updated tools also still work on TypeScript 5.
  • The dev watcher now also rebuilds when files are added or deleted (e.g. by npm run generate), and when config/, public/ or nutin.config.js change. It used to react to edits of existing src/ files only.

  • Opt-in dependencies (Tailwind, markdown) are installed by one shared helper, tools/utils/ensure-deps.js. Outside an interactive terminal (CI, the dev watcher) it no longer installs packages on its own; pass -- -y to allow it. The -y env variable is NUTIN_ASSUME_YES. The Tailwind CLI is started through node, so it also works on Windows.

  • Dynamic SEO routes are written to their real URL: /blog/:slug with mockParams: { "slug": "hello-world" } goes to /blog/hello-world/ and is listed in sitemap.xml under that URL. Before, it went to a literal :slug/ folder that no URL reached. New optional outputPath route field overrides that path.

  • Prod builds compress files last, so prerendered SEO pages, index.html, sitemap.xml and robots.txt also get .gz/.br versions.

  • testin-nutin mocks match the real services' public APIs.

  • I18nService.onLanguageChange(callback) now returns a function that unsubscribes the callback, like Navigation.onNavigate(). MockI18n does the same.

  • New testin-nutin:only script runs the tests without rebuilding.

  • i18n: Two folders with the same name in different places (e.g. admin/user/ and public/user/) used to overwrite each other's translations silently, since locales are keyed by folder name. That now fails the build and names both folders.

  • runCommand no longer goes through a shell outside Windows, so arguments with spaces are passed unsplit.

  • dev no longer leaves the server and watcher running (holding the port) when the terminal tab/window is closed or dev-serve.js is killed hard: it now handles SIGHUP, and both children exit when their IPC channel to it drops.

  • Prod builds no longer hash or compress the copied src/app and src/core sources.

  • nutin-add now also updates base files that depend on the added feature. The change is applied as a patch, so your own edits to those files are kept. When you edited right around it, each inserted block is placed next to the unchanged lines it belongs with instead. If those lines are gone too, the file is left untouched and the patch is printed to apply by hand.

  • nutin-add features share a single // Nutin features block in nutin.config.js.

  • New apps still get empty src/app/components/ and src/app/services/ folders, but no longer get a .gitkeep inside them.

  • Smaller prod bundle: a new app's bundle.js goes from ~39 KB to ~30 KB minified (~10 KB gzipped, ~9 KB brotlied).

    • Core services you don't use are left out of the bundle.
    • With i18n: false, the i18n code is left out of the bundle (unless app code references I18nService). New initI18n() loads the translations when i18n is enabled and does nothing otherwise. main.ts now calls await initI18n() instead of checking nutinConfig.i18n itself. I18nService.initTranslations() is unchanged.
    • With i18n disabled, a per-language title in config/seo.json resolves to its first value. It used to depend on the browser's language.
    • The browser bundle no longer includes the tooling-only parts of nutin.config.js (builder, testinNutin, dockerPorts, markdownSources).

Fixes

  • i18n lookups only follow a translation file's own keys: translate('constructor') returned Object's source text.

  • The docker feature's nginx config sends X-XSS-Protection: 0 (the old 1; mode=block is deprecated and can itself be abused in older browsers).

  • A parent's render no longer applies data-pipe a second time to content inside its child components.

  • When a route guard redirects during back/forward navigation, a reload() or the first page load, the address bar now shows the redirect target. The guarded URL is replaced in history instead of being left in place, so Back no longer lands on it again. Redirects from in-app navigation still add a new history entry.

  • New apps no longer ship with a CSS reset.

  • A build killed by Ctrl-C, kill or a crash no longer leaves .build-lock behind. The next build used to wait 2 minutes and then fail. The lock is released on SIGINT/SIGTERM/SIGHUP, and a lock whose process is gone is freed right away.

  • $$, $&, $` and $' in component/view templates are kept literally when merged. Cost: $$5 used to become Cost: $5, and $& pasted the placeholder back in. The same applies to prerendered SEO pages ($& in rendered content duplicated the #app element).

  • SEO files generation

    • robots.txt: a bot listed in disallowBots stays fully blocked even when a route also names it in disallow. A bot with its own group now repeats the global disallow: true paths, since robots.txt groups don't inherit *. With i18n, disallowed paths cover each /<lang>/ prefix, and dynamic segments become *.
    • sitemap.xml leaves out routes disallowed for every bot, and its URLs are XML-escaped.
    • SEO prerendering runs each route's guards first, as an anonymous visitor. A route a guard blocks or redirects (e.g. a logged-out /admin sent to /) is no longer written as public static HTML. It's also left out of sitemap.xml, with a build warning naming it. Guards that allow the route still run, so loader guards have their data when the page renders.
    • SEO prerendering fills any element with id="app", not only <main id="app">, and keeps its attributes (other elements used to get empty pages). It fails the build when index.html has no closed #app element.
    • SSR's route check passes the locales folder, so a locale fetch during module load no longer throws an unhandled rejection.
    • SEO page generation no longer expands $&, $`, $' in config/seo.json titles/descriptions (they copied raw page HTML into <title>/meta tags). The page URL and hreflang links are now HTML-escaped too.
  • Sass partials (_name.scss) under src/app are no longer compiled on their own, which duplicated their CSS in the output.

  • testin-nutin:

    • toBeLessThan/toBeGreaterThan no longer pass when both values are equal.
    • a beforeEach/afterEach/beforeAll/afterAll declared after some it() calls now applies to them too.
    • TODO lines now show the real .test.js path in TypeScript projects instead of .test.ts.
    • testin-nutin:watch works and also rebuilds before each run, watches src/ (plus tools/ with includeTools), and reacts to added and deleted files. A change made while tests are running now triggers another run instead of being dropped.
    • A test file that fails to import (syntax error, bad import) now fails the run with exit code 1. It used to be printed and skipped, so CI passed.
  • A coverage run that covers no file now reports 0% instead of 100%, so it fails a threshold instead of passing it.

  • The SSR "unguarded browser global" hint points to the real tools/builder/core/seo/ssr/ssr-polyfills.js path.

  • A component no longer binds, fills or reads the data-event, data-bind and data-i18n elements of nested child components. A child data-event="click:toggle" used to fire the parent's toggle() too, the parent's props overwrote the child's data-bind fields, and getValues() returned them.

  • data-optional cleanup only looks inside the rendering component. It used to scan the whole document on every render, and it missed components rendered while detached.

  • props.className accepts several space-separated classes; it threw InvalidCharacterError.

  • A comma inside a quoted data-event literal (click:_tag:'a,b') no longer splits it into two arguments.

  • The date pipe's time flag reads "true"/"false" from templates: date:en-US,long,false showed the time.

  • Router:

    • A navigation whose guards resolve after a newer navigation has started is dropped; it used to render over the newer one.
    • The query string is kept by navigate(), reload(), back/forward and language switches.
    • Route params are URL-decoded (/users/J%C3%B6rg → Jörg).
    • Static route segments match literally (. in a pattern matched any character).
  • EventBus.emit(): a listener that throws is reported with console.error and no longer keeps the other listeners from running.

  • i18n no longer crashes the app at load when the browser blocks site storage (localStorage throws a SecurityError). Teardown keeps the saved language preference; resetTranslations() still forgets it.

  • Catalog items no longer get the normalizeKeys array spread into their props as 0, 1, … keys.

  • Template minification no longer breaks a template that contains a nested html, such as ${items.map((i) => html`<li>${i}</li>`)}. An inline template was cut at the inner backtick, and the minifier closed the "unclosed" markup, turning the code into a syntax error. The build now finds the real end of each template. A template that can't be minified safely, such as one with style="${…}" or a dynamic tag name, is kept as written, with a note in the build output.

  • nutin-add builds the feature template path from the generator's templates root instead of recomputing it. The CLI also drops several unused leftovers.

  • nutin new no longer fails when git isn't installed or git init fails. It warns and creates the project without a repository.

  • nutin-update and nutin-add in a project without .nutin-meta.json no longer crash (stdin closed, e.g. CI) or wait forever (stdin an open pipe) when the shell is non-interactive. They exit with a message that says to run the command in a terminal or create .nutin-meta.json by hand, with an example. Nothing is written.