@nifrajs/web 5.1.0 → 5.3.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 (162) hide show
  1. package/CHANGELOG.md +3042 -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 +23 -3
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +224 -16
  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/csp.d.ts +2 -0
  19. package/dist/csp.d.ts.map +1 -1
  20. package/dist/csp.js +2 -0
  21. package/dist/csp.js.map +1 -1
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +23 -3
  24. package/dist/deferred.js.map +1 -1
  25. package/dist/index.d.ts +4 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/codegen.d.ts +6 -0
  30. package/dist/internal/codegen.d.ts.map +1 -1
  31. package/dist/internal/codegen.js +23 -5
  32. package/dist/internal/codegen.js.map +1 -1
  33. package/dist/internal/copy-split.d.ts +27 -0
  34. package/dist/internal/copy-split.d.ts.map +1 -0
  35. package/dist/internal/copy-split.js +173 -0
  36. package/dist/internal/copy-split.js.map +1 -0
  37. package/dist/internal/deferred-free.d.ts +3 -0
  38. package/dist/internal/deferred-free.d.ts.map +1 -0
  39. package/dist/internal/deferred-free.js +15 -0
  40. package/dist/internal/deferred-free.js.map +1 -0
  41. package/dist/internal/module-preparation.d.ts +7 -0
  42. package/dist/internal/module-preparation.d.ts.map +1 -0
  43. package/dist/internal/module-preparation.js +24 -0
  44. package/dist/internal/module-preparation.js.map +1 -0
  45. package/dist/internal/output-guard.d.ts +2 -0
  46. package/dist/internal/output-guard.d.ts.map +1 -1
  47. package/dist/internal/output-guard.js +51 -9
  48. package/dist/internal/output-guard.js.map +1 -1
  49. package/dist/internal/page-execution.d.ts +20 -1
  50. package/dist/internal/page-execution.d.ts.map +1 -1
  51. package/dist/internal/page-execution.js +115 -50
  52. package/dist/internal/page-execution.js.map +1 -1
  53. package/dist/internal/parity.d.ts.map +1 -1
  54. package/dist/internal/parity.js +3 -1
  55. package/dist/internal/parity.js.map +1 -1
  56. package/dist/internal/prerendered-pages.d.ts +42 -0
  57. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  58. package/dist/internal/prerendered-pages.js +92 -0
  59. package/dist/internal/prerendered-pages.js.map +1 -0
  60. package/dist/internal/public-url.d.ts +7 -0
  61. package/dist/internal/public-url.d.ts.map +1 -1
  62. package/dist/internal/public-url.js +13 -0
  63. package/dist/internal/public-url.js.map +1 -1
  64. package/dist/internal/render-document.d.ts +11 -2
  65. package/dist/internal/render-document.d.ts.map +1 -1
  66. package/dist/internal/render-document.js +131 -41
  67. package/dist/internal/render-document.js.map +1 -1
  68. package/dist/internal/request-url.d.ts +4 -0
  69. package/dist/internal/request-url.d.ts.map +1 -1
  70. package/dist/internal/request-url.js +24 -0
  71. package/dist/internal/request-url.js.map +1 -1
  72. package/dist/internal/secret-scan.d.ts +4 -0
  73. package/dist/internal/secret-scan.d.ts.map +1 -1
  74. package/dist/internal/secret-scan.js +8 -1
  75. package/dist/internal/secret-scan.js.map +1 -1
  76. package/dist/internal/typescript-api.d.ts +4 -0
  77. package/dist/internal/typescript-api.d.ts.map +1 -0
  78. package/dist/internal/typescript-api.js +8 -0
  79. package/dist/internal/typescript-api.js.map +1 -0
  80. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  81. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  82. package/dist/internal/vite-plugin-compat.js +82 -0
  83. package/dist/internal/vite-plugin-compat.js.map +1 -0
  84. package/dist/internal/web-app.d.ts +45 -0
  85. package/dist/internal/web-app.d.ts.map +1 -1
  86. package/dist/internal/web-app.js +32 -1
  87. package/dist/internal/web-app.js.map +1 -1
  88. package/dist/isr.d.ts +1 -0
  89. package/dist/isr.d.ts.map +1 -1
  90. package/dist/isr.js +7 -4
  91. package/dist/isr.js.map +1 -1
  92. package/dist/plugins/import-glob.d.ts +20 -0
  93. package/dist/plugins/import-glob.d.ts.map +1 -0
  94. package/dist/plugins/import-glob.js +401 -0
  95. package/dist/plugins/import-glob.js.map +1 -0
  96. package/dist/plugins/kit.d.ts +11 -0
  97. package/dist/plugins/kit.d.ts.map +1 -1
  98. package/dist/plugins/kit.js +32 -0
  99. package/dist/plugins/kit.js.map +1 -1
  100. package/dist/plugins/stylex.d.ts.map +1 -1
  101. package/dist/plugins/stylex.js +2 -2
  102. package/dist/plugins/stylex.js.map +1 -1
  103. package/dist/prerender.d.ts +4 -0
  104. package/dist/prerender.d.ts.map +1 -1
  105. package/dist/prerender.js +9 -1
  106. package/dist/prerender.js.map +1 -1
  107. package/dist/public-dir.d.ts +15 -0
  108. package/dist/public-dir.d.ts.map +1 -1
  109. package/dist/public-dir.js +91 -12
  110. package/dist/public-dir.js.map +1 -1
  111. package/dist/render-seam.d.ts +42 -2
  112. package/dist/render-seam.d.ts.map +1 -1
  113. package/dist/render-seam.js +33 -0
  114. package/dist/render-seam.js.map +1 -1
  115. package/dist/router.d.ts +3 -2
  116. package/dist/router.d.ts.map +1 -1
  117. package/dist/router.js +4 -4
  118. package/dist/router.js.map +1 -1
  119. package/dist/search.d.ts.map +1 -1
  120. package/dist/search.js +2 -0
  121. package/dist/search.js.map +1 -1
  122. package/dist/server-components/typescript.d.ts.map +1 -1
  123. package/dist/server-components/typescript.js +2 -7
  124. package/dist/server-components/typescript.js.map +1 -1
  125. package/dist/vite.d.ts +7 -28
  126. package/dist/vite.d.ts.map +1 -1
  127. package/dist/vite.js +130 -123
  128. package/dist/vite.js.map +1 -1
  129. package/package.json +5 -4
  130. package/src/build-plan.ts +21 -0
  131. package/src/build-vite.ts +60 -3
  132. package/src/build.ts +283 -39
  133. package/src/client.ts +15 -7
  134. package/src/csp.ts +4 -0
  135. package/src/deferred.ts +20 -3
  136. package/src/index.ts +9 -0
  137. package/src/internal/codegen.ts +32 -3
  138. package/src/internal/copy-split.ts +220 -0
  139. package/src/internal/deferred-free.ts +15 -0
  140. package/src/internal/module-preparation.ts +30 -0
  141. package/src/internal/output-guard.ts +51 -9
  142. package/src/internal/page-execution.ts +137 -53
  143. package/src/internal/parity.ts +2 -1
  144. package/src/internal/prerendered-pages.ts +124 -0
  145. package/src/internal/public-url.ts +13 -0
  146. package/src/internal/render-document.ts +179 -58
  147. package/src/internal/request-url.ts +24 -0
  148. package/src/internal/secret-scan.ts +9 -1
  149. package/src/internal/typescript-api.ts +11 -0
  150. package/src/internal/vite-plugin-compat.ts +81 -0
  151. package/src/internal/web-app.ts +82 -2
  152. package/src/isr.ts +7 -4
  153. package/src/plugins/import-glob.ts +392 -0
  154. package/src/plugins/kit.ts +36 -0
  155. package/src/plugins/stylex.ts +2 -1
  156. package/src/prerender.ts +13 -1
  157. package/src/public-dir.ts +107 -12
  158. package/src/render-seam.ts +65 -2
  159. package/src/router.ts +7 -6
  160. package/src/search.ts +1 -0
  161. package/src/server-components/typescript.ts +2 -10
  162. package/src/vite.ts +133 -135
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"
@@ -58,8 +60,12 @@ import {
58
60
  assertIdentityParity,
59
61
  collectDevelopmentParityInput,
60
62
  } from "./internal/parity.ts"
