@nifrajs/web 5.0.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 (171) hide show
  1. package/CHANGELOG.md +2970 -0
  2. package/README.md +8 -0
  3. package/dist/build-plan.d.ts +7 -0
  4. package/dist/build-plan.d.ts.map +1 -1
  5. package/dist/build-plan.js +19 -0
  6. package/dist/build-plan.js.map +1 -1
  7. package/dist/build-vite.d.ts +1 -0
  8. package/dist/build-vite.d.ts.map +1 -1
  9. package/dist/build-vite.js +51 -3
  10. package/dist/build-vite.js.map +1 -1
  11. package/dist/build.d.ts +12 -1
  12. package/dist/build.d.ts.map +1 -1
  13. package/dist/build.js +43 -6
  14. package/dist/build.js.map +1 -1
  15. package/dist/client.d.ts +7 -6
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +39 -22
  18. package/dist/client.js.map +1 -1
  19. package/dist/dev-port.d.ts +3 -2
  20. package/dist/dev-port.d.ts.map +1 -1
  21. package/dist/dev-port.js +2 -6
  22. package/dist/dev-port.js.map +1 -1
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/codegen.d.ts +6 -0
  28. package/dist/internal/codegen.d.ts.map +1 -1
  29. package/dist/internal/codegen.js +23 -5
  30. package/dist/internal/codegen.js.map +1 -1
  31. package/dist/internal/copy-split.d.ts +27 -0
  32. package/dist/internal/copy-split.d.ts.map +1 -0
  33. package/dist/internal/copy-split.js +173 -0
  34. package/dist/internal/copy-split.js.map +1 -0
  35. package/dist/internal/package-entries.d.ts +34 -0
  36. package/dist/internal/package-entries.d.ts.map +1 -0
  37. package/dist/internal/package-entries.js +324 -0
  38. package/dist/internal/package-entries.js.map +1 -0
  39. package/dist/internal/page-execution.d.ts +19 -1
  40. package/dist/internal/page-execution.d.ts.map +1 -1
  41. package/dist/internal/page-execution.js +123 -14
  42. package/dist/internal/page-execution.js.map +1 -1
  43. package/dist/internal/parity.d.ts.map +1 -1
  44. package/dist/internal/parity.js +15 -2
  45. package/dist/internal/parity.js.map +1 -1
  46. package/dist/internal/private-env.d.ts +8 -0
  47. package/dist/internal/private-env.d.ts.map +1 -1
  48. package/dist/internal/private-env.js +25 -10
  49. package/dist/internal/private-env.js.map +1 -1
  50. package/dist/internal/render-document.d.ts +15 -46
  51. package/dist/internal/render-document.d.ts.map +1 -1
  52. package/dist/internal/render-document.js +59 -62
  53. package/dist/internal/render-document.js.map +1 -1
  54. package/dist/internal/route-path.d.ts +6 -0
  55. package/dist/internal/route-path.d.ts.map +1 -1
  56. package/dist/internal/route-path.js +11 -0
  57. package/dist/internal/route-path.js.map +1 -1
  58. package/dist/internal/secret-scan.d.ts.map +1 -1
  59. package/dist/internal/secret-scan.js +103 -1
  60. package/dist/internal/secret-scan.js.map +1 -1
  61. package/dist/internal/typescript-api.d.ts +4 -0
  62. package/dist/internal/typescript-api.d.ts.map +1 -0
  63. package/dist/internal/typescript-api.js +8 -0
  64. package/dist/internal/typescript-api.js.map +1 -0
  65. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  66. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  67. package/dist/internal/vite-plugin-compat.js +82 -0
  68. package/dist/internal/vite-plugin-compat.js.map +1 -0
  69. package/dist/internal/web-app.d.ts +38 -2
  70. package/dist/internal/web-app.d.ts.map +1 -1
  71. package/dist/internal/web-app.js +30 -3
  72. package/dist/internal/web-app.js.map +1 -1
  73. package/dist/internal/without-query.d.ts +4 -0
  74. package/dist/internal/without-query.d.ts.map +1 -0
  75. package/dist/internal/without-query.js +9 -0
  76. package/dist/internal/without-query.js.map +1 -0
  77. package/dist/internal/zone-graph.d.ts.map +1 -1
  78. package/dist/internal/zone-graph.js +17 -6
  79. package/dist/internal/zone-graph.js.map +1 -1
  80. package/dist/isr.d.ts +1 -1
  81. package/dist/isr.d.ts.map +1 -1
  82. package/dist/isr.js +15 -2
  83. package/dist/isr.js.map +1 -1
  84. package/dist/manifest.d.ts +9 -0
  85. package/dist/manifest.d.ts.map +1 -1
  86. package/dist/manifest.js +1 -0
  87. package/dist/manifest.js.map +1 -1
  88. package/dist/plugins/import-glob.d.ts +20 -0
  89. package/dist/plugins/import-glob.d.ts.map +1 -0
  90. package/dist/plugins/import-glob.js +401 -0
  91. package/dist/plugins/import-glob.js.map +1 -0
  92. package/dist/plugins/kit.d.ts +11 -0
  93. package/dist/plugins/kit.d.ts.map +1 -1
  94. package/dist/plugins/kit.js +32 -0
  95. package/dist/plugins/kit.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts.map +1 -1
  97. package/dist/plugins/stylex.js +2 -2
  98. package/dist/plugins/stylex.js.map +1 -1
  99. package/dist/plugins/vite-leak-guard.d.ts +6 -0
  100. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  101. package/dist/plugins/vite-leak-guard.js +80 -7
  102. package/dist/plugins/vite-leak-guard.js.map +1 -1
  103. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  104. package/dist/plugins/vite-zone-guard.js +17 -21
  105. package/dist/plugins/vite-zone-guard.js.map +1 -1
  106. package/dist/plugins/zone-guard.d.ts.map +1 -1
  107. package/dist/plugins/zone-guard.js +44 -3
  108. package/dist/plugins/zone-guard.js.map +1 -1
  109. package/dist/prerender.d.ts +5 -1
  110. package/dist/prerender.d.ts.map +1 -1
  111. package/dist/prerender.js +41 -19
  112. package/dist/prerender.js.map +1 -1
  113. package/dist/render-seam.d.ts +60 -2
  114. package/dist/render-seam.d.ts.map +1 -1
  115. package/dist/render-seam.js +33 -0
  116. package/dist/render-seam.js.map +1 -1
  117. package/dist/router.d.ts +3 -2
  118. package/dist/router.d.ts.map +1 -1
  119. package/dist/router.js +13 -9
  120. package/dist/router.js.map +1 -1
  121. package/dist/seo.d.ts +49 -0
  122. package/dist/seo.d.ts.map +1 -0
  123. package/dist/seo.js +48 -0
  124. package/dist/seo.js.map +1 -0
  125. package/dist/server-components/typescript.d.ts.map +1 -1
  126. package/dist/server-components/typescript.js +2 -7
  127. package/dist/server-components/typescript.js.map +1 -1
  128. package/dist/vite.d.ts +1 -28
  129. package/dist/vite.d.ts.map +1 -1
  130. package/dist/vite.js +3 -69
  131. package/dist/vite.js.map +1 -1
  132. package/dist/zones.d.ts +30 -8
  133. package/dist/zones.d.ts.map +1 -1
  134. package/dist/zones.js +185 -46
  135. package/dist/zones.js.map +1 -1
  136. package/package.json +10 -4
  137. package/src/build-plan.ts +21 -0
  138. package/src/build-vite.ts +60 -3
  139. package/src/build.ts +57 -4
  140. package/src/client.ts +42 -22
  141. package/src/dev-port.ts +4 -10
  142. package/src/index.ts +8 -4
  143. package/src/internal/codegen.ts +32 -3
  144. package/src/internal/copy-split.ts +220 -0
  145. package/src/internal/package-entries.ts +331 -0
  146. package/src/internal/page-execution.ts +150 -13
  147. package/src/internal/parity.ts +15 -2
  148. package/src/internal/private-env.ts +27 -13
  149. package/src/internal/render-document.ts +100 -81
  150. package/src/internal/route-path.ts +16 -0
  151. package/src/internal/secret-scan.ts +101 -1
  152. package/src/internal/typescript-api.ts +11 -0
  153. package/src/internal/vite-plugin-compat.ts +81 -0
  154. package/src/internal/web-app.ts +71 -6
  155. package/src/internal/without-query.ts +8 -0
  156. package/src/internal/zone-graph.ts +19 -6
  157. package/src/isr.ts +13 -2
  158. package/src/manifest.ts +10 -0
  159. package/src/plugins/import-glob.ts +392 -0
  160. package/src/plugins/kit.ts +36 -0
  161. package/src/plugins/stylex.ts +2 -1
  162. package/src/plugins/vite-leak-guard.ts +99 -7
  163. package/src/plugins/vite-zone-guard.ts +21 -19
  164. package/src/plugins/zone-guard.ts +41 -2
  165. package/src/prerender.ts +39 -18
  166. package/src/render-seam.ts +88 -2
  167. package/src/router.ts +16 -11
  168. package/src/seo.ts +68 -0
  169. package/src/server-components/typescript.ts +2 -10
  170. package/src/vite.ts +3 -81
  171. package/src/zones.ts +237 -52
