@nifrajs/web 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +2970 -0
  2. package/dist/build-plan.d.ts +7 -0
  3. package/dist/build-plan.d.ts.map +1 -1
  4. package/dist/build-plan.js +19 -0
  5. package/dist/build-plan.js.map +1 -1
  6. package/dist/build-vite.d.ts +1 -0
  7. package/dist/build-vite.d.ts.map +1 -1
  8. package/dist/build-vite.js +51 -3
  9. package/dist/build-vite.js.map +1 -1
  10. package/dist/build.d.ts +12 -1
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +43 -6
  13. package/dist/build.js.map +1 -1
  14. package/dist/client.d.ts +2 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +14 -7
  17. package/dist/client.js.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/internal/codegen.d.ts +6 -0
  23. package/dist/internal/codegen.d.ts.map +1 -1
  24. package/dist/internal/codegen.js +23 -5
  25. package/dist/internal/codegen.js.map +1 -1
  26. package/dist/internal/copy-split.d.ts +27 -0
  27. package/dist/internal/copy-split.d.ts.map +1 -0
  28. package/dist/internal/copy-split.js +173 -0
  29. package/dist/internal/copy-split.js.map +1 -0
  30. package/dist/internal/page-execution.d.ts +19 -1
  31. package/dist/internal/page-execution.d.ts.map +1 -1
  32. package/dist/internal/page-execution.js +63 -8
  33. package/dist/internal/page-execution.js.map +1 -1
  34. package/dist/internal/render-document.d.ts +11 -2
  35. package/dist/internal/render-document.d.ts.map +1 -1
  36. package/dist/internal/render-document.js +38 -6
  37. package/dist/internal/render-document.js.map +1 -1
  38. package/dist/internal/typescript-api.d.ts +4 -0
  39. package/dist/internal/typescript-api.d.ts.map +1 -0
  40. package/dist/internal/typescript-api.js +8 -0
  41. package/dist/internal/typescript-api.js.map +1 -0
  42. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  43. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  44. package/dist/internal/vite-plugin-compat.js +82 -0
  45. package/dist/internal/vite-plugin-compat.js.map +1 -0
  46. package/dist/internal/web-app.d.ts +34 -0
  47. package/dist/internal/web-app.d.ts.map +1 -1
  48. package/dist/internal/web-app.js +22 -0
  49. package/dist/internal/web-app.js.map +1 -1
  50. package/dist/plugins/import-glob.d.ts +20 -0
  51. package/dist/plugins/import-glob.d.ts.map +1 -0
  52. package/dist/plugins/import-glob.js +401 -0
  53. package/dist/plugins/import-glob.js.map +1 -0
  54. package/dist/plugins/kit.d.ts +11 -0
  55. package/dist/plugins/kit.d.ts.map +1 -1
  56. package/dist/plugins/kit.js +32 -0
  57. package/dist/plugins/kit.js.map +1 -1
  58. package/dist/plugins/stylex.d.ts.map +1 -1
  59. package/dist/plugins/stylex.js +2 -2
  60. package/dist/plugins/stylex.js.map +1 -1
  61. package/dist/render-seam.d.ts +37 -0
  62. package/dist/render-seam.d.ts.map +1 -1
  63. package/dist/render-seam.js +33 -0
  64. package/dist/render-seam.js.map +1 -1
  65. package/dist/router.d.ts +3 -2
  66. package/dist/router.d.ts.map +1 -1
  67. package/dist/router.js +4 -4
  68. package/dist/router.js.map +1 -1
  69. package/dist/server-components/typescript.d.ts.map +1 -1
  70. package/dist/server-components/typescript.js +2 -7
  71. package/dist/server-components/typescript.js.map +1 -1
  72. package/dist/vite.d.ts +1 -28
  73. package/dist/vite.d.ts.map +1 -1
  74. package/dist/vite.js +3 -69
  75. package/dist/vite.js.map +1 -1
  76. package/package.json +5 -4
  77. package/src/build-plan.ts +21 -0
  78. package/src/build-vite.ts +60 -3
  79. package/src/build.ts +57 -4
  80. package/src/client.ts +15 -7
  81. package/src/index.ts +6 -0
  82. package/src/internal/codegen.ts +32 -3
  83. package/src/internal/copy-split.ts +220 -0
  84. package/src/internal/page-execution.ts +83 -7
  85. package/src/internal/render-document.ts +63 -6
  86. package/src/internal/typescript-api.ts +11 -0
  87. package/src/internal/vite-plugin-compat.ts +81 -0
  88. package/src/internal/web-app.ts +61 -1
  89. package/src/plugins/import-glob.ts +392 -0
  90. package/src/plugins/kit.ts +36 -0
  91. package/src/plugins/stylex.ts +2 -1
  92. package/src/render-seam.ts +60 -0
  93. package/src/router.ts +7 -6
  94. package/src/server-components/typescript.ts +2 -10
  95. package/src/vite.ts +3 -81