63
+ import {
64
+ type ServerPrerenderedPage,
65
+ servablePrerenderedPages,
66
+ } from "./internal/prerendered-pages.ts"
61
67
  import { privateEnvCheck } from "./internal/private-env.ts"
62
- import { publicUrlPath } from "./internal/public-url.ts"
68
+ import { isHiddenPublicPath, publicUrlPath } from "./internal/public-url.ts"
63
69
  import {
64
70
  assertNoSecrets,
65
71
  buildEnvironment,
@@ -69,6 +75,7 @@ import {
69
75
  originName,
70
76
  publicScanFiles,
71
77
  type SecretExemption,
78
+ SecretScanError,
72
79
  scanForSecrets,
73
80
  textOf,
74
81
  } from "./internal/secret-scan.ts"
@@ -89,9 +96,10 @@ import {
89
96
  verifyServerGraph,
90
97
  } from "./internal/zone-graph.ts"
91
98
  import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
99
+ import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.ts"
92
100
  import { zoneGuardPlugin } from "./plugins/zone-guard.ts"
93
101
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
94
- import { prerenderRoutes } from "./prerender.ts"
102
+ import { type PrerenderOptions, prerenderRoutes } from "./prerender.ts"
95
103
  import { CONTENT_TYPES, IMMUTABLE, ONE_DAY } from "./public-dir.ts"
96
104
  import { createZoneClassifier } from "./zones.ts"
97
105
 
@@ -125,6 +133,8 @@ interface BunMetafile {
125
133
  >
126
134
  }
127
135
 
136
+ // A page a bun or node build answers from its file (`generateServerEntry`'s `prerenderedPages`).
137
+ export type { ServerPrerenderedPage } from "./internal/prerendered-pages.ts"
128
138
  // The secret scan every client build, `public/` copy and prerender runs.
129
139
  export type { SecretExemption, SecretRule } from "./internal/secret-scan.ts"
130
140
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
@@ -186,6 +196,12 @@ export interface BuildClientOptions {
186
196
  * variable. Each names its rule, its file (or, for an environment value, its variable) and a reason.
187
197
  */
188
198
  readonly secretExemptions?: readonly SecretExemption[]
199
+ /**
200
+ * Move copy tables (`@nifrajs/i18n/copy`) into one chunk per locale, so a page's JavaScript carries
201
+ * only the copy of the locale it renders in (default: on when minifying). The server bundle keeps
202
+ * every table whole; the server entry passes the manifest's `copyChunks` to `createWebApp`.
203
+ */
204
+ readonly copySplit?: boolean
189
205
  }
190
206
 
191
207
  /**
@@ -331,7 +347,9 @@ export function cloudflareRouteRules(
331
347
 
332
348
  /** The built asset map - the server reads `entry` for the client script + serves `assets`. */
333
349
  /**
334
- * Copy `from` into `to`, returning the URL paths copied (sorted).
350
+ * Copy `from` into `to`, returning the URL paths copied (sorted). Hidden entries other than
351
+ * `.well-known` are skipped, as development never serves them: every target ships this list, and a
352
+ * CDN-hosted one serves whatever was copied.
335
353
  *
336
354
  * Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
337
355
  * `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
@@ -342,6 +360,7 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
342
360
  const sourceReal = await realpath(source)
343
361
  const copied: string[] = []
344
362
  for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
363
+ if (isHiddenPublicPath(rel)) continue
345
364
  const candidate = join(source, rel)
346
365
  const candidateStat = await lstat(candidate)
347
366
  if (candidateStat.isSymbolicLink()) {
@@ -374,12 +393,14 @@ export function resyncServerManifestSource(
374
393
  routesPrefix: string,
375
394
  ): string {
376
395
  const cssLoading = parseManifestCssLoading(source)
396
+ const copyChunks = parseManifestCopyChunks(source)
377
397
  return generateServerManifest(manifest, {
378
398
  resolve: (file) => `${routesPrefix}${file}`,
379
399
  clientEntry: parseManifestClientEntry(source) ?? "",
380
400
  styles: parseManifestStyles(source),
381
401
  routeStyles: parseManifestRouteStyles(source),
382
402
  ...(cssLoading !== undefined ? { cssLoading } : {}),
403
+ ...(copyChunks !== undefined ? { copyChunks } : {}),
383
404
  lazy: MANIFEST_IS_LAZY.test(source),
384
405
  })
385
406
  }
@@ -426,6 +447,10 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
426
447
  // The bootstrap's filename is `_nifra`-namespaced (not `entry.ts`) so its `[name]` can't collide with
427
448
  // a user route named `entry.tsx` when the CSS mapping below excludes the bootstrap's aggregate CSS.
428
449
  const mode = options.minify === false ? "development" : "production"
450
+ const copySplit =
451
+ (options.copySplit ?? mode === "production")
452
+ ? await loadCopySplit(root, { skip: [outDir] })
453
+ : undefined
429
454
  // PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
430
455
  // falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
431
456
  // in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
@@ -442,7 +467,11 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
442
467
  // an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
443
468
  writeFileSync(
444
469
  entryFile,
445
- `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`,
470
+ `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, {
471
+ clientModule,
472
+ resolve,
473
+ copyChunks: copySplit !== undefined,
474
+ })}`,
446
475
  )
447
476
 
448
477
  // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
@@ -496,6 +525,10 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
496
525
  svelteDedupePlugin(routesDir),
497
526
  serverFnStubPlugin(),
498
527
  ...(options.plugins ?? []),
528
+ // Before the copy split: it claims a module that calls the glob and reads it through the split.
529
+ importGlobPlugin({ root }),
530
+ // Last: it rewrites a module no other plugin claims, and the ones that do read through it.
531
+ ...(copySplit === undefined ? [] : [copySplit.plugin]),
499
532
  ],
500
533
  ...(options.conditions ? { conditions: [...options.conditions] } : {}),
501
534
  // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
@@ -550,6 +583,11 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
550
583
  return { name: out.path, kind: "code", text: await out.text() }
551
584
  }),
552
585
  )
586
+ assertNoImportGlob(
587
+ emitted.flatMap((file) =>
588
+ file.kind === "code" ? [[relative(cwd, file.name), file.text ?? ""] as const] : [],
589
+ ),
590
+ )
553
591
  const clientGraph = withEmittedImports(fromBunMetafile(clientMeta), emitted, publicPath)
554
592
  const verdict = verifyClientGraph(clientGraph, {
555
593
  classifier,
@@ -596,6 +634,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
596
634
  if (unaccounted !== undefined) throw new Error(unaccounted)
597
635
 
598
636
  const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
637
+ const copy = copySplit?.chunks(publicPath)
599
638
  const { sources, originsOf } = graphScanInput(clientGraph, bunModuleSource(cwd), classifier)
600
639
  const secrets = formatSecretFindings(
601
640
  scanForSecrets({
@@ -606,6 +645,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
606
645
  const name = file.name.replace(/^\.\//, "")
607
646
  return emittedScanFiles(name, file.text, originsOf(name))
608
647
  }),
648
+ ...(copy?.files ?? []).flatMap((file) => emittedScanFiles(file.name, file.text)),
609
649
  ...(publicDir !== undefined && existsSync(publicDir)
610
650
  ? publicScanFiles(publicDir, originName(root, resolvePath(publicDir)))
611
651
  : []),
@@ -624,6 +664,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
624
664
  return { kind: out.kind, path }
625
665
  }),
626
666
  )
667
+ for (const file of copy?.files ?? []) writeFileSync(join(outDir, file.name), file.text)
627
668
 
628
669
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
629
670
  // rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
@@ -750,13 +791,15 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
750
791
  const publicFiles =
751
792
  publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
752
793
 
794
+ const copyChunks = copy?.urls ?? {}
753
795
  const manifest: BuildManifest = {
754
796
  entry: entryUrl,
755
- assets: outputs.map((o) => toUrl(o.path)),
797
+ assets: [...outputs.map((o) => toUrl(o.path)), ...Object.values(copyChunks)],
756
798
  routes,
757
799
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
758
800
  ...(css.length > 0 ? { css } : {}),
759
801
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
802
+ ...(Object.keys(copyChunks).length > 0 ? { copyChunks } : {}),
760
803
  }
761
804
  assertDevelopmentProductionParity(collectDevelopmentParityInput(routesDir, publicDir), manifest)
762
805
  writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2))
@@ -780,6 +823,8 @@ export interface BuildServerOptions {
780
823
  readonly styles?: readonly string[] | undefined
781
824
  /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked alongside `styles`. */
782
825
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
826
+ /** Copy chunk URLs by locale (`buildClient`'s `BuildManifest.copyChunks`) - baked as `copyChunks`. */
827
+ readonly copyChunks?: Readonly<Record<string, string>> | undefined
783
828
  /** How framework-owned stylesheet links should activate before hydration (default `"blocking"`). */
784
829
  readonly cssLoading?: CssLoadingMode
785
830
  /** Route/layout file → import specifier in the generated manifest (default: a relative path from the
@@ -1051,6 +1096,7 @@ function formatRefusedFiles(refused: ReadonlyMap<string, string>, root: string):
1051
1096
  return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`
1052
1097
  }
1053
1098
 
1099
+ export { importGlobPlugin } from "./plugins/import-glob.ts"
1054
1100
  export { zoneGuardPlugin } from "./plugins/zone-guard.ts"
1055
1101
 
1056
1102
  /**
@@ -1130,6 +1176,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1130
1176
  styles,
1131
1177
  routeStyles,
1132
1178
  ...(cssLoading !== undefined ? { cssLoading } : {}),
1179
+ ...(options.copyChunks !== undefined ? { copyChunks: options.copyChunks } : {}),
1133
1180
  lazy,
1134
1181
  }),
1135
1182
  )
@@ -1161,6 +1208,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1161
1208
  reactDedupePlugin(entryDir),
1162
1209
  preactDedupePlugin(entryDir),
1163
1210
  ...(options.plugins ?? []),
1211
+ importGlobPlugin({ root: resolvePath(options.root ?? dirname(routesDir)) }),
1164
1212
  ],
1165
1213
  })
1166
1214
  if (!result.success) {
@@ -1227,7 +1275,14 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1227
1275
  // ===================================================================================================
1228
1276
 
1229
1277
  /** 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
1278
+ export const SERVER_ENTRY_OPTIONS = [
1279
+ "apiPrefix",
1280
+ "apiStrip",
1281
+ "mounts",
1282
+ "csp",
1283
+ "nonce",
1284
+ "i18n",
1285
+ ] as const
1231
1286
  export type ServerEntryOption = (typeof SERVER_ENTRY_OPTIONS)[number]
1232
1287
  /** Each importable option mapped to the specifier of the module that exports it. */
1233
1288
  export type ServerEntryOptionImports = Readonly<Partial<Record<ServerEntryOption, string>>>
@@ -1251,6 +1306,9 @@ const PLATFORM_CLIENT_IP_HEADERS: Readonly<Partial<Record<BuildTarget, string>>>
1251
1306
  vercel: "x-real-ip",
1252
1307
  }