package/src/build-vite.ts CHANGED
@@ -39,15 +39,18 @@ import {
39
39
  } from "./css-contract.ts"
40
40
  import { discoverRoutes } from "./fs.ts"
41
41
  import { generateClientEntry, generateServerManifest } from "./index.ts"
42
+ import { loadCopySplit } from "./internal/copy-split.ts"
42
43
  import { viteDedupePackages } from "./internal/identity-policy.ts"
43
44
  import {
44
45
  assertDevelopmentProductionParity,
45
46
  assertIdentityParity,
46
47
  collectDevelopmentParityInput,
47
48
  } from "./internal/parity.ts"
49
+ import { emittedScanFiles, formatSecretFindings, scanForSecrets } from "./internal/secret-scan.ts"
48
50
  import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
49
51
  import { unsupportedBuiltin } from "./internal/target-compat.ts"
50
52
  import { importVite, isViteUnresolved } from "./internal/vite-import.ts"
53
+ import { normalizeRolldownPlugins } from "./internal/vite-plugin-compat.ts"
51
54
  import { scopedName } from "./plugins/css-modules.ts"
52
55
  import { reproduciblePath } from "./plugins/kit.ts"
53
56
  import {
@@ -73,6 +76,7 @@ interface ViteManifestEntry {
73
76
  type ViteBuildManifest = Readonly<Record<string, ViteManifestEntry>>
74
77
 
75
78
  interface ViteModule {
79
+ readonly rolldownVersion?: string
76
80
  build(config: Record<string, unknown>): Promise<unknown>
77
81
  loadEnv(mode: string, envDir: string, prefixes: string): Record<string, string>
78
82
  }
@@ -166,13 +170,21 @@ export async function buildClientVite(options: BuildClientViteOptions): Promise<
166
170
  mkdirSync(outDir, { recursive: true })
167
171
 
168
172
  const routeManifest = discoverRoutes(routesDir)
173
+ const copySplit =
174
+ (options.copySplit ?? options.minify !== false)
175
+ ? await loadCopySplit(root, { skip: [outDir] })
176
+ : undefined
169
177
  // The generated bootstrap, written beside the project so its imports resolve the app's deps (same
170
178
  // reasoning as buildClient). A unique dir avoids clobbering a user file / colliding with parallel builds.
171
179
  const entryDir = mkdtempSync(resolvePath(dirname(routesDir), ".nifra-vite-client-"))
172
180
  const entryFile = `${entryDir}/_nifra-entry.ts`
173
181
  writeFileSync(
174
182
  entryFile,
175
- `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`,
183
+ `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, {
184
+ clientModule,
185
+ resolve,
186
+ copyChunks: copySplit !== undefined,
187
+ })}`,
176
188
  )
177
189
 
178
190
  // Every unique route/layout/_404 file - each an additional entry so Vite emits a named chunk per file
@@ -274,7 +286,28 @@ export async function buildClientVite(options: BuildClientViteOptions): Promise<
274
286
  },
275
287
  // First, so a bare built-in is named before Vite turns it into an anonymous stub.
276
288
  // The asset URL guard last, so it reads each module after any framework plugin made it JavaScript.
277
- plugins: [viteBareBuiltinExternal(), ...(options.vitePlugins ?? []), assetUrlGuard],
289
+ plugins: [
290
+ viteBareBuiltinExternal(),
291
+ ...(copySplit === undefined
292
+ ? []
293
+ : [
294
+ {
295
+ name: "nifra-copy-split",
296
+ // Before a framework plugin compiles the module: the tables are read from its source.
297
+ enforce: "pre" as const,
298
+ transform(code: string, id: string) {
299
+ const out = copySplit.rewrite(id, code)
300
+ // Lines do not move, so the module's map stays the source's.
301
+ return out === undefined ? undefined : { code: out, map: null }
302
+ },
303
+ },
304
+ ]),
305
+ ...normalizeRolldownPlugins(
306
+ options.vitePlugins ?? [],
307
+ vite.rolldownVersion !== undefined,
308
+ ),
309
+ assetUrlGuard,
310
+ ],
278
311
  worker: {
279
312
  plugins: () => {
280
313
  const guard = viteLeakGuard(guardOptions)
@@ -416,6 +449,23 @@ export async function buildClientVite(options: BuildClientViteOptions): Promise<
416
449
  for (const asset of entry.assets ?? []) assets.add(url(asset))
417
450
  }
418
451
 
452
+ // The leak guard scanned the bundle; the copy it no longer holds is scanned here.
453
+ const copy = copySplit?.chunks(publicPath)
454
+ if (copy !== undefined && copy.files.length > 0) {
455
+ const secrets = formatSecretFindings(
456
+ scanForSecrets({
457
+ artifacts: copy.files.flatMap((file) => emittedScanFiles(file.name, file.text)),
458
+ env: guardOptions.secrets.env,
459
+ publicEnvPrefix: options.publicEnvPrefix ?? "PUBLIC_",
460
+ exemptions: exemptions ?? [],
461
+ }),
462
+ )
463
+ if (secrets !== undefined) throw new Error(secrets)
464
+ for (const file of copy.files) writeFileSync(join(outDir, file.name), file.text)
465
+ }
466
+ const copyChunks = copy?.urls ?? {}
467
+ for (const chunk of Object.values(copyChunks)) assets.add(chunk)
468
+
419
469
  const publicFiles =
420
470
  publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
421
471
 
@@ -428,6 +478,7 @@ export async function buildClientVite(options: BuildClientViteOptions): Promise<
428
478
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
429
479
  ...(css.length > 0 ? { css } : {}),
430
480
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
481
+ ...(Object.keys(copyChunks).length > 0 ? { copyChunks } : {}),
431
482
  }
432
483
  assertDevelopmentProductionParity(collectDevelopmentParityInput(routesDir, publicDir), manifest)
433
484
  writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2))