@@ -11,13 +11,23 @@ import type {
11
11
  RouteModule,
12
12
  UnsafeScriptDescriptor,
13
13
  } from "../manifest.ts"
14
- import type { DocumentParts, MatchChain, RenderAdapter, RenderProps } from "../render-seam.ts"
14
+ import type {
15
+ DocumentParts,
16
+ MatchChain,
17
+ PageLocale,
18
+ PageLocales,
19
+ RenderAdapter,
20
+ RenderProps,
21
+ } from "../render-seam.ts"
15
22
  import {
16
23
  ACTION_GLOBAL,
17
24
  BOUNDARY_GLOBAL,
25
+ COPY_CHUNKS_GLOBAL,
26
+ COPY_REALM,
18
27
  DATA_GLOBAL,
19
28
  HANDOVER_ID,
20
29
  LAYOUT_DATA_GLOBAL,
30
+ LOCALES_GLOBAL,
21
31
  ROOT_ATTRIBUTE,
22
32
  ROUTE_GLOBAL,
23
33
  } from "../render-seam.ts"
@@ -111,6 +121,15 @@ export interface RenderPageOptions {
111
121
  /** SSG: the URL of a JSON array of the prerendered paths, handed over in place of
112
122
  * {@link prerenderedPaths} so a large set is fetched once instead of riding in every page. */
113
123
  readonly prerenderedListUrl?: string
124
+ /** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`), handed over to the
125
+ * client; the chunks this page reads its copy from are preloaded. Omit ⇒ none. */
126
+ readonly copyChunks?: Readonly<Record<string, string>>
127
+ /** The page's locale from the app's locale routing: `<html lang dir>` default to it unless `head`
128
+ * sets `lang`, the response carries `Content-Language`, and the adapter reads it as
129
+ * `RenderProps.locale`. Omit ⇒ none of these. */
130
+ readonly locale?: PageLocale
131
+ /** The served locales, handed to the client so it names each page's locale on navigation. */
132
+ readonly locales?: PageLocales
114
133
  /** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
115
134
  * wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
116
135
  readonly revalidate?: number
@@ -184,7 +203,7 @@ export interface RenderPageOptions {
184
203
  * prefix/suffix, tail statics) on the first render and reuses afterwards, skipping their re-assembly.
185
204
  *
186
205
  * Only pass a cache when every shell-shaping input is IDENTICAL across the requests sharing it:
187
- * same `head` content (a static meta chain - never a `meta(data)` function), `title`, `styles`,
206
+ * same `head` content (a static meta chain - never a `meta(data)` function), `locale`, `title`, `styles`,
188
207
  * `cssLoading`, `preload`, `islandScripts`, `clientEntry`, `rootId`, `hydrate`, and `prerenderedPaths`.
189
208
  * Per-request
190
209
  * values (`data`, `params`, `search`, `actionData`, `layoutData`, deferred state) are always assembled
@@ -248,6 +267,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
248
267
  cssLoading: requestedCssLoading,
249
268
  prerenderedPaths = [],
250
269
  prerenderedListUrl,
270
+ copyChunks,
271
+ locale,
251
272
  revalidate,
252
273
  revalidateTags = [],
253
274
  routeId,
@@ -276,6 +297,13 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
276
297
  : prerenderedPaths.length === 0
277
298
  ? ""
278
299
  : `,"${PRERENDERED_GLOBAL}":${serializeData(prerenderedPaths)}`
300
+ const copyHandover =
301
+ copyChunks === undefined ? "" : `,"${COPY_CHUNKS_GLOBAL}":${serializeData(copyChunks)}`
302
+ const localesHandover =
303
+ options.locales === undefined ? "" : `,"${LOCALES_GLOBAL}":${serializeData(options.locales)}`
304
+ // A route's `lang` wins with its own `dir`; otherwise the page's registered locale names both.
305
+ const lang = head?.lang ?? locale?.lang ?? "en"
306
+ const dir = head?.dir ?? (head?.lang === undefined ? locale?.dir : undefined)
279
307
  // Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
280
308
  // data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
281
309
  // also `defer()` - split it too, continuing the id space so a single registry settles both. The
@@ -405,7 +433,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
405
433
  // `<html>` attributes. `lang` defaults to `"en"`; `dir` is omitted entirely when unset, which IS
406
434
  // HTML's `ltr` default. Both attribute-escaped. `client.ts`'s `applyHead` mirrors this exact
407
435
  // defaulting on soft-nav, so a hard load and a client navigation produce the same `<html>`.
408
- const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
436
+ const htmlAttrs = ` lang="${escapeAttr(lang)}"${dir === undefined ? "" : ` dir="${escapeAttr(dir)}"`}`
409
437
  // Marks the container for the client entry when the id is not the one the entry falls back to.
410
438
  const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
411
439
  // A non-hydrated page omits the adapter's hydration bootstrap entirely (Solid's `_$HY` registry
@@ -432,7 +460,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
432
460
  if (hydrate) {
433
461
  // Inert JSON, never executed: the page state needs no nonce and no hash under any CSP.
434
462
  slot.tailPre = `<script type="application/json" id="${HANDOVER_ID}">{"${DATA_GLOBAL}":`
435
- slot.tailPost = `${route}${prerendered}}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`
463
+ slot.tailPost = `${route}${prerendered}${copyHandover}${localesHandover}}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`
436
464
  } else {
437
465
  slot.tailPre = ""
438
466
  slot.tailPost = `${islandTags}</body></html>`
@@ -441,13 +469,18 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
441
469
  // The deferred runtime rides only on a hydrating page (a static page has no client takeover), and
442
470
  // only when something actually deferred - matching the pre-cache emission exactly.
443
471
  const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : ""
444
- const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}${slot.rootOpen}`
472
+ // Per request, so outside the cached shell: one route serves every locale.
473
+ const copyPreloads =
474
+ hydrate && copyChunks !== undefined
475
+ ? copyPreloadLinks(copyChunks, options.path ?? "", lang)
476
+ : ""
477
+ const shellHtml = `${slot.shellPre}${copyPreloads}${runtimeSeam}${slot.shellPost}${slot.rootOpen}`
445
478
  const parts: DocumentParts | undefined =
446
479
  adapter.documentParts === true ? { head: "", body: "" } : undefined
447
480
  const shellOf = (): string =>
448
481
  parts === undefined || (parts.head === "" && parts.body === "")
449
482
  ? shellHtml
450
- : `${slot.shellPre}${runtimeSeam}${parts.head}${slot.shellPost}${parts.body}${slot.rootOpen}`
483
+ : `${slot.shellPre}${copyPreloads}${runtimeSeam}${parts.head}${slot.shellPost}${parts.body}${slot.rootOpen}`
451
484
  // Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
452
485
  // aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
453
486
  const closeRootHtml = "</div>"
@@ -457,6 +490,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
457
490
  ? `${slot.tailPre}${serializeData(forClient)}${action}${layoutTail}${boundaryTail}${componentTail}${slot.tailPost}`
458
491
  : (slot.tailPost as string)
459
492
  const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
493
+ if (locale !== undefined) headers["content-language"] = lang
460
494
  // Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
461
495
  // framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
462
496
  // not be able to mislabel the document or forge the freshness header by passing them here.
@@ -501,6 +535,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
501
535
  ? { boundaries: boundarySplit.forComponent as BoundaryStates }
502
536
  : {}),
503
537
  ...(options.matchChain !== undefined ? { matchChain: options.matchChain } : {}),
538
+ ...(locale === undefined ? {} : { locale }),
504
539
  }
505
540
 
506
541
  // Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
@@ -1171,3 +1206,25 @@ export function unsafeInlineScript(
1171
1206
  content,
1172
1207
  }
1173
1208
  }
1209
+
1210
+ /** The copy chunks a page loads first, as `@nifrajs/i18n/copy` picks them in the browser, so they
1211
+ * download beside the route's code instead of after it. */
1212
+ function copyPreloadLinks(
1213
+ chunks: Readonly<Record<string, string>>,
1214
+ path: string,
1215
+ lang: string,
1216
+ ): string {
1217
+ const realm: unknown = Reflect.get(globalThis, Symbol.for(COPY_REALM))
1218
+ if (typeof realm !== "object" || realm === null) return ""
1219
+ const pageLocales: unknown = Reflect.get(realm, "pageLocales")
1220
+ if (typeof pageLocales !== "function") return ""
1221
+ const locales: unknown = Reflect.apply(pageLocales, realm, [path, lang])
1222
+ if (!Array.isArray(locales)) return ""
1223
+ let links = ""
1224
+ for (const locale of locales) {
1225
+ const url =
1226
+ typeof locale === "string" && Object.hasOwn(chunks, locale) ? chunks[locale] : undefined
1227
+ if (url !== undefined) links += `<link rel="modulepreload" href="${escapeAttr(url)}">`
1228
+ }
1229
+ return links
1230
+ }
@@ -0,0 +1,11 @@
1
+ import type * as TS from "typescript"
2
+
3
+ /** Whether a loaded `typescript` module carries the in-process compiler API (TypeScript 7's does not). */
4
+ export function isTypeScriptCompiler(value: unknown): value is typeof TS {
5
+ return (
6
+ typeof value === "object" &&
7
+ value !== null &&
8
+ "createSourceFile" in value &&
9
+ typeof value.createSourceFile === "function"
10
+ )
11
+ }
@@ -0,0 +1,81 @@
1
+ // A Vite `config` hook is a plain function or the object form `{ handler, order }` Vite accepts for hook
2
+ // ordering; plugins are typed structurally (no `vite` type dep) and every other field is kept by spread.
3
+ type ConfigFn = (config: unknown, env: unknown) => unknown
4
+
5
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
6
+ value !== null && typeof value === "object" && !Array.isArray(value)
7
+
8
+ /**
9
+ * Move a plugin's `optimizeDeps.rollupOptions` to `optimizeDeps.rolldownOptions` under rolldown-vite and
10
+ * drop its `jsx` key, so Vite 8 warns about neither on `nifra dev` or a Vite build.
11
+ *
12
+ * Both are version-skew artifacts of plugins such as `@vitejs/plugin-react@4.x`, which still emit
13
+ * `optimizeDeps.rollupOptions.jsx`:
14
+ *
15
+ * - Vite 8 renamed the dep optimizer's options to `rolldownOptions` and moved jsx under `transform.jsx`,
16
+ * so a stale `jsx` key prints `Invalid input options … "jsx" Invalid key`. The route JSX transform runs
17
+ * through the plugin's own `transform` hook and pre-bundled deps almost never hold raw JSX, so the key
18
+ * is dead and dropping it changes no behavior.
19
+ * - Vite 8 reads `rollupOptions` there only as a deprecated alias of `rolldownOptions` and warns on every
20
+ * resolve, even for `{}`. The remaining keys move to `rolldownOptions`; when the same object already
21
+ * sets `rolldownOptions`, Vite ignores the alias, so it is dropped. `ssr.optimizeDeps` gets the same.
22
+ *
23
+ * Only a plugin's `config` hook output is rewritten, never mutated, and only under rolldown-vite.
24
+ * Non-rolldown Vite is passed through verbatim.
25
+ *
26
+ * FLATTEN FIRST: a Vite plugin factory may return an ARRAY of plugins - `@vitejs/plugin-react`'s `react()`
27
+ * returns `[vite:react-babel, vite:react-refresh]`, and it's `react:react-babel`'s `config` hook that emits
28
+ * the offending `optimizeDeps.rollupOptions.jsx`. `nifra.config.ts` writes `vitePlugins = [react()]`, so the
29
+ * plugin list arrives NESTED (`[[babel, refresh]]`). Without flattening, `.map` sees the inner array (which
30
+ * has no `config`), leaves it untouched, and Vite - which flattens plugin arrays itself before running them
31
+ * - then executes the un-stripped babel hook, so the warning survives. Flattening here (Vite accepts a flat
32
+ * list identically) is what lets the strip reach every real plugin.
33
+ */
34
+ export function normalizeRolldownPlugins(
35
+ plugins: readonly unknown[],
36
+ isRolldown: boolean,
37
+ ): readonly unknown[] {
38
+ if (!isRolldown) return plugins
39
+ const migrateDepOptions = (deps: unknown): unknown => {
40
+ if (!isRecord(deps)) return deps
41
+ const { rollupOptions, ...rest } = deps
42
+ if (rollupOptions === undefined) return deps
43
+ // Vite skips the alias when the same object sets rolldownOptions (mergeConfigRecursively).
44
+ if (rest.rolldownOptions || rollupOptions === null) return rest
45
+ if (!isRecord(rollupOptions)) return { ...rest, rolldownOptions: rollupOptions }
46
+ const { jsx: _dropped, ...kept } = rollupOptions
47
+ return Object.keys(kept).length === 0 ? rest : { ...rest, rolldownOptions: kept }
48
+ }
49
+ const migrateConfig = (returned: unknown): unknown => {
50
+ if (!isRecord(returned)) return returned
51
+ const ssr = isRecord(returned.ssr) ? returned.ssr : undefined
52
+ const deps = migrateDepOptions(returned.optimizeDeps)
53
+ const ssrDeps = migrateDepOptions(ssr?.optimizeDeps)
54
+ if (deps === returned.optimizeDeps && ssrDeps === ssr?.optimizeDeps) return returned
55
+ const out = { ...returned }
56
+ if (deps !== returned.optimizeDeps) out.optimizeDeps = deps
57
+ if (ssr !== undefined && ssrDeps !== ssr.optimizeDeps)
58
+ out.ssr = { ...ssr, optimizeDeps: ssrDeps }
59
+ return out
60
+ }
61
+ return plugins.flat(Number.POSITIVE_INFINITY).map((plugin) => {
62
+ if (!isRecord(plugin)) return plugin
63
+ // `config` may be a plain function or the object form `{ handler, order }`. Wrap the handler either way.
64
+ const hook = plugin.config
65
+ const objectHook = isRecord(hook) ? hook : undefined
66
+ const handler = typeof hook === "function" ? hook : objectHook?.handler
67
+ if (typeof handler !== "function") return plugin
68
+ const wrappedHandler: ConfigFn = (config, env) => {
69
+ const returned: unknown = handler(config, env)
70
+ // The hook may return a promise - normalize both shapes.
71
+ return returned instanceof Promise ? returned.then(migrateConfig) : migrateConfig(returned)
72
+ }
73
+ // Preserve the ORIGINAL shape: a function stays a function; the object form keeps its `order`
74
+ // (collapsing `{ handler, order }` to a bare function would silently drop the hook ordering).
75
+ return {
76
+ ...plugin,
77
+ config:
78
+ objectHook === undefined ? wrappedHandler : { ...objectHook, handler: wrappedHandler },
79
+ }
80
+ })
81
+ }
@@ -21,10 +21,54 @@ import type { RenderAdapter } from "../render-seam.ts"
21
21
  import { createMatcher, PRERENDERED_LIST_PATH } from "../router.ts"
22
22
  import type { EvidenceAncestry } from "./evidence-traversal.ts"
23
23
  import { formatShadowedPages, shadowedPages } from "./mount-shadow.ts"
24
- import { createPageRequestExecutor, type NonceResolver } from "./page-execution.ts"
24
+ import { createPageRequestExecutor, type NonceResolver, type PageI18n } from "./page-execution.ts"
25
25
  import { requestPattern } from "./route-path.ts"
26
26
 
27
27
  export type { NonceResolver } from "./page-execution.ts"
28
+
29
+ /** An app's locale routing as `createWebApp` reads it: what `defineI18nRouting()` from
30
+ * `@nifrajs/i18n/routing` returns. */
31
+ export interface WebAppI18n {
32
+ readonly locales: {
33
+ readonly default: string
34
+ /** Served locale keys, in registry order. */
35
+ readonly served: readonly string[]
36
+ documentMeta(key: string): { readonly lang: string; readonly dir: "ltr" | "rtl" }
37
+ }
38
+ localeOf(path: string): string
39
+ matchSegment(
40
+ value: string | undefined,
41
+ pathname: string,
42
+ ):
43
+ | { readonly kind: "ok" | "not-found" }
44
+ | { readonly kind: "redirect"; readonly location: string }
45
+ }
46
+
47
+ function pageI18n(routing: WebAppI18n): PageI18n {
48
+ const { locales } = routing
49
+ if (
50
+ typeof routing.localeOf !== "function" ||
51
+ typeof routing.matchSegment !== "function" ||
52
+ typeof locales?.documentMeta !== "function" ||
53
+ !Array.isArray(locales.served)
54
+ ) {
55
+ throw new TypeError(
56
+ "[nifra/web] createWebApp `i18n` must be the router defineI18nRouting() from @nifrajs/i18n/routing returns",
57
+ )
58
+ }
59
+ return {
60
+ locales: {
61
+ default: locales.default,
62
+ locales: locales.served.map((key) => {
63
+ const { lang, dir } = locales.documentMeta(key)
64
+ return { key, lang, dir }
65
+ }),
66
+ },
67
+ localeOf: (path) => routing.localeOf(path),
68
+ matchSegment: (value, pathname) => routing.matchSegment(value, pathname),
69
+ }
70
+ }
71
+
28
72
  export interface CreateWebAppOptions<Env = unknown> {
29
73
  readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
30
74
  readonly adapter: RenderAdapter
@@ -181,6 +225,20 @@ export interface CreateWebAppOptions<Env = unknown> {
181
225
  * of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
182
226
  * page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
183
227
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
228
+ /** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`, the server manifest's
229
+ * `copyChunks`). A production client build moves copy tables (`@nifrajs/i18n/copy`) into one chunk per
230
+ * locale; each page lists them for the browser and preloads its own. Omit ⇒ tables keep their values. */
231
+ readonly copyChunks?: Readonly<Record<string, string>>
232
+ /**
233
+ * The app's locale routing, `defineI18nRouting()` from `@nifrajs/i18n/routing`, registered once:
234
+ * export it as `i18n` from `backend/framework.ts`. Every document then takes its locale from the
235
+ * URL: `<html lang dir>` from the registry unless the route's meta sets `lang`, a `Content-Language`
236
+ * header, and the locale each adapter's `useLocale()` and `<I18nProvider>` read. A route under a
237
+ * `[lang]` or `[[lang]]` directory is checked with its `matchSegment()` before any middleware: an
238
+ * unserved locale, or a page missing from it, is a 404, and a non-canonical prefix (`/en/x` when the
239
+ * default is unprefixed) redirects with a 308.
240
+ */
241
+ readonly i18n?: WebAppI18n
184
242
  /**
185
243
  * Activation policy for framework-owned stylesheet links (default `"blocking"`). `"deferred"`
186
244
  * emits `media="print"` links; the generated client waits for them and switches them to `all` before
@@ -442,6 +500,8 @@ export function createWebApp<Env = unknown>(
442
500
  ...(options.routePreload === undefined ? {} : { routePreload: options.routePreload }),
443
501
  ...(options.styles === undefined ? {} : { styles: options.styles }),
444
502
  ...(options.routeStyles === undefined ? {} : { routeStyles: options.routeStyles }),
503
+ ...(options.copyChunks === undefined ? {} : { copyChunks: options.copyChunks }),
504
+ ...(options.i18n === undefined ? {} : { i18n: pageI18n(options.i18n) }),
445
505
  ...(options.cssLoading === undefined ? {} : { cssLoading: options.cssLoading }),
446
506
  ...(options.prerenderedPaths === undefined
447
507
  ? {}