1253
1308
 
1309
+ /** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
1310
+ const PRERENDERED_DIR = "_prerendered"
1311
+
1254
1312
  /**
1255
1313
  * Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
1256
1314
  * the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
@@ -1285,6 +1343,11 @@ export function generateServerEntry(options: {
1285
1343
  readonly hashedFiles?: readonly string[]
1286
1344
  /** Trust the edge platform's client-address header for `c.clientIp`. See {@link ServerEntryClientIp}. */
1287
1345
  readonly clientIp?: ServerEntryClientIp
1346
+ /** The server manifest exports `copyChunks` (the client build split copy tables by locale). */
1347
+ readonly copyChunks?: boolean
1348
+ /** `bun` / `node` only: the pages the build prerendered into `_prerendered/`, by request path, which
1349
+ * the app answers from their file (see `createWebApp`'s `prerenderedPages`). */
1350
+ readonly prerenderedPages?: Readonly<Record<string, ServerPrerenderedPage>>
1288
1351
  }): string {
1289
1352
  const {
1290
1353
  target,
@@ -1316,7 +1379,22 @@ export function generateServerEntry(options: {
1316
1379
  if (target === "static") {
1317
1380
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
1318
1381
  }
1319
- const lines: string[] = ['import { createWebApp } from "@nifrajs/web"']
1382
+ // Bun and Node serve the client bundle through `servePublicDir`. Deno bundles for the browser
1383
+ // condition, where `node:fs` is a stub, so its entry runs the same checks on Deno's own file API.
1384
+ const nodeFs = target === "bun" || target === "node"
1385
+ const prerendered = Object.entries(options.prerenderedPages ?? {})
1386
+ if (prerendered.length > 0 && !nodeFs) {
1387
+ throw new Error(
1388
+ `[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`,
1389
+ )
1390
+ }
1391
+ const lines: string[] = [
1392
+ !nodeFs
1393
+ ? 'import { createWebApp } from "@nifrajs/web"'
1394
+ : prerendered.length > 0
1395
+ ? 'import { createWebApp, prerenderedPageReader, servePublicDir } from "@nifrajs/web"'
1396
+ : 'import { createWebApp, servePublicDir } from "@nifrajs/web"',
1397
+ ]
1320
1398
  if (backendImport !== undefined) lines.push('import { inProcessClient } from "@nifrajs/client"')
1321
1399
  lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`)
1322
1400
  if (useImport !== undefined) lines.push(`import { use } from ${JSON.stringify(useImport)}`)
@@ -1327,22 +1405,43 @@ export function generateServerEntry(options: {
1327
1405
  lines.push(`import { backend } from ${JSON.stringify(backendImport)}`)
1328
1406
  }
1329
1407
  lines.push(
1330
- 'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"',
1408
+ options.copyChunks === true
1409
+ ? 'import { clientEntry, copyChunks, cssLoading, manifest, styles, routeStyles } from "./server-manifest"'
1410
+ : 'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"',
1331
1411
  )
1332
1412
  // Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
1333
1413
  if (target === "cloudflare") lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
1334
1414
  if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
1415
+ if (nodeFs) lines.push('import { fileURLToPath } from "node:url"')
1416
+ if (target === "deno") lines.push('import { parseByteRange } from "@nifrajs/core/range"')
1417
+ if (prerendered.length > 0) {
1418
+ const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]))
1419
+ const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]))
1420
+ lines.push(
1421
+ "",
1422
+ `// The pages the build prerendered into ${PRERENDERED_DIR}/, answered from their file after \`use\`.`,
1423
+ "const prerenderedPages = {",
1424
+ ` pages: ${JSON.stringify(headers)},`,
1425
+ " read: prerenderedPageReader({",
1426
+ ` dir: fileURLToPath(new URL(${JSON.stringify(`./${PRERENDERED_DIR}/`)}, import.meta.url)),`,
1427
+ ` files: ${JSON.stringify(files)},`,
1428
+ " }),",
1429
+ "}",
1430
+ )
1431
+ }
1335
1432
  lines.push(
1336
1433
  "",
1337
1434
  "const app = createWebApp({",
1338
1435
  " adapter,",
1339
1436
  ...(useImport !== undefined ? [" use,"] : []),
1437
+ ...(prerendered.length > 0 ? [" prerenderedPages,"] : []),
1340
1438
  ...importedOptions.map((name) => ` ${name},`),
1341
1439
  " manifest,",
1342
1440
  " clientEntry,",
1343
1441
  " styles,",
1344
1442
  " routeStyles,",
1345
1443
  " cssLoading,",
1444
+ ...(options.copyChunks === true ? [" copyChunks,"] : []),
1346
1445
  ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []),
1347
1446
  ` title: ${JSON.stringify(title)},`,
1348
1447
  // Core's own `{ header }` trust, so the mounted backend and every loader see the derived caller.
@@ -1406,14 +1505,20 @@ export function generateServerEntry(options: {
1406
1505
  ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
1407
1506
  " return segments.every(safe) ? '.' + pathname : undefined",
1408
1507
  "}",
1409
- // The type table and cache policy `servePublicDir` uses. Without a known type, Bun answers a file
1410
- // as an `application/octet-stream` attachment, which a browser downloads instead of showing.
1411
- `const TYPES: Record<string, string> = ${JSON.stringify(CONTENT_TYPES)}`,
1412
- "const staticHeaders = (pathname: string): Record<string, string> => ({",
1413
- ' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
1414
- ` "cache-control": ${hashedFiles !== undefined ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
1415
- ' "x-content-type-options": "nosniff",',
1416
- "})",
1508
+ // The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, HEAD
1509
+ // answered without reading the file, byte ranges and conditional requests, and a descriptor opened
1510
+ // only once the real path is inside the root, so a link planted in the deployed tree cannot reach
1511
+ // a file outside it.
1512
+ ...(nodeFs
1513
+ ? [
1514
+ "const serveStatic = servePublicDir({",
1515
+ " dir: fileURLToPath(STATIC_ROOT),",
1516
+ ...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
1517
+ "})",
1518
+ "const staticResponse = async (req: Request): Promise<Response> =>",
1519
+ ' (await serveStatic(req)) ?? new Response("not found", { status: 404 })',
1520
+ ]
1521
+ : denoStaticResponse(hashedFiles !== undefined)),
1417
1522
  )
1418
1523
  // Bun.serve and Deno.serve delimit the body themselves, so its declared length is the transport
1419
1524
  // frame - the guarantee core's own listen() and the Deno adapter mark (see markTrustedBodyFraming).
@@ -1425,11 +1530,9 @@ export function generateServerEntry(options: {
1425
1530
  " port: Number(Bun.env.PORT ?? 3000),",
1426
1531
  " async fetch(req, server) {",
1427
1532
  " const { pathname } = new URL(req.url)",
1428
- " const filePath = staticPath(pathname)",
1429
- " if (filePath !== undefined) {",
1430
- " const file = Bun.file(new URL(filePath, STATIC_ROOT))",
1431
- ' if (!(await file.exists())) return new Response("not found", { status: 404 })',
1432
- " return new Response(file, { headers: staticHeaders(pathname) })",
1533
+ // Braced: the next emitted line opens with `(`, which would otherwise call the returned value.
1534
+ " if (staticPath(pathname) !== undefined) {",
1535
+ " return staticResponse(req)",
1433
1536
  " }",
1434
1537
  ` ${markFramed}`,
1435
1538
  " // The socket peer, looked up only when something reads it (as core's own listen() does).",
@@ -1444,20 +1547,11 @@ export function generateServerEntry(options: {
1444
1547
  }
1445
1548
  if (target === "node") {
1446
1549
  lines.push(
1447
- 'import { readFile } from "node:fs/promises"',
1448
1550
  "await serve(",
1449
1551
  " {",
1450
1552
  " async fetch(req, platform) {",
1451
1553
  " const { pathname } = new URL(req.url)",
1452
- " const filePath = staticPath(pathname)",
1453
- " if (filePath !== undefined) {",
1454
- " try {",
1455
- " const body = await readFile(new URL(filePath, STATIC_ROOT))",
1456
- " return new Response(body, { headers: staticHeaders(pathname) })",
1457
- " } catch {",
1458
- ' return new Response("not found", { status: 404 })',
1459
- " }",
1460
- " }",
1554
+ " if (staticPath(pathname) !== undefined) return staticResponse(req)",
1461
1555
  " return app.fetch(req, platform)",
1462
1556
  " },",
1463
1557
  " },",
@@ -1473,13 +1567,7 @@ export function generateServerEntry(options: {
1473
1567
  " const { pathname } = new URL(req.url)",
1474
1568
  " const filePath = staticPath(pathname)",
1475
1569
  " if (filePath !== undefined) {",
1476
- " try {",
1477
- " // @ts-ignore - Deno.readFile is present on the Deno runtime.",
1478
- " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))",
1479
- " return new Response(body, { headers: staticHeaders(pathname) })",
1480
- " } catch {",
1481
- ' return new Response("not found", { status: 404 })',
1482
- " }",
1570
+ " return staticResponse(req, filePath, pathname)",
1483
1571
  " }",
1484
1572
  ` ${markFramed}`,
1485
1573
  " return app.fetch(req, { clientIp: info.remoteAddr.hostname })",
@@ -1488,6 +1576,99 @@ export function generateServerEntry(options: {
1488
1576
  return `${lines.join("\n")}\n`
1489
1577
  }
1490
1578
 
1579
+ /**
1580
+ * The Deno entry's static handler: `servePublicDir`'s checks on Deno's file API. The real path must sit
1581
+ * inside the root and cross no hidden segment, and the opened file must still be the one that path
1582
+ * names, so a link planted in the deployed tree cannot reach a file outside it or a `.env` inside it.
1583
+ * HEAD answers from `stat` without reading; one byte range is served from a seek, several as the
1584
+ * whole file.
1585
+ */
1586
+ function denoStaticResponse(hashed: boolean): string[] {
1587
+ return [
1588
+ `const TYPES: Record<string, string> = ${JSON.stringify(CONTENT_TYPES)}`,
1589
+ "const staticHeaders = (pathname: string): Record<string, string> => ({",
1590
+ ' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
1591
+ ` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
1592
+ ' "x-content-type-options": "nosniff",',
1593
+ ' "accept-ranges": "bytes",',
1594
+ "})",
1595
+ "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1596
+ "const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
1597
+ "const hiddenSegment = (rel: string): boolean =>",
1598
+ ' rel.split(/[\\\\/]/).some((segment) => segment.startsWith(".") && segment !== ".well-known")',
1599
+ "const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
1600
+ ' const missing = () => new Response("not found", { status: 404 })',
1601
+ ' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
1602
+ " let file, info",
1603
+ " try {",
1604
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1605
+ " const real: string = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
1606
+ ' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
1607
+ " if (hiddenSegment(real.slice(STATIC_REAL.length + 1))) return missing()",
1608
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1609
+ " file = await Deno.open(real)",
1610
+ " // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
1611
+ " // The opened file must be the one at `real`, which must still resolve to itself.",
1612
+ " const opened = await file.stat()",
1613
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1614
+ " const named = await Deno.stat(real)",
1615
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1616
+ " if (opened.dev !== named.dev || opened.ino !== named.ino || (await Deno.realPath(real)) !== real) {",
1617
+ " file.close()",
1618
+ " return missing()",
1619
+ " }",
1620
+ " info = opened",
1621
+ " } catch {",
1622
+ " file?.close()",
1623
+ " return missing()",
1624
+ " }",
1625
+ " const size: number = info.size",
1626
+ " const headers = new Headers(staticHeaders(pathname))",
1627
+ ' const range = req.method === "GET" ? parseByteRange(req.headers.get("range"), size) : { kind: "none" as const }',
1628
+ ' if (!info.isFile || req.method === "HEAD" || range.kind === "unsatisfiable") {',
1629
+ " file.close()",
1630
+ " if (!info.isFile) return missing()",
1631
+ ' if (range.kind !== "unsatisfiable") {',
1632
+ ' headers.set("content-length", String(size))',
1633
+ " return new Response(null, { headers })",
1634
+ " }",
1635
+ ' headers.delete("content-type")',
1636
+ ' headers.set("content-range", "bytes */" + size)',
1637
+ " return new Response(null, { status: 416, headers })",
1638
+ " }",
1639
+ ' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
1640
+ " const { start, end } = range.ranges[0]!",
1641
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1642
+ " await file.seek(start, Deno.SeekMode.Start)",
1643
+ " let left = end - start + 1",
1644
+ ' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
1645
+ ' headers.set("content-length", String(left))',
1646
+ " const body = new ReadableStream<Uint8Array>({",
1647
+ " async pull(controller) {",
1648
+ " const chunk = new Uint8Array(Math.min(left, 65_536))",
1649
+ " const read: number | null = await file.read(chunk)",
1650
+ " if (read === null || read === 0) {",
1651
+ " file.close()",
1652
+ " controller.close()",
1653
+ " return",
1654
+ " }",
1655
+ " controller.enqueue(chunk.subarray(0, read))",
1656
+ " left -= read",
1657
+ " if (left <= 0) {",
1658
+ " file.close()",
1659
+ " controller.close()",
1660
+ " }",
1661
+ " },",
1662
+ " cancel: () => file.close(),",
1663
+ " })",
1664
+ " return new Response(body, { status: 206, headers })",
1665
+ " }",
1666
+ ' headers.set("content-length", String(size))',
1667
+ " return new Response(file.readable, { headers })",
1668
+ "}",
1669
+ ]
1670
+ }
1671
+
1491
1672
  /** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with
1492
1673
  * `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the
1493
1674
  * pure {@link aggregateSizeReport}. */
@@ -1531,7 +1712,9 @@ export interface BuildTargetOptions {
1531
1712
  * emitted hydration `<script src>` uses the REAL content-hashed entry (`client.entry`) plus the same
1532
1713
  * styles/route-preload the server targets use. A pre-built instance can't work here: the hash isn't known
1533
1714
  * until `buildClient` runs inside `buildTarget`, so a hardcoded entry 404s → pages render but never
1534
- * hydrate. Required for `target: "static"` (SSG drives `app.fetch`); ignored otherwise. */
1715
+ * hydrate. Required for `target: "static"` (SSG drives `app.fetch`). For `bun` and `node` it is
1716
+ * optional: given, the opted-in routes are prerendered into `_prerendered/` and the server answers
1717
+ * them from their file; the other targets ignore it. */
1535
1718
  readonly prerenderApp?: (client: BuildManifest) => PrerenderAppLike | Promise<PrerenderAppLike>
1536
1719
  /** Client-build plugins (e.g. the MDX/Vue/Solid Bun plugins). */
1537
1720
  readonly clientPlugins?: readonly BunPlugin[]
@@ -1630,6 +1813,7 @@ export const bunBundler: Bundler = {
1630
1813
  target: input.target,
1631
1814
  ...(input.styles !== undefined ? { styles: input.styles } : {}),
1632
1815
  ...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
1816
+ ...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
1633
1817
  ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1634
1818
  ...(input.define ? { define: input.define } : {}),
1635
1819
  ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
@@ -1659,6 +1843,48 @@ function assertNoPublicCollision(
1659
1843
  }
1660
1844
  }
1661
1845
 
1846
+ /** Prerender a self-hosting build's opted-in routes into `dir` and return the pages its server may
1847
+ * answer from their file: those whose render was not specific to its request. */
1848
+ async function prerenderForServer(
1849
+ prerenderApp: NonNullable<BuildTargetOptions["prerenderApp"]>,
1850
+ client: BuildManifest,
1851
+ routesDir: string,
1852
+ dir: string,
1853
+ publicFiles: readonly string[],
1854
+ secrets: NonNullable<PrerenderOptions["secrets"]>,
1855
+ ): Promise<Record<string, ServerPrerenderedPage>> {
1856
+ const owned = `/${PRERENDERED_DIR}/`
1857
+ const clashes = publicFiles.filter((file) => file.toLowerCase().startsWith(owned))
1858
+ if (clashes.length > 0) {
1859
+ throw new Error(
1860
+ `[nifra/web] public file(s) ${clashes.join(", ")} sit under ${owned}, where the build writes its ` +
1861
+ "prerendered pages. Rename or move them out of the public directory.",
1862
+ )
1863
+ }
1864
+ let result: Awaited<ReturnType<typeof prerenderRoutes>>
1865
+ try {
1866
+ result = await prerenderRoutes({
1867
+ app: await prerenderApp(client),
1868
+ routes: discoverRoutes(routesDir).routes,
1869
+ outDir: dir,
1870
+ secrets,
1871
+ })
1872
+ } catch (error) {
1873
+ // Prerendering loads every route module at build time, which a server build never needed before:
1874
+ // one that cannot load there (a connection opened on import, an env var only production sets) costs
1875
+ // the speedup, not the build. A secret in a page still fails it.
1876
+ if (error instanceof SecretScanError) throw error
1877
+ rmSync(dir, { recursive: true, force: true })
1878
+ console.warn(
1879
+ `[nifra/web] prerendering failed, so the server renders every page on demand: ${
1880
+ error instanceof Error ? error.message : String(error)
1881
+ }`,
1882
+ )
1883
+ return {}
1884
+ }
1885
+ return servablePrerenderedPages(result.prerendered)
1886
+ }
1887
+
1662
1888
  /** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
1663
1889
  export async function buildTarget(
1664
1890
  target: BuildTarget,
@@ -1759,6 +1985,21 @@ export async function buildTargetWith(
1759
1985
  writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
1760
1986
  }
1761
1987
 
1988
+ // A self-hosting server answers its prerendered pages from their file. The static target is all
1989
+ // prerendered pages; the edge targets keep rendering them on demand.
1990
+ const prerenderedPages =
1991
+ options.prerenderApp !== undefined &&
1992
+ (targetPlan.target === "bun" || targetPlan.target === "node")
1993
+ ? await prerenderForServer(
1994
+ options.prerenderApp,
1995
+ client,
1996
+ routesDir,
1997
+ `${staticRoot}/${PRERENDERED_DIR}`,
1998
+ publicFiles,
1999
+ secretScan,
2000
+ )
2001
+ : undefined
2002
+
1762
2003
  if (targetPlan.kind === "static") {
1763
2004
  if (options.prerenderApp === undefined) {
1764
2005
  throw new Error(
@@ -1813,6 +2054,8 @@ export async function buildTargetWith(
1813
2054
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
1814
2055
  hashedFiles: client.assets,
1815
2056
  ...(options.clientIp !== undefined ? { clientIp: options.clientIp } : {}),
2057
+ ...(client.copyChunks !== undefined ? { copyChunks: true } : {}),
2058
+ ...(prerenderedPages !== undefined ? { prerenderedPages } : {}),
1816
2059
  }),
1817
2060
  )
1818
2061
  const { worker } = await bundler.buildServer({
@@ -1823,6 +2066,7 @@ export async function buildTargetWith(
1823
2066
  target: targetPlan.serverTarget,
1824
2067
  ...(client.css !== undefined ? { styles: client.css } : {}),
1825
2068
  ...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
2069
+ ...(client.copyChunks !== undefined ? { copyChunks: client.copyChunks } : {}),
1826
2070
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1827
2071
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1828
2072
  ...(client.cssLoading !== undefined
package/src/client.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  setBlockerController,
20
20
  setBrowserNavigate,
21
21
  } from "./navigation.ts"
22
+ import { handoverLocale } from "./render-seam.ts"
22
23
  import { type ClientRouter, redirectOf } from "./router.ts"
23
24
 
24
25
  /** Read the nonce carried by the current server-rendered document, if any. */
@@ -49,6 +50,7 @@ export {
49
50
  resolveNavigate,
50
51
  } from "./navigation.ts"
51
52
  export { createMutation, createQueryClient } from "./query.ts"
53
+ export { localeOfProps } from "./render-seam.ts"
52
54
  export { createClientRouter, createMatcher } from "./router.ts"
53
55
  // Same reason, for the ADAPTER packages (`@nifrajs/web-react` and friends). Every one of them reaches
54
56
  // for these from the root - `searchOfChain` in its `/client` entry, the navigation and query values in
@@ -617,17 +619,23 @@ export function installHistory(
617
619
  * content (charset, hand-written tags) is never touched. SSR injects the same `data-nifra` tags, so
618
620
  * the first navigation cleanly takes over from the server-rendered head.
619
621
  */
620
- export function applyHead(head: Meta): void {
622
+ export function applyHead(head: Meta, path?: string): void {
621
623
  const documentNonce = currentDocumentNonce()
622
624
  if (head.title !== undefined) document.title = head.title
623
625
  // `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
624
- // route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
625
- // removed when unset. Anything laxer drifts on a multilingual site - navigating /ur → /en would leave
626
- // `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
627
635
  const root = document.documentElement
628
- root.setAttribute("lang", head.lang ?? "en")
629
- if (head.dir === undefined) root.removeAttribute("dir")
630
- 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)
631
639
  for (const el of document.head.querySelectorAll("[data-nifra]")) el.remove()
632
640
  // Values follow the same HTML attribute conventions as the SSR `tagAttrs`: a string sets the value,
633
641
  // `true` sets the bare boolean attribute, `false`/`undefined` skip it - so a soft-nav head matches