@@ -442,6 +493,7 @@ export interface BuildServerViteOptions {
442
493
  readonly clientEntry: string
443
494
  readonly styles?: readonly string[] | undefined
444
495
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
496
+ readonly copyChunks?: Readonly<Record<string, string>> | undefined
445
497
  /** How framework-owned stylesheet links should activate before hydration (default `"blocking"`). */
446
498
  readonly cssLoading?: CssLoadingMode
447
499
  readonly resolve?: (file: string) => string
@@ -563,6 +615,7 @@ export async function buildServerVite(options: BuildServerViteOptions): Promise<
563
615
  styles,
564
616
  routeStyles,
565
617
  ...(cssLoading !== undefined ? { cssLoading } : {}),
618
+ ...(options.copyChunks !== undefined ? { copyChunks: options.copyChunks } : {}),
566
619
  }),
567
620
  )
568
621
 
@@ -599,7 +652,10 @@ export async function buildServerVite(options: BuildServerViteOptions): Promise<
599
652
  dedupe: ["react", "react-dom", ...singleCopyPackageNames(root)],
600
653
  conditions: [...conditions],
601
654
  },
602
- plugins: [...(options.vitePlugins ?? [])],
655
+ plugins: normalizeRolldownPlugins(
656
+ options.vitePlugins ?? [],
657
+ vite.rolldownVersion !== undefined,
658
+ ),
603
659
  build: {
604
660
  outDir,
605
661
  emptyOutDir: false,
@@ -675,6 +731,7 @@ export const viteBundler: Bundler = {
675
731
  target: input.target,
676
732
  ...(input.styles !== undefined ? { styles: input.styles } : {}),
677
733
  ...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
734
+ ...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
678
735
  ...(input.plugins ? { vitePlugins: input.plugins } : {}),
679
736
  ...(input.define ? { define: input.define } : {}),
680
737
  ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
package/src/build.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  formatServerOnlyLeak,
40
40
  parseBuildTarget,
41
41
  parseManifestClientEntry,
42
+ parseManifestCopyChunks,
42
43
  parseManifestCssLoading,
43
44
  parseManifestRouteStyles,
44
45
  parseManifestStyles,
@@ -50,6 +51,7 @@ import { sanitizeOutputNames } from "./chunk-names.ts"
50
51
  import { type CssLoadingMode, normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.ts"
51
52
  import { discoverRoutes } from "./fs.ts"
52
53
  import { generateClientEntry, generateServerManifest } from "./index.ts"
54
+ import { loadCopySplit } from "./internal/copy-split.ts"
53
55
  import { assertDisposableDir } from "./internal/disposable-dir.ts"
54
56
  import { entryChunks } from "./internal/entry-chunks.ts"
55
57
  import { dedupePolicyFor } from "./internal/identity-policy.ts"
@@ -89,6 +91,7 @@ import {
89
91
  verifyServerGraph,
90
92
  } from "./internal/zone-graph.ts"
91
93
  import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
94
+ import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.ts"
92
95
  import { zoneGuardPlugin } from "./plugins/zone-guard.ts"
93
96
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
94
97
  import { prerenderRoutes } from "./prerender.ts"
@@ -186,6 +189,12 @@ export interface BuildClientOptions {
186
189
  * variable. Each names its rule, its file (or, for an environment value, its variable) and a reason.
187
190
  */
188
191
  readonly secretExemptions?: readonly SecretExemption[]
192
+ /**
193
+ * Move copy tables (`@nifrajs/i18n/copy`) into one chunk per locale, so a page's JavaScript carries
194
+ * only the copy of the locale it renders in (default: on when minifying). The server bundle keeps
195
+ * every table whole; the server entry passes the manifest's `copyChunks` to `createWebApp`.
196
+ */
197
+ readonly copySplit?: boolean
189
198
  }
190
199
 
191
200
  /**
@@ -374,12 +383,14 @@ export function resyncServerManifestSource(
374
383
  routesPrefix: string,
375
384
  ): string {
376
385
  const cssLoading = parseManifestCssLoading(source)
386
+ const copyChunks = parseManifestCopyChunks(source)
377
387
  return generateServerManifest(manifest, {
378
388
  resolve: (file) => `${routesPrefix}${file}`,
379
389
  clientEntry: parseManifestClientEntry(source) ?? "",
380
390
  styles: parseManifestStyles(source),
381
391
  routeStyles: parseManifestRouteStyles(source),
382
392
  ...(cssLoading !== undefined ? { cssLoading } : {}),
393
+ ...(copyChunks !== undefined ? { copyChunks } : {}),
383
394
  lazy: MANIFEST_IS_LAZY.test(source),
384
395
  })
385
396
  }
@@ -426,6 +437,10 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
426
437
  // The bootstrap's filename is `_nifra`-namespaced (not `entry.ts`) so its `[name]` can't collide with
427
438
  // a user route named `entry.tsx` when the CSS mapping below excludes the bootstrap's aggregate CSS.
428
439
  const mode = options.minify === false ? "development" : "production"
440
+ const copySplit =
441
+ (options.copySplit ?? mode === "production")
442
+ ? await loadCopySplit(root, { skip: [outDir] })
443
+ : undefined
429
444
  // PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
430
445
  // falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
431
446
  // in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
@@ -442,7 +457,11 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
442
457
  // an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
443
458
  writeFileSync(
444
459
  entryFile,
445
- `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`,
460
+ `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, {
461
+ clientModule,
462
+ resolve,
463
+ copyChunks: copySplit !== undefined,
464
+ })}`,
446
465
  )
447
466
 
448
467
  // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
@@ -496,6 +515,10 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
496
515
  svelteDedupePlugin(routesDir),
497
516
  serverFnStubPlugin(),
498
517
  ...(options.plugins ?? []),
518
+ // Before the copy split: it claims a module that calls the glob and reads it through the split.
519
+ importGlobPlugin({ root }),
520
+ // Last: it rewrites a module no other plugin claims, and the ones that do read through it.
521
+ ...(copySplit === undefined ? [] : [copySplit.plugin]),
499
522
  ],
500
523
  ...(options.conditions ? { conditions: [...options.conditions] } : {}),
501
524
  // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
@@ -550,6 +573,11 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
550
573
  return { name: out.path, kind: "code", text: await out.text() }
551
574
  }),
552
575
  )
576
+ assertNoImportGlob(
577
+ emitted.flatMap((file) =>
578
+ file.kind === "code" ? [[relative(cwd, file.name), file.text ?? ""] as const] : [],
579
+ ),
580
+ )
553
581
  const clientGraph = withEmittedImports(fromBunMetafile(clientMeta), emitted, publicPath)
554
582
  const verdict = verifyClientGraph(clientGraph, {
555
583
  classifier,
@@ -596,6 +624,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
596
624
  if (unaccounted !== undefined) throw new Error(unaccounted)
597
625
 
598
626
  const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
627
+ const copy = copySplit?.chunks(publicPath)
599
628
  const { sources, originsOf } = graphScanInput(clientGraph, bunModuleSource(cwd), classifier)
600
629
  const secrets = formatSecretFindings(
601
630
  scanForSecrets({
@@ -606,6 +635,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
606
635
  const name = file.name.replace(/^\.\//, "")
607
636
  return emittedScanFiles(name, file.text, originsOf(name))
608
637
  }),
638
+ ...(copy?.files ?? []).flatMap((file) => emittedScanFiles(file.name, file.text)),
609
639
  ...(publicDir !== undefined && existsSync(publicDir)
610
640
  ? publicScanFiles(publicDir, originName(root, resolvePath(publicDir)))
611
641
  : []),
@@ -624,6 +654,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
624
654
  return { kind: out.kind, path }
625
655
  }),
626
656
  )
657
+ for (const file of copy?.files ?? []) writeFileSync(join(outDir, file.name), file.text)
627
658
 
628
659
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
629
660
  // rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
@@ -750,13 +781,15 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
750
781
  const publicFiles =
751
782
  publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
752
783
 
784
+ const copyChunks = copy?.urls ?? {}
753
785
  const manifest: BuildManifest = {
754
786
  entry: entryUrl,
755
- assets: outputs.map((o) => toUrl(o.path)),
787
+ assets: [...outputs.map((o) => toUrl(o.path)), ...Object.values(copyChunks)],
756
788
  routes,
757
789
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
758
790
  ...(css.length > 0 ? { css } : {}),
759
791
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
792
+ ...(Object.keys(copyChunks).length > 0 ? { copyChunks } : {}),
760
793
  }
761
794
  assertDevelopmentProductionParity(collectDevelopmentParityInput(routesDir, publicDir), manifest)
762
795
  writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2))
@@ -780,6 +813,8 @@ export interface BuildServerOptions {
780
813
  readonly styles?: readonly string[] | undefined
781
814
  /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked alongside `styles`. */
782
815
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
816
+ /** Copy chunk URLs by locale (`buildClient`'s `BuildManifest.copyChunks`) - baked as `copyChunks`. */
817
+ readonly copyChunks?: Readonly<Record<string, string>> | undefined
783
818
  /** How framework-owned stylesheet links should activate before hydration (default `"blocking"`). */
784
819
  readonly cssLoading?: CssLoadingMode
785
820
  /** Route/layout file → import specifier in the generated manifest (default: a relative path from the
@@ -1051,6 +1086,7 @@ function formatRefusedFiles(refused: ReadonlyMap<string, string>, root: string):
1051
1086
  return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`
1052
1087
  }
1053
1088
 
1089
+ export { importGlobPlugin } from "./plugins/import-glob.ts"
1054
1090
  export { zoneGuardPlugin } from "./plugins/zone-guard.ts"
1055
1091
 
1056
1092
  /**
@@ -1130,6 +1166,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1130
1166
  styles,
1131
1167
  routeStyles,
1132
1168
  ...(cssLoading !== undefined ? { cssLoading } : {}),
1169
+ ...(options.copyChunks !== undefined ? { copyChunks: options.copyChunks } : {}),
1133
1170
  lazy,
1134
1171
  }),
1135
1172
  )
@@ -1161,6 +1198,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1161
1198
  reactDedupePlugin(entryDir),
1162
1199
  preactDedupePlugin(entryDir),
1163
1200
  ...(options.plugins ?? []),
1201
+ importGlobPlugin({ root: resolvePath(options.root ?? dirname(routesDir)) }),
1164
1202
  ],
1165
1203
  })
1166
1204
  if (!result.success) {
@@ -1227,7 +1265,14 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1227
1265
  // ===================================================================================================
1228
1266
 
1229
1267
  /** The `createWebApp` options a generated server entry can import from the app's framework module. */
1230
- export const SERVER_ENTRY_OPTIONS = ["apiPrefix", "apiStrip", "mounts", "csp", "nonce"] as const
1268
+ export const SERVER_ENTRY_OPTIONS = [
1269
+ "apiPrefix",
1270
+ "apiStrip",
1271
+ "mounts",
1272
+ "csp",
1273
+ "nonce",
1274
+ "i18n",
1275
+ ] as const
1231
1276
  export type ServerEntryOption = (typeof SERVER_ENTRY_OPTIONS)[number]
1232
1277
  /** Each importable option mapped to the specifier of the module that exports it. */
1233
1278
  export type ServerEntryOptionImports = Readonly<Partial<Record<ServerEntryOption, string>>>
@@ -1285,6 +1330,8 @@ export function generateServerEntry(options: {
1285
1330
  readonly hashedFiles?: readonly string[]
1286
1331
  /** Trust the edge platform's client-address header for `c.clientIp`. See {@link ServerEntryClientIp}. */
1287
1332
  readonly clientIp?: ServerEntryClientIp
1333
+ /** The server manifest exports `copyChunks` (the client build split copy tables by locale). */
1334
+ readonly copyChunks?: boolean
1288
1335
  }): string {
1289
1336
  const {
1290
1337
  target,
@@ -1327,7 +1374,9 @@ export function generateServerEntry(options: {
1327
1374
  lines.push(`import { backend } from ${JSON.stringify(backendImport)}`)
1328
1375
  }
1329
1376
  lines.push(
1330
- 'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"',
1377
+ options.copyChunks === true
1378
+ ? 'import { clientEntry, copyChunks, cssLoading, manifest, styles, routeStyles } from "./server-manifest"'
1379
+ : 'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"',
1331
1380
  )
1332
1381
  // Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
1333
1382
  if (target === "cloudflare") lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
@@ -1343,6 +1392,7 @@ export function generateServerEntry(options: {
1343
1392
  " styles,",
1344
1393
  " routeStyles,",
1345
1394
  " cssLoading,",
1395
+ ...(options.copyChunks === true ? [" copyChunks,"] : []),
1346
1396
  ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []),
1347
1397
  ` title: ${JSON.stringify(title)},`,
1348
1398
  // Core's own `{ header }` trust, so the mounted backend and every loader see the derived caller.
@@ -1630,6 +1680,7 @@ export const bunBundler: Bundler = {
1630
1680
  target: input.target,
1631
1681
  ...(input.styles !== undefined ? { styles: input.styles } : {}),
1632
1682
  ...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
1683
+ ...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
1633
1684
  ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1634
1685
  ...(input.define ? { define: input.define } : {}),
1635
1686
  ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
@@ -1813,6 +1864,7 @@ export async function buildTargetWith(
1813
1864
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
1814
1865
  hashedFiles: client.assets,
1815
1866
  ...(options.clientIp !== undefined ? { clientIp: options.clientIp } : {}),
1867
+ ...(client.copyChunks !== undefined ? { copyChunks: true } : {}),
1816
1868
  }),
1817
1869
  )
1818
1870
  const { worker } = await bundler.buildServer({
@@ -1823,6 +1875,7 @@ export async function buildTargetWith(
1823
1875
  target: targetPlan.serverTarget,
1824
1876
  ...(client.css !== undefined ? { styles: client.css } : {}),
1825
1877
  ...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
1878
+ ...(client.copyChunks !== undefined ? { copyChunks: client.copyChunks } : {}),
1826
1879
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1827
1880
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1828
1881
  ...(client.cssLoading !== undefined
package/src/client.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * a full page load). DOM-only - never imported on the server, so the store stays SSR-safe.
6
6
  */
7
7
  import { trustedHeadAttributes } from "./internal/head-attributes.ts"
8
+ import { appUrl } from "./internal/route-path.ts"
8
9
  import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
9
10
  import type { Meta } from "./manifest.ts"
10
11
  import {
@@ -18,6 +19,7 @@ import {
18
19
  setBlockerController,
19
20
  setBrowserNavigate,
20
21
  } from "./navigation.ts"
22
+ import { handoverLocale } from "./render-seam.ts"
21
23
  import { type ClientRouter, redirectOf } from "./router.ts"
22
24
 
23
25
  /** Read the nonce carried by the current server-rendered document, if any. */
@@ -48,6 +50,7 @@ export {
48
50
  resolveNavigate,
49
51
  } from "./navigation.ts"
50
52
  export { createMutation, createQueryClient } from "./query.ts"
53
+ export { localeOfProps } from "./render-seam.ts"
51
54
  export { createClientRouter, createMatcher } from "./router.ts"
52
55
  // Same reason, for the ADAPTER packages (`@nifrajs/web-react` and friends). Every one of them reaches
53
56
  // for these from the root - `searchOfChain` in its `/client` entry, the navigation and query values in
@@ -232,7 +235,7 @@ export function installHistory(
232
235
  router: ClientRouter,
233
236
  options: InstallHistoryOptions = {},
234
237
  ): () => void {
235
- const fallback = options.fallback ?? ((path: string) => location.assign(path))
238
+ const fallback = options.fallback ?? ((path: string) => location.assign(appUrl(path)))
236
239
 
237
240
  // We manage scroll ourselves (save per history entry, restore after the navigated content
238
241
  // renders), so disable the browser's native restoration which would otherwise fight us.
@@ -364,7 +367,7 @@ export function installHistory(
364
367
  if (mode === "replace") {
365
368
  // Replace the current entry (same index) with the new URL. The entry we're leaving is discarded, so
366
369
  // its scroll isn't worth saving; the new route scrolls to its fragment target, else the top.
367
- history.replaceState(entryState, "", path)
370
+ history.replaceState(entryState, "", appUrl(path))
368
371
  pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
369
372
  } else {
370
373
  // Save the leaving entry's scroll (spread keeps its nifraIndex), push a fresh higher-indexed entry
@@ -372,7 +375,7 @@ export function installHistory(
372
375
  history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
373
376
  index += 1
374
377
  entryState.nifraIndex = index
375
- history.pushState(entryState, "", path)
378
+ history.pushState(entryState, "", appUrl(path))
376
379
  pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
377
380
  }
378
381
  here = url.pathname + url.search + url.hash
@@ -555,9 +558,9 @@ export function installHistory(
555
558
  if (expected === undefined) {
556
559
  history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
557
560
  index += 1
558
- history.pushState({ nifraIndex: index }, "", path)
561
+ history.pushState({ nifraIndex: index }, "", appUrl(path))
559
562
  } else {
560
- history.replaceState(history.state, "", path + location.hash)
563
+ history.replaceState(history.state, "", appUrl(path) + location.hash)
561
564
  }
562
565
  here = location.pathname + location.search + location.hash
563
566
  pendingScroll = location.hash !== "" ? { hash: hashId(location.hash) } : { pos: [0, 0] }
@@ -610,28 +613,29 @@ export function installHistory(
610
613
  }
611
614
  }
612
615
 
613
- /**
614
- * Intercept submissions of same-origin `<form method="post">` whose action targets an app route:
615
- * submit via the router (no full reload - POST the action, then revalidate the active loader). On
616
- * failure it falls back to a native submit, so the form still works. Returns a teardown function.
617
- */
618
616
  /**
619
617
  * Sync the document head to a route's resolved {@link Meta} on client navigation. Sets the title
620
618
  * (when provided) and replaces the **managed** (`data-nifra`) `<meta>`/`<link>` tags - static head
621
619
  * content (charset, hand-written tags) is never touched. SSR injects the same `data-nifra` tags, so
622
620
  * the first navigation cleanly takes over from the server-rendered head.
623
621
  */
624
- export function applyHead(head: Meta): void {
622
+ export function applyHead(head: Meta, path?: string): void {
625
623
  const documentNonce = currentDocumentNonce()
626
624
  if (head.title !== undefined) document.title = head.title
627
625
  // `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
628
- // route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
629
- // removed when unset. Anything laxer drifts on a multilingual site - navigating /ur → /en would leave
630
- // `dir="rtl"` behind and lay the English page out right-to-left, a bug a hard reload would not show.
626
+ // route omits it), mirroring the SSR shell's defaulting exactly: a meta `lang` wins, then the page's
627
+ // registered locale, then `"en"`; `dir` is removed when unset. Anything laxer drifts on a
628
+ // multilingual site - navigating /ur → /en would leave `dir="rtl"` behind and lay the English page
629
+ // out right-to-left, a bug a hard reload would not show.
630
+ const locale =
631
+ head.lang === undefined
632
+ ? handoverLocale(path ?? (typeof location === "undefined" ? "" : location.pathname))
633
+ : undefined
634
+ const dir = head.dir ?? locale?.dir
631
635
  const root = document.documentElement
632
- root.setAttribute("lang", head.lang ?? "en")
633
- if (head.dir === undefined) root.removeAttribute("dir")
634
- else root.setAttribute("dir", head.dir)
636
+ root.setAttribute("lang", head.lang ?? locale?.lang ?? "en")
637
+ if (dir === undefined) root.removeAttribute("dir")
638
+ else root.setAttribute("dir", dir)
635
639
  for (const el of document.head.querySelectorAll("[data-nifra]")) el.remove()
636
640
  // Values follow the same HTML attribute conventions as the SSR `tagAttrs`: a string sets the value,
637
641
  // `true` sets the bare boolean attribute, `false`/`undefined` skip it - so a soft-nav head matches
@@ -689,6 +693,17 @@ export function applyHead(head: Meta): void {
689
693
  }
690
694
  }
691
695
 
696
+ // A control named like a form property shadows it (`<input name="target">` makes `form.target` that
697
+ // input), so the form's own state is read through the prototype accessors.
698
+ function formProperty(form: HTMLFormElement, name: "action" | "method" | "target"): string {
699
+ return String(Reflect.get(HTMLFormElement.prototype, name, form))
700
+ }
701
+
702
+ /**
703
+ * Intercept submissions of same-origin `<form method="post">` whose action targets an app route:
704
+ * submit via the router (no full reload - POST the action, then revalidate the active loader). On
705
+ * failure it falls back to a native submit, so the form still works. Returns a teardown function.
706
+ */
692
707
  export function installForms(router: ClientRouter): () => void {
693
708
  // Forms whose data submit failed before the server answered: their next submit event is the native
694
709
  // post that replaces it.
@@ -702,20 +717,25 @@ export function installForms(router: ClientRouter): () => void {
702
717
  // name=value is part of the submission (`<button name="intent" value="delete">`).
703
718
  // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a submit event's submitter is the submit button or input that fired it, or null
704
719
  const submitter = event.submitter as HTMLButtonElement | null
705
- const method = submitter?.hasAttribute("formmethod") ? submitter.formMethod : form.method
720
+ const method = submitter?.hasAttribute("formmethod")
721
+ ? submitter.formMethod
722
+ : formProperty(form, "method")
706
723
  if (method.toLowerCase() !== "post") return // GET forms submit natively
707
- const target = submitter?.hasAttribute("formtarget") ? submitter.formTarget : form.target
724
+ const target = submitter?.hasAttribute("formtarget")
725
+ ? submitter.formTarget
726
+ : formProperty(form, "target")
708
727
  if (target !== "" && target !== "_self") return // it loads in another browsing context
709
728
  // Resolved (or the current document URL if unset).
710
729
  const url = sameDocumentUrl(
711
- submitter?.hasAttribute("formaction") ? submitter.formAction : form.action,
730
+ submitter?.hasAttribute("formaction") ? submitter.formAction : formProperty(form, "action"),
712
731
  )
713
732
  if (url === null) return
714
733
  if (router.match(url.pathname) === null) return // not an app route → native submit
715
734
  event.preventDefault()
716
735
  // `data-nifra-revalidate="false"` opts out of the post-action loader revalidation (the action's
717
736
  // actionData drives the update); absent or any other value keeps the default revalidation.
718
- const revalidate = form.dataset.nifraRevalidate !== "false"
737
+ const revalidate =
738
+ Element.prototype.getAttribute.call(form, "data-nifra-revalidate") !== "false"
719
739
  const body = new FormData(form, submitter)
720
740
  router.submit(url.pathname + url.search, body, { revalidate }).catch((error) => {
721
741
  const to = redirectOf(error)
@@ -724,7 +744,7 @@ export function installForms(router: ClientRouter): () => void {
724
744
  else {
725
745
  // Nothing reached the server - post natively, through the same button.
726
746
  native.add(form)
727
- form.requestSubmit(submitter)
747
+ HTMLFormElement.prototype.requestSubmit.call(form, submitter)
728
748
  }
729
749
  })
730
750
  }
package/src/dev-port.ts CHANGED
@@ -38,13 +38,14 @@ export const explainBindFailure = (err: unknown, port: number): Error =>
38
38
 
39
39
  /** The bits of a Node server {@link listenOrExplain} touches - structural, so a test can fake it. */
40
40
  export interface ListenTarget {
41
- listen(port: number, cb: () => void): void
41
+ listen(port: number, hostname: string, cb: () => void): void
42
42
  once(event: "error", cb: (err: unknown) => void): void
43
43
  removeListener(event: "error", cb: (err: unknown) => void): void
44
44
  }
45
45
 
46
46
  /**
47
47
  * `listen`, but a bind failure becomes a readable nifra error instead of Node's raw internal throw.
48
+ * The hostname is required: omitting it binds every interface, and a dev server is local-only.
48
49
  *
49
50
  * Without an `error` listener attached, Node throws from deep inside `node:events`, the new process dies
50
51
  * in the background, and nothing connects that death to the stale page in front of you.
@@ -52,7 +53,7 @@ export interface ListenTarget {
52
53
  export function listenOrExplain(
53
54
  server: ListenTarget,
54
55
  port: number,
55
- hostname?: string,
56
+ hostname: string,
56
57
  ): Promise<void> {
57
58
  return new Promise<void>((resolve, reject) => {
58
59
  const onError = (err: unknown): void => reject(explainBindFailure(err, port))
@@ -63,13 +64,6 @@ export function listenOrExplain(
63
64
  server.removeListener("error", onError)
64
65
  resolve()
65
66
  }
66
- if (hostname === undefined) server.listen(port, onListening)
67
- else {
68
- ;(
69
- server as ListenTarget & {
70
- listen(port: number, hostname: string, cb: () => void): void
71
- }
72
- ).listen(port, hostname, onListening)
73
- }
67
+ server.listen(port, hostname, onListening)
74
68
  })
75
69
  }
package/src/index.ts CHANGED
@@ -88,12 +88,8 @@ export {
88
88
  // `import { createClientRouter, createMatcher, mergeHeads, resolveChainMeta } from "@nifrajs/web/client"`.
89
89
  export { mergeHeads, resolveChainMeta, resolveMeta } from "./internal/head-merge.ts"
90
90
  export {
91
- canonical,
92
91
  gone,
93
- jsonLd,
94
92
  notFound,
95
- type OpenGraphInput,
96
- openGraph,
97
93
  type RedirectOptions,
98
94
  type RenderAssemblyCache,
99
95
  type RenderedPage,
@@ -125,6 +121,7 @@ export {
125
121
  type CreateWebAppOptions,
126
122
  createWebApp,
127
123
  type NonceResolver,
124
+ type WebAppI18n,
128
125
  webProjectEvidence,
129
126
  } from "./internal/web-app.ts"
130
127
  // ISR (incremental static regeneration): a pluggable cache store + the `withISR` stale-while-revalidate
@@ -257,9 +254,15 @@ export {
257
254
  ACTION_GLOBAL,
258
255
  BOUNDARY_GLOBAL,
259
256
  DATA_GLOBAL,
257
+ type DocumentParts,
260
258
  HANDOVER_ID,
261
259
  LAYOUT_DATA_GLOBAL,
260
+ LOCALES_GLOBAL,
261
+ localeOfProps,
262
262
  type MatchChain,
263
+ type PageLocale,
264
+ type PageLocales,
265
+ pageLocaleIn,
263
266
  type RenderAdapter,
264
267
  type RenderProps,
265
268
  type RenderStreamOptions,
@@ -298,6 +301,7 @@ export {
298
301
  // ctx + `renderPage`) and a client adapter mount call this with the same URL + schema, so the two sides
299
302
  // produce the identical value by construction. An adapter's `useSearch` binding reads its result.
300
303
  export { type SearchOf, searchOf, searchOfChain, serializeSearch } from "./search.ts"
304
+ export { canonical, jsonLd, type OpenGraphInput, openGraph } from "./seo.ts"
301
305
  export {
302
306
  type HtmlSanitizer,
303
307
  type SanitizedHtml,