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
htmltag,raw()andtrustedRaw(). In anhtml... template, every${}is HTML-escaped, so data from any source renders as text and can't inject markup ordata-event/data-componentattributes. Nestedhtmlresults 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 newmarkdownfeature uses it). data-optional="${value}"still removes its element whenvalueisnull/undefined: in that attribute,htmlwrites them as the literal strings the check looks for. Everywhere else they render as empty.htmlchecks URL attributes (href,src,action,formaction,poster,background,xlink:href) at every trust level,trustedincluded: a value from data that makes one ajavascript:URL is replaced withabout:invalid#nutin-blocked, with a warning in development. Atnormal/strictsuch a link now keeps an inerthrefinstead of losing the attribute. A scheme written in the template itself (href="/p/${id}") is left alone. NewSecurityHelper.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 araw('</textarea>…')from breaking out of the element inString()of the template. NestedhtmlandtrustedRaw()there are unchanged.String()of a template withraw()no longer corrupts sources containing$&,$1, … (they were read as replacement patterns).data-eventtokens now pass raw values instead of HTML-escaped ones. RemovedSecurityHelper.sanitizeInputElement().- The default
normaltrust level now also stripssrcdocattributes, SVG<animate>/<set>elements that rewrite a URL attribute andjavascript:URLs.data:URLs are still stripped understrictonly. normalalso strips<style>,<link>,<base>and<meta>elements, SVG<script>, anddata:/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. NewSecurityHelper.sanitizeToFragment();sanitizeTemplate()still returns a string. - Migration: tag each
template/templateFnwithhtml(import { html } from core), drop.join('')on mappedhtmlresults, and wrap markup you trust inraw(). The build warns about every untagged template that contains${}, since its values are no longer escaped anywhere.
- New
HttpClientURLs, bodies and responses- URLs: endpoints are joined onto the base URL as URLs (not strings) and rejected before
fetchwhen they leave its origin or path. Before,${baseUrl}${endpoint}let an endpoint like@other.host/xsend 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. Onlyhttp:/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 newtrustedAPIsoption. - 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/jsonis only set for JSON bodies, so GET/DELETE no longer trigger a CORS preflight. Headers merge case-insensitively, and request interceptors get aHeadersobject. - Responses: 204/205 and empty bodies resolve to
undefined(they threw);+jsontypes 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
Authorizationon cross-origin redirects but forward custom headers (e.g.X-Api-Key), so APIs using those should passredirect: 'error'.
- URLs: endpoints are joined onto the base URL as URLs (not strings) and rejected before
Framework types live in a global
Nutinnamespace- The public types are now declared in
src/core/internals.d.ts, insidedeclare namespace Nutin, and no longer exported fromcore/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,IHttpClientOptionsandIRequestConfiglose theirIprefix:Nutin.Router,Nutin.EventBus,Nutin.HttpClient,Nutin.HttpClientOptions,Nutin.RequestConfig.- Only the name
Nutinis global, so these types can no longer collide with lib.dom (asNavigationEventMapdid) or with other packages' global types. - Migration: remove these names from your
import { … } from '…/core/index.js'lines and prefix them withNutin.. Insrc/app/globals.d.ts(not touched bynutin-update), wrap yourdeclare interface AppEventMap { … }indeclare 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.
- The public types are now declared in
No teardown on page unload
- Services no longer register a
beforeunloadlistener, and the generatedmain.tsno longer callsService.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()andService.destroyAll()now all runregisterCleanupcallbacks; before, onlydispose()did.- Migration: delete the
window.addEventListener('beforeunload', …Service.destroyAll()…)block fromsrc/app/main.ts, along with itsServiceimport if nothing else uses it.
- Services no longer register a
data-eventonly cancels navigation defaults- A
data-eventhandler now callspreventDefault()for link clicks (<a href>), formsubmitevents and submit-button clicks inside a form, whether or not it takes arguments. Call@event'spreventDefault()in your handler for any other default you relied on. TokenHelper.resolve()no longer callspreventDefault().
- A
nutin new:-preplaces-pm- The package manager flag is now
-p, --package-manager, and only acceptsnpm,yarn,pnpmorbun. Any other value used to end up in the install command and in the generated scripts.-pmwas a multi-letter short flag that newer Commander versions reject.
- The package manager flag is now
Typed
emit()payloads andgetInstance()argumentsAppEventBus.emit(event, data)requires the payload itsNutin.EventMapentry declares;emit('navigate')no longer compiles. An event declaredundefinedtakes none. The framework's payload-less events (reload,before-render,after-render,before-destroy,after-destroy) are now declaredundefinedinstead 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 asundefinedinstead of{}(or pass{}when emitting them); fix the type errors reported forgetInstance()calls.
Features
JS-only projects
nutin-new --js-onlygenerates a plain JavaScript project. TypeScript remains the default and recommended option, and documentation only covers TypeScript.
New
markdownfeature (nutin-add markdown)- Compiles Markdown folders listed in
nutin.config.js'smarkdownSources.sourceFoldersinto/generated/<name>.jsonmanifests at build time. - Rendered by a
MarkdownViewwith navigation and a table of contents. Each folder gets a route frommarkdownRoutes()(spread intoappRoutes), and its manifest is loaded on first visit (loadAll()preloads every manifest). - The
markdownfeature'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 Contentsis 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
loadErrorlocale 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;MarkdownViewtakesnavComponent,contentComponent,landingComponent(your own classes) andonContentRendered(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). landingfolder option: the bare route shows an index of the folder (hub title, description, groups and pages; withsectionInPath, a landing per section) instead of its first page, prerendered and in the sitemap withgenerateSEOFiles. It's styled like the folder's pages (.markdown-landingand.markdown-landing__bodyshare the content styles).ogImagefolder option: the defaultog:imageof 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 frontmattergroupoverrides it. - In-app navigation sets
document.titleto the page's title, like its prerendered SEO page. - With
generateSEOFiles: true, every page is prerendered at its own URL with its title, description andog:image(new frontmatterogImage), and listed insitemap.xml, with noconfig/seo.jsonentries needed. Aseo.jsonroute for the same URL overrides the generated one;seo: falseon 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 withgenerateSEOFilesevery language gets its own SEO page withhreflangalternates. The feature's UI texts are translatable throughsrc/app/markdown/locales/<lang>.json. docker:buildworks with themarkdownfeature: the Dockerfile copies everymarkdownSourcesfolder into the image build (folder names with spaces included). A folder outside the project is rejected with a clear error.
- Compiles Markdown folders listed in
Keeping children across re-renders
- Children can now survive their parent's re-render. Give a single child a
keepvalue inregisterChildren(), or a catalog akeepBy(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 getdisconnectedCallback. WheremoveBefore()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.
- Children can now survive their parent's re-render. Give a single child a
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 inbundle.js.
- The client bundle is now built with esbuild's code splitting: each dynamic
Lazy routes
- A route's view factory may return a Promise:
'/reports': () => import('./views/reports/reports.view.js').then((m) => new m.ReportsView()). New typeNutin.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 the404, to a lesser extent).
- A route's view factory may return a Promise:
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
tabindexgetstabindex="-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 andNavigation.reload()don't move focus.
- After an in-app navigation (link,
data-eventhandlers 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 failstsc, reported at the template's line with TypeScript's "Did you mean" suggestion. The check references the handlers from the class indist-build's copy only, fortsc, and is removed before bundling. Handlers named with${}aren't checked, and a handler used by a subclass's template must beprotected. - Template-only handlers now count as used, so new apps'
tsconfig.jsonenablesnoUnusedLocals. Existing apps can add it (nutin-updatelists it as a difference). - At runtime, a
data-eventwhose handler doesn't exist logs aconsole.warnnaming the component and the method (development builds only). It used to be ignored silently.
- TypeScript builds check every handler named in a component's or view's template (
Views can set their document title
- New
View.documentTitle(): return a title (e.g. the article shown) and the router uses it fordocument.titleafter each navigation, beforeconfig/seo.json's title, the<viewName>.titletranslation andviewName.
- New
nutin-updatereportspackage.json/tsconfig.jsondrift- The update summary now lists each
scripts/devDependenciesentry andtsconfig.jsonoption that differs from what the installed nutin generates. They are listed only: nothing is written.
- The update summary now lists each
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 orhreflang) 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
dockerfeature'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./healthis now an exact-match location so the redirect can't catch it. Language codes inconfig/languages.jsonmust be letters, digits and-.Prerendered SEO pages drop Nutin's binding attributes.
data-event,data-i18n,data-component,data-catalog,data-bind,data-pipeanddata-pipe-sourceare removed from the HTML written bygenerateSEOFiles: 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 otherdata-*attributes. On nutin.dev: −10% HTML, −6% brotli.SecurityHelper.stripBindingsFrom(root), the in-place version ofstripBindings(markup), is now exported..htmltemplates are type-checked. The build now merges.htmltemplates into their components and views before runningtsc(it ran onsrc/, where they were still__TEMPLATE_PLACEHOLDER__), so${task.nmae}in an.htmltemplate fails the build like it does in an inline one. Errors are reported at the.htmlfile 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.tscnow runs ondist-build's copies through a generateddist-build/tsconfig.jsonthat 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 formerprivate staticmembers are no longer exported.- Calls are unchanged:
TokenHelper.registerCustomToken(),SecurityHelper.escapeHtml(),NavigationManager.replaceState()work as before (core/index.tsexports 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-updatemerges the helper files you edited, like any other core file.
- Calls are unchanged:
Node 24 required. The CLI and generated apps now declare
engines.node: ">=24"(was>=22), and the SEO prerender bundle targetsnode24. The Docker feature's image and the CI workflows already used Node 24. Upgraded apps: setengines.nodeinpackage.jsonby hand (nutin-updatedoesn'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 auditis clean. With commander 15, extra arguments are rejected (nutin update extraerrors instead of ignoringextra).src/corefiles import each other directly, not throughcore/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 fromcore/index.ts. Internally,ViewRenderManager.renderNewView()now takes the view instance instead of its factory.nutin-updatemerges files you edited. A file you changed that also changed upstream used to be left at the old version, with a two-way diff inNUTIN-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
diff3conflict markers (yours / the original nutin version / the new one), and the update says the project won't build until they're resolved. This meansnutin-updatenow writes to files you edited. - Files left with markers are recorded in
.nutin-meta.json(unmergedFiles). Runningnutin-updateagain refuses to update while any are left and lists them, instead of saying "Already up to date". NUTIN-UPDATE-REPORT.mdis now a short list of merged, conflicting and binary files, without diffs.nutin-updateneeds git for this (the binary only, not a repository). Without it, it stops before changing anything.- Before writing anything,
nutin-updatechecks 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 withgit checkout -- . && git clean -fd, which it prints when done. New--allow-dirtyflag 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, thenpushStatethrew aSecurityError; 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(orgenerateSEOFilesis on, since prerendering usesappRoutes), andindex.htmlonly needs anid="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 anullelement) now logs aconsole.errorin development instead of leaving the page silently empty.build:prodrunsnode tools/builder/builder.js --prodinstead ofNODE_ENV=production node …, which Windows shells don't understand.NODE_ENV=productionstill works, so existingpackage.jsonscripts keep working.The dev server (
serve,dev) listens on127.0.0.1only, the address it prints, instead of every network interface.dev,serve,serve:prodandserve:onlyaccept a port:npm run dev -- --port 3000,npm run serve -- --port=3000, orPORT=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--porthint when the port is busy (live-server silently moved to a random port). Prerendered SEO pages are served from their folder'sindex.html. Page navigations without a matching file get the SPA shell, even when the URL contains a dot (/users/john.doeused to 404); a missing asset gets a 404.devreloads open pages after each successful rebuild (Server-Sent Events, via an external script so a CSP without'unsafe-inline'still allows it).serve/serve:prodno longer live-reload. Existing apps can removelive-serverfrom theirdevDependencies.New apps get
esbuild0.28,jsdom30 andchokidar5 (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 importstypescript, 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 inroutes.tsis now reported, not only insideappRoutes. The CLI keeps its own pinned TypeScript 5.9.3 for--js-onlygeneration; that version is separate from your app's. - The generated
tsconfig.jsonno longer setsbaseUrl(removed in TypeScript 7) or the emptypaths. - The global
NavigationEventMapdeclaration incore/internals.d.tsis nowNutin.RouterEventMap. lib.dom now has its ownNavigationEventMap(the Navigation API), and the two merged into conflictingnavigatetypes. - To upgrade an existing app: run
nutin-update, set"typescript": "^7.0.2"indevDependencies, and removebaseUrland the emptypathsfromtsconfig.json. The updated tools also still work on TypeScript 5.
- The build's route check (
The
devwatcher now also rebuilds when files are added or deleted (e.g. bynpm run generate), and whenconfig/,public/ornutin.config.jschange. It used to react to edits of existingsrc/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-- -yto allow it. The-yenv variable isNUTIN_ASSUME_YES. The Tailwind CLI is started throughnode, so it also works on Windows.Dynamic SEO routes are written to their real URL:
/blog/:slugwithmockParams: { "slug": "hello-world" }goes to/blog/hello-world/and is listed insitemap.xmlunder that URL. Before, it went to a literal:slug/folder that no URL reached. New optionaloutputPathroute field overrides that path.Prod builds compress files last, so prerendered SEO pages,
index.html,sitemap.xmlandrobots.txtalso get.gz/.brversions.testin-nutin mocks match the real services' public APIs.
I18nService.onLanguageChange(callback)now returns a function that unsubscribes the callback, likeNavigation.onNavigate().MockI18ndoes the same.New
testin-nutin:onlyscript runs the tests without rebuilding.i18n: Two folders with the same name in different places (e.g.
admin/user/andpublic/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.runCommandno longer goes through a shell outside Windows, so arguments with spaces are passed unsplit.devno longer leaves the server and watcher running (holding the port) when the terminal tab/window is closed ordev-serve.jsis killed hard: it now handlesSIGHUP, and both children exit when their IPC channel to it drops.Prod builds no longer hash or compress the copied
src/appandsrc/coresources.nutin-addnow 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-addfeatures share a single// Nutin featuresblock innutin.config.js.New apps still get empty
src/app/components/andsrc/app/services/folders, but no longer get a.gitkeepinside them.Smaller prod bundle: a new app's
bundle.jsgoes 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 referencesI18nService). NewinitI18n()loads the translations wheni18nis enabled and does nothing otherwise.main.tsnow callsawait initI18n()instead of checkingnutinConfig.i18nitself.I18nService.initTranslations()is unchanged. - With i18n disabled, a per-language
titleinconfig/seo.jsonresolves 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')returnedObject's source text.The
dockerfeature's nginx config sendsX-XSS-Protection: 0(the old1; mode=blockis deprecated and can itself be abused in older browsers).A parent's render no longer applies
data-pipea 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,
killor a crash no longer leaves.build-lockbehind. The next build used to wait 2 minutes and then fail. The lock is released onSIGINT/SIGTERM/SIGHUP, and a lock whose process is gone is freed right away.$$,$&,$`and$'in component/view templates are kept literally when merged.Cost: $$5used to becomeCost: $5, and$&pasted the placeholder back in. The same applies to prerendered SEO pages ($&in rendered content duplicated the#appelement).SEO files generation
robots.txt: a bot listed indisallowBotsstays fully blocked even when a route also names it indisallow. A bot with its own group now repeats the globaldisallow: truepaths, since robots.txt groups don't inherit*. With i18n, disallowed paths cover each/<lang>/prefix, and dynamic segments become*.sitemap.xmlleaves 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
/adminsent to/) is no longer written as public static HTML. It's also left out ofsitemap.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#appelement. - 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
$&,$`,$'inconfig/seo.jsontitles/descriptions (they copied raw page HTML into<title>/meta tags). The page URL andhreflanglinks are now HTML-escaped too.
Sass partials (
_name.scss) undersrc/appare no longer compiled on their own, which duplicated their CSS in the output.testin-nutin:
toBeLessThan/toBeGreaterThanno longer pass when both values are equal.- a
beforeEach/afterEach/beforeAll/afterAlldeclared after someit()calls now applies to them too. - TODO lines now show the real
.test.jspath in TypeScript projects instead of.test.ts. testin-nutin:watchworks and also rebuilds before each run, watchessrc/(plustools/withincludeTools), 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.jspath.A component no longer binds, fills or reads the
data-event,data-bindanddata-i18nelements of nested child components. A childdata-event="click:toggle"used to fire the parent'stoggle()too, the parent'spropsoverwrote the child'sdata-bindfields, andgetValues()returned them.data-optionalcleanup only looks inside the rendering component. It used to scan the whole document on every render, and it missed components rendered while detached.props.classNameaccepts several space-separated classes; it threwInvalidCharacterError.A comma inside a quoted
data-eventliteral (click:_tag:'a,b') no longer splits it into two arguments.The
datepipe's time flag reads"true"/"false"from templates:date:en-US,long,falseshowed 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 withconsole.errorand no longer keeps the other listeners from running.i18n no longer crashes the app at load when the browser blocks site storage (
localStoragethrows aSecurityError). Teardown keeps the saved language preference;resetTranslations()still forgets it.Catalog items no longer get the
normalizeKeysarray spread into their props as0,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 withstyle="${…}"or a dynamic tag name, is kept as written, with a note in the build output.nutin-addbuilds the feature template path from the generator's templates root instead of recomputing it. The CLI also drops several unused leftovers.nutin newno longer fails when git isn't installed orgit initfails. It warns and creates the project without a repository.nutin-updateandnutin-addin a project without.nutin-meta.jsonno 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.jsonby hand, with an example. Nothing is written.