@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
@@ -5,9 +5,12 @@ import {
5
5
  ACTION_GLOBAL,
6
6
  BOUNDARY_GLOBAL,
7
7
  COMPONENT_GLOBAL,
8
+ COPY_CHUNKS_GLOBAL,
9
+ COPY_REALM,
8
10
  DATA_GLOBAL,
9
11
  HANDOVER_ID,
10
12
  LAYOUT_DATA_GLOBAL,
13
+ LOCALES_GLOBAL,
11
14
  ROOT_ATTRIBUTE,
12
15
  ROUTE_GLOBAL,
13
16
  } from "../render-seam.ts"
@@ -23,6 +26,7 @@ const HANDOVER_GLOBALS = [
23
26
  BOUNDARY_GLOBAL,
24
27
  COMPONENT_GLOBAL,
25
28
  PRERENDERED_GLOBAL,
29
+ LOCALES_GLOBAL,
26
30
  ]
27
31
  export interface GenerateClientEntryOptions {
28
32
  /**
@@ -39,6 +43,9 @@ export interface GenerateClientEntryOptions {
39
43
  /** Turn a route/layout source file (relative to the routes dir) into an import specifier. */
40
44
  readonly resolve: (file: string) => string
41
45
  readonly serverComponents?: boolean
46
+ /** The build moved copy tables into per-locale chunks: the handover carries the chunk URLs, and a
47
+ * navigation loads the copy of the URL it goes to beside the route's code. */
48
+ readonly copyChunks?: boolean
42
49
  }
43
50
 
44
51
  /**
@@ -222,7 +229,7 @@ export function generateClientEntry(
222
229
  // routeId → the ids and `handle` exports `useMatches` reports (layouts, then the page).
223
230
  `const layoutIdsOf = ${jsValueLiteral(layoutIdsOf)}`,
224
231
  "const matchChains = {}",
225
- "const loadModule = async (id) => {",
232
+ `const ${options.copyChunks === true ? "loadRoute" : "loadModule"} = async (id) => {`,
226
233
  " if (chains[id]) return",
227
234
  " const mods = await loaders[id]()",
228
235
  // For an error route the `_error` module is appended LAST: wrap the page (now second-to-last) in
@@ -260,6 +267,17 @@ export function generateClientEntry(
260
267
  " routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
261
268
  " if (page.ssr === false) holds[id] = [...chains[id].slice(0, -1), page.HydrateFallback ?? (() => null)]",
262
269
  "}",
270
+ ...(options.copyChunks === true
271
+ ? [
272
+ `const copyRealm = () => globalThis[Symbol.for(${jsStringLiteral(COPY_REALM)})]`,
273
+ // A second prepare covers a copy definition the route's own code brought in.
274
+ "const loadModule = async (id, path) => {",
275
+ " if (path === undefined) return loadRoute(id)",
276
+ " await Promise.all([loadRoute(id), copyRealm()?.prepare(path)])",
277
+ " await copyRealm()?.prepare(path)",
278
+ "}",
279
+ ]
280
+ : []),
263
281
  // The server hands page state over as one inert JSON script; lift it onto the globals the
264
282
  // router, the deferred mapper and the adapters read, before any of them runs. Only the LAST
265
283
  // matching <script> counts, and only known keys: page HTML (sanitized user content) can carry an
@@ -268,7 +286,7 @@ export function generateClientEntry(
268
286
  "const handover = handovers[handovers.length - 1]",
269
287
  "if (handover !== undefined) {",
270
288
  ' const state = JSON.parse(handover.textContent || "{}")',
271
- ` for (const key of ${jsValueLiteral(HANDOVER_GLOBALS)}) {`,
289
+ ` for (const key of ${jsValueLiteral(options.copyChunks === true ? [...HANDOVER_GLOBALS, COPY_CHUNKS_GLOBAL] : HANDOVER_GLOBALS)}) {`,
272
290
  " if (Object.prototype.hasOwnProperty.call(state, key)) Reflect.set(window, key, state[key])",
273
291
  " }",
274
292
  "}",
@@ -383,7 +401,7 @@ export function generateClientEntry(
383
401
  // page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
384
402
  " const nonce = currentDocumentNonce()",
385
403
  " const args = { data: s.data, pageData: s.data, params: s.params, origin: location.origin, ...(nonce === undefined ? {} : { nonce }) }",
386
- " applyHead(mergeHeads(resolveChainMeta(metas[s.routeId] ?? [undefined], s.layoutData, args)))",
404
+ " applyHead(mergeHeads(resolveChainMeta(metas[s.routeId] ?? [undefined], s.layoutData, args)), s.path)",
387
405
  " }",
388
406
  " })",
389
407
  "}).catch((error) => console.error('[nifra/web] hydration failed:', error))",
@@ -453,6 +471,9 @@ export interface GenerateServerManifestOptions {
453
471
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
454
472
  /** How framework-owned stylesheet links should activate before the generated client mounts. */
455
473
  readonly cssLoading?: CssLoadingMode
474
+ /** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`) - baked as
475
+ * `export const copyChunks` when the client build split copy tables, for `createWebApp`. */
476
+ readonly copyChunks?: Readonly<Record<string, string>> | undefined
456
477
  /** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
457
478
  * eager `import * as`, so a bundler with code-splitting emits one chunk per route - loaded on the
458
479
  * first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
@@ -500,6 +521,7 @@ export function generateServerManifest(
500
521
  styles,
501
522
  routeStyles,
502
523
  cssLoading: requestedCssLoading,
524
+ copyChunks,
503
525
  lazy = false,
504
526
  } = options
505
527
  // A missing entry would bake `clientEntry = undefined` and the build would still succeed, leaving
@@ -541,6 +563,11 @@ export function generateServerManifest(
541
563
  const stylesLine = `export const styles = ${jsValueLiteral(styles ?? [])}`
542
564
  const routeStylesLine = `export const routeStyles = ${jsValueLiteral(routeStyles ?? {})}`
543
565
  const cssLoadingLine = `export const cssLoading = ${jsStringLiteral(cssLoading)}`
566
+ // Only a build that split copy tables has the line; its server entry is the one that imports it.
567
+ const copyChunksLines =
568
+ copyChunks === undefined || Object.keys(copyChunks).length === 0
569
+ ? []
570
+ : [`export const copyChunks = ${jsValueLiteral(copyChunks)}`]
544
571
  if (lazy) {
545
572
  // Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
546
573
  // code-splitting bundler, loaded on first request). The map keys are the route-relative paths
@@ -558,6 +585,7 @@ export function generateServerManifest(
558
585
  stylesLine,
559
586
  routeStylesLine,
560
587
  cssLoadingLine,
588
+ ...copyChunksLines,
561
589
  `export const manifest = buildManifest(Object.keys(loaders), ${LAZY_IMPORTER})`,
562
590
  ].join("\n")}\n`
563
591
  }
@@ -577,6 +605,7 @@ export function generateServerManifest(
577
605
  stylesLine,
578
606
  routeStylesLine,
579
607
  cssLoadingLine,
608
+ ...copyChunksLines,
580
609
  `export const manifest = buildManifest(Object.keys(modules), ${EAGER_IMPORTER})`,
581
610
  ].join("\n")}\n`
582
611
  }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Copy tables (`@nifrajs/i18n/copy`) in a production client build. Each table a module declares is
3
+ * replaced by its chunk id, and its values move into one chunk per locale, so a page's JavaScript carries
4
+ * the copy of the locale it renders in and none of the others. The server bundle keeps every table whole.
5
+ *
6
+ * The tables are found by `@nifrajs/i18n/extract` with the app's own TypeScript, both resolved from the
7
+ * app root: `@nifrajs/web` depends on neither. An app with no `defineCopy` pays one source scan.
8
+ */
9
+ import { createHash } from "node:crypto"
10
+ import { type Dirent, readdirSync, readFileSync } from "node:fs"
11
+ import { createRequire } from "node:module"
12
+ import { join, resolve as resolvePath } from "node:path"
13
+ import { pathToFileURL } from "node:url"
14
+ import type { BunPlugin } from "bun"
15
+ import type * as TS from "typescript"
16
+ import { addSourceTransform, normalizeFilePath } from "../plugins/kit.ts"
17
+ import { isTypeScriptCompiler } from "./typescript-api.ts"
18
+
19
+ const COPY_MODULE = "@nifrajs/i18n/copy"
20
+ const EXTRACT_MODULE = "@nifrajs/i18n/extract"
21
+ const SCRIPT = /\.[cm]?[jt]sx?$/
22
+ const REWRITABLE = /\.m?[jt]sx?(\?|$)/
23
+ const DEPENDENCY = /[\\/]node_modules[\\/]/
24
+ const MAX_DEPTH = 32
25
+
26
+ /** The part of `@nifrajs/i18n/extract` the build reads. */
27
+ interface TableSite {
28
+ readonly id: string
29
+ readonly values?: Readonly<Record<string, unknown>>
30
+ readonly definition: { readonly file: string; readonly name: string }
31
+ }
32
+ interface Scanner {
33
+ tables(file: string): readonly TableSite[]
34
+ definition(ref: TableSite["definition"]): { readonly locales?: unknown } | undefined
35
+ rewrite(file: string): string | undefined
36
+ }
37
+ interface SplitTable {
38
+ readonly id: string
39
+ readonly values: Readonly<Record<string, unknown>>
40
+ readonly locales: unknown
41
+ }
42
+ interface Extract {
43
+ createCopyScanner(
44
+ ts: typeof TS,
45
+ host: {
46
+ readonly root: string
47
+ read(file: string): string | undefined
48
+ resolve(specifier: string, from: string): string | undefined
49
+ },
50
+ ): Scanner
51
+ copyChunks(tables: Iterable<SplitTable>): ReadonlyMap<string, unknown>
52
+ }
53
+
54
+ const isExtract = (value: unknown): value is Extract =>
55
+ typeof value === "object" &&
56
+ value !== null &&
57
+ "createCopyScanner" in value &&
58
+ typeof value.createCopyScanner === "function" &&
59
+ "copyChunks" in value &&
60
+ typeof value.copyChunks === "function"
61
+
62
+ /** A chunk file the build wrote. */
63
+ export interface CopyChunkFile {
64
+ /** The file name, served under the build's public path. */
65
+ readonly name: string
66
+ readonly text: string
67
+ }
68
+
69
+ export interface CopySplit {
70
+ /** The client build's plugin, registered after every other one. */
71
+ readonly plugin: BunPlugin
72
+ /** A module with its tables replaced by chunk ids, or `undefined` when it has none to split. */
73
+ rewrite(file: string, source: string): string | undefined
74
+ /** One chunk per locale for the tables this build split, and each one's URL by locale key. */
75
+ chunks(publicPath: string): {
76
+ readonly urls: Readonly<Record<string, string>>
77
+ readonly files: readonly CopyChunkFile[]
78
+ }
79
+ }
80
+
81
+ const readText = (file: string): string | undefined => {
82
+ try {
83
+ return readFileSync(file, "utf8")
84
+ } catch {
85
+ return undefined
86
+ }
87
+ }
88
+
89
+ /** Whether a module under `dir` imports the copy module: the only way an app defines copy tables. */
90
+ function definesCopy(dir: string, skip: ReadonlySet<string>, depth = 0): boolean {
91
+ let entries: Dirent[]
92
+ try {
93
+ entries = readdirSync(dir, { withFileTypes: true })
94
+ } catch {
95
+ return false
96
+ }
97
+ for (const entry of entries) {
98
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue
99
+ const path = join(dir, entry.name)
100
+ if (entry.isDirectory()) {
101
+ if (depth < MAX_DEPTH && !skip.has(path) && definesCopy(path, skip, depth + 1)) return true
102
+ } else if (entry.isFile() && SCRIPT.test(entry.name)) {
103
+ if (readText(path)?.includes(COPY_MODULE) === true) return true
104
+ }
105
+ }
106
+ return false
107
+ }
108
+
109
+ const loaderOf = (path: string): "js" | "jsx" | "ts" | "tsx" => {
110
+ if (path.endsWith("x")) return path.endsWith(".tsx") ? "tsx" : "jsx"
111
+ return /\.m?ts$/.test(path) ? "ts" : "js"
112
+ }
113
+
114
+ /**
115
+ * The copy split for an app, or `undefined` when it defines no copy tables. An app that does, but whose
116
+ * TypeScript has no compiler API, builds with its tables whole, with a warning.
117
+ */
118
+ export async function loadCopySplit(
119
+ root: string,
120
+ options: { readonly skip?: readonly string[]; readonly warn?: (message: string) => void } = {},
121
+ ): Promise<CopySplit | undefined> {
122
+ const appRoot = resolvePath(root)
123
+ const require = createRequire(join(appRoot, "package.json"))
124
+ let extractPath: string
125
+ try {
126
+ extractPath = require.resolve(EXTRACT_MODULE)
127
+ } catch {
128
+ return undefined
129
+ }
130
+ if (!definesCopy(appRoot, new Set((options.skip ?? []).map((dir) => resolvePath(dir))))) {
131
+ return undefined
132
+ }
133
+ const warn = options.warn ?? console.warn
134
+ let ts: unknown
135
+ try {
136
+ ts = require("typescript")
137
+ } catch {
138
+ ts = undefined
139
+ }
140
+ if (!isTypeScriptCompiler(ts)) {
141
+ warn(
142
+ "[nifra/web] copy tables ship every locale in the client bundle: splitting them by locale reads the source with the TypeScript compiler API, and the app's TypeScript has none (TypeScript 7 ships none; install typescript@^6 as a dev dependency).",
143
+ )
144
+ return undefined
145
+ }
146
+ const extract: unknown = await import(pathToFileURL(extractPath).href)
147
+ if (!isExtract(extract)) {
148
+ warn(
149
+ `[nifra/web] copy tables ship every locale in the client bundle: ${EXTRACT_MODULE} at ${extractPath} predates the client split; update @nifrajs/i18n.`,
150
+ )
151
+ return undefined
152
+ }
153
+ const scanner = extract.createCopyScanner(ts, {
154
+ root: appRoot.replaceAll("\\", "/"),
155
+ read: readText,
156
+ resolve: (specifier, from) => {
157
+ try {
158
+ return createRequire(from).resolve(specifier)
159
+ } catch {
160
+ return undefined
161
+ }
162
+ },
163
+ })
164
+
165
+ const split = new Map<string, readonly SplitTable[]>()
166
+ const rewrite = (file: string, source: string): string | undefined => {
167
+ const path = normalizeFilePath(file)
168
+ // The scanner reads the file off disk: text another transform changed first is left alone, since
169
+ // its tables' positions are no longer the file's.
170
+ if (!REWRITABLE.test(path) || DEPENDENCY.test(path) || readText(path) !== source) {
171
+ return undefined
172
+ }
173
+ const out = scanner.rewrite(path)
174
+ if (out === undefined) return undefined
175
+ split.set(
176
+ path,
177
+ scanner.tables(path).flatMap((site) => {
178
+ const locales =
179
+ site.values === undefined ? undefined : scanner.definition(site.definition)?.locales
180
+ return site.values === undefined || locales === undefined
181
+ ? []
182
+ : [{ id: site.id, values: site.values, locales }]
183
+ }),
184
+ )
185
+ return out
186
+ }
187
+
188
+ return {
189
+ plugin: {
190
+ name: "nifra-copy-split",
191
+ setup(build) {
192
+ addSourceTransform(build, rewrite)
193
+ build.onLoad({ filter: REWRITABLE }, async (args) => {
194
+ if (args.namespace !== "file") return undefined
195
+ const path = normalizeFilePath(args.path)
196
+ const contents = rewrite(path, await Bun.file(path).text())
197
+ return contents === undefined ? undefined : { contents, loader: loaderOf(path) }
198
+ })
199
+ },
200
+ },
201
+ rewrite,
202
+ chunks(publicPath) {
203
+ // Sorted, so a chunk's bytes, and so its name, do not depend on the order modules loaded in.
204
+ const tables = [...split.values()].flat().sort((a, b) => (a.id < b.id ? -1 : 1))
205
+ const urls: Record<string, string> = {}
206
+ const files: CopyChunkFile[] = []
207
+ if (tables.length === 0) return { urls, files }
208
+ const chunks = [...extract.copyChunks(tables)].sort(([a], [b]) => (a < b ? -1 : 1))
209
+ for (const [locale, chunk] of chunks) {
210
+ // A JSON string parses faster than the same object as a literal.
211
+ const text = `export default JSON.parse(${JSON.stringify(JSON.stringify(chunk))})\n`
212
+ const hash = createHash("sha256").update(text).digest("hex").slice(0, 12)
213
+ const name = `copy-${locale.replace(/[^A-Za-z0-9-]/g, "_")}-${hash}.js`
214
+ urls[locale] = `${publicPath}${name}`
215
+ files.push({ name, text })
216
+ }
217
+ return { urls, files }
218
+ },
219
+ }
220
+ }
@@ -34,7 +34,7 @@ import type {
34
34
  ShouldRevalidate,
35
35
  } from "../manifest.ts"
36
36
  import type { NonceResolver } from "../nonce.ts"
37
- import type { MatchChain, RenderAdapter } from "../render-seam.ts"
37
+ import type { MatchChain, PageLocale, PageLocales, RenderAdapter } from "../render-seam.ts"
38
38
  import {
39
39
  createMatcher,
40
40
  DATA_HEADER,
@@ -54,9 +54,11 @@ import {
54
54
  isStatusSignal,
55
55
  type LoadedLayoutModules,
56
56
  layoutErrorId,
57
+ notFound,
57
58
  type RenderAssemblyCache,
58
59
  type RenderedPage,
59
60
  type RevalidateResult,
61
+ redirect,
60
62
  renderPageResult,
61
63
  STATUS_SIGNAL,
62
64
  STATUS_TEXT,
@@ -179,6 +181,24 @@ function appTarget(location: string, requestUrl: string): string {
179
181
  : target.pathname + target.search + target.hash
180
182
  }
181
183
 
184
+ /** The app's locale routing, as page execution reads it. */
185
+ export interface PageI18n {
186
+ readonly locales: PageLocales
187
+ /** The served locale key a `pathname + search` is in. */
188
+ localeOf(path: string): string
189
+ /** The answer for a `[lang]` / `[[lang]]` segment's value (`undefined` when an optional one is
190
+ * absent), as `@nifrajs/i18n/routing`'s `matchSegment` gives it. */
191
+ matchSegment(
192
+ value: string | undefined,
193
+ pathname: string,
194
+ ):
195
+ | { readonly kind: "ok" | "not-found" }
196
+ | { readonly kind: "redirect"; readonly location: string }
197
+ }
198
+
199
+ // The route directories whose first segment names the locale.
200
+ const LOCALE_SEGMENTS: ReadonlySet<string> = new Set(["[lang]", "[[lang]]"])
201
+
182
202
  export interface PageExecutionOptions<Env = unknown> {
183
203
  readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
184
204
  readonly rerenderOwnerGET?: (
@@ -198,6 +218,10 @@ export interface PageExecutionOptions<Env = unknown> {
198
218
  readonly prerenderedPaths?: readonly string[]
199
219
  /** Where pages fetch {@link prerenderedPaths} from when the list is too large to inline. */
200
220
  readonly prerenderedListUrl?: string
221
+ /** Locale key -> copy chunk URL, from a build that split copy tables by locale. */
222
+ readonly copyChunks?: Readonly<Record<string, string>>
223
+ /** The app's locale routing: each document's locale, and the check on a `[lang]` segment. */
224
+ readonly i18n?: PageI18n
201
225
  readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
202
226
  readonly staticBoundaryCache?: StaticBoundaryCache
203
227
  /** Per-request CSP nonce for framework-owned executable document scripts. */
@@ -301,6 +325,23 @@ export function createPageRequestExecutor<Env = unknown>(
301
325
  options.prerenderedListUrl === undefined
302
326
  ? { prerenderedPaths: options.prerenderedPaths ?? [] }
303
327
  : { prerenderedListUrl: options.prerenderedListUrl }
328
+ const copyOption = options.copyChunks === undefined ? {} : { copyChunks: options.copyChunks }
329
+ const { i18n } = options
330
+ const pageLocales = new Map(i18n?.locales.locales.map((locale) => [locale.key, locale]))
331
+ /** The request's locale for a document, with the served locales the client names locales from. */
332
+ const localeOption = (req: Request): { locale?: PageLocale; locales?: PageLocales } => {
333
+ if (i18n === undefined) return {}
334
+ const locale = pageLocales.get(i18n.localeOf(pathOf(req)))
335
+ return locale === undefined ? { locales: i18n.locales } : { locale, locales: i18n.locales }
336
+ }
337
+ /** Whether a route sits under a `[lang]` or `[[lang]]` directory, which the locale routing checks. */
338
+ const localeGuarded = (route: RouteEntry): boolean => {
339
+ if (i18n === undefined) return false
340
+ for (const segment of route.id.split("/")) {
341
+ if (!(segment.startsWith("(") && segment.endsWith(")"))) return LOCALE_SEGMENTS.has(segment)
342
+ }
343
+ return false
344
+ }
304
345
  const matchManifestRoute = createMatcher(
305
346
  manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })),
306
347
  )
@@ -553,6 +594,21 @@ export function createPageRequestExecutor<Env = unknown>(
553
594
  ctx: LoaderContext,
554
595
  controls: PageResponseControls,
555
596
  ): Promise<void> => {
597
+ if (i18n !== undefined && localeGuarded(route)) {
598
+ // Before any app middleware, as a root `_middleware` would run: an unserved locale, or a page
599
+ // missing from it, never reaches the app's code.
600
+ const match = i18n.matchSegment(ctx.params.lang, pathOf(ctx.request))
601
+ if (match.kind === "redirect") {
602
+ throw tagLayoutError(redirect(match.location, { status: 308 }), "_layout")
603
+ }
604
+ if (match.kind === "not-found") {
605
+ try {
606
+ notFound()
607
+ } catch (signal) {
608
+ throw tagLayoutError(signal, "_layout")
609
+ }
610
+ }
611
+ }
556
612
  const ids = route.middlewareIds ?? []
557
613
  for (let i = 0; i < ids.length; i++) {
558
614
  const id = ids[i] as string
@@ -776,6 +832,7 @@ export function createPageRequestExecutor<Env = unknown>(
776
832
  chain: components,
777
833
  data,
778
834
  ...(wrapped === undefined ? {} : { head: wrapped.head }),
835
+ ...localeOption(req),
779
836
  clientEntry,
780
837
  routeId: errorId,
781
838
  status: 500,
@@ -939,6 +996,8 @@ export function createPageRequestExecutor<Env = unknown>(
939
996
  // The build keys stylesheets by route, so a page inside the root layout links the whole-app one.
940
997
  ...(chain === undefined ? stylesOf(routeId) : stylesFrom(options.styles)),
941
998
  ...prerenderedHandover,
999
+ ...copyOption,
1000
+ ...localeOption(req),
942
1001
  ...titleOption,
943
1002
  }
944
1003
  if (chain === undefined) return renderPageResult(statusPage)
@@ -1087,6 +1146,8 @@ export function createPageRequestExecutor<Env = unknown>(
1087
1146
  ...preloadOf(route.id),
1088
1147
  ...stylesOf(route.id),
1089
1148
  ...prerenderedHandover,
1149
+ ...copyOption,
1150
+ ...localeOption(req),
1090
1151
  ...(responseHeaders === undefined ? {} : { headers: responseHeaders }),
1091
1152
  // A response that sets a cookie is one visitor's: it never advertises ISR freshness.
1092
1153
  ...(mod.revalidate !== undefined && !personalized && options.cacheChannel?.enabled === true
@@ -1213,6 +1274,7 @@ export function createPageRequestExecutor<Env = unknown>(
1213
1274
  matchChain: matchChainOf(page.layoutIds, layoutModules, id, mod),
1214
1275
  status: 404,
1215
1276
  hydrate: false,
1277
+ ...localeOption(req),
1216
1278
  ...(layoutData !== undefined ? { layoutData } : {}),
1217
1279
  ...stylesOf(id),
1218
1280
  ...(headers === undefined ? {} : { headers }),
@@ -1304,12 +1366,14 @@ export function createPageRequestExecutor<Env = unknown>(
1304
1366
  return {
1305
1367
  get: (route) => {
1306
1368
  const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
1307
- let assemblySlot: RenderAssemblyCache | undefined
1369
+ // One shell per locale: `<html lang>` is in it, and one route serves every locale.
1370
+ let assemblySlots = new Map<string, RenderAssemblyCache>()
1308
1371
  let assemblySlotMod: unknown
1309
1372
  let assemblySlotLayouts: LoadedLayoutModules | undefined
1310
1373
  const assemblyCacheFor = (
1311
1374
  mod: RouteModule,
1312
1375
  layouts: LoadedLayoutModules,
1376
+ req: Request,
1313
1377
  ): RenderAssemblyCache | undefined => {
1314
1378
  if (typeof mod.meta === "function") return undefined
1315
1379
  for (const layout of layouts) if (typeof layout.meta === "function") return undefined
@@ -1318,11 +1382,17 @@ export function createPageRequestExecutor<Env = unknown>(
1318
1382
  assemblySlotLayouts.length === layouts.length &&
1319
1383
  assemblySlotLayouts.every((layout, index) => layout === layouts[index])
1320
1384
  if (assemblySlotMod !== mod || !layoutsUnchanged) {
1321
- assemblySlot = {}
1385
+ assemblySlots = new Map()
1322
1386
  assemblySlotMod = mod
1323
1387
  assemblySlotLayouts = layouts
1324
1388
  }
1325
- return assemblySlot
1389
+ const key = i18n === undefined ? "" : i18n.localeOf(pathOf(req))
1390
+ let slot = assemblySlots.get(key)
1391
+ if (slot === undefined) {
1392
+ slot = {}
1393
+ assemblySlots.set(key, slot)
1394
+ }
1395
+ return slot
1326
1396
  }
1327
1397
 
1328
1398
  return withResponseControls(async (c, controls) => {
@@ -1364,7 +1434,9 @@ export function createPageRequestExecutor<Env = unknown>(
1364
1434
  search: loaderSearch(mod.searchSchema, c.req),
1365
1435
  set: controls.scope(PAGE_SCOPE),
1366
1436
  }
1367
- if (route.middlewareIds !== undefined) await runMiddleware(route, ctx, controls)
1437
+ if (route.middlewareIds !== undefined || localeGuarded(route)) {
1438
+ await runMiddleware(route, ctx, controls)
1439
+ }
1368
1440
  run = await runLayoutChain(
1369
1441
  route,
1370
1442
  ctx,
@@ -1510,7 +1582,7 @@ export function createPageRequestExecutor<Env = unknown>(
1510
1582
  layoutModules ?? (await loadLayoutModules(route)),
1511
1583
  layoutRetained,
1512
1584
  boundaryStates,
1513
- layoutModules === undefined ? undefined : assemblyCacheFor(mod, layoutModules),
1585
+ layoutModules === undefined ? undefined : assemblyCacheFor(mod, layoutModules, c.req),
1514
1586
  responseHeaders,
1515
1587
  controls.personalized,
1516
1588
  componentState,
@@ -1569,7 +1641,9 @@ export function createPageRequestExecutor<Env = unknown>(
1569
1641
  let gated: GatedLayouts
1570
1642
  let result: unknown
1571
1643
  try {
1572
- if (route.middlewareIds !== undefined) await runMiddleware(route, actionContext, controls)
1644
+ if (route.middlewareIds !== undefined || localeGuarded(route)) {
1645
+ await runMiddleware(route, actionContext, controls)
1646
+ }
1573
1647
  gated = await runLayoutGates(route, actionContext, controls)
1574
1648
  result = await mod.action(actionContext)
1575
1649
  } catch (err) {
@@ -1758,6 +1832,8 @@ export function createPageRequestExecutor<Env = unknown>(
1758
1832
  ...preloadOf(route.id),
1759
1833
  ...stylesOf(route.id),
1760
1834
  ...prerenderedHandover,
1835
+ ...copyOption,
1836
+ ...localeOption(c.req),
1761
1837
  ...(responseHeaders === undefined ? {} : { headers: responseHeaders }),
1762
1838
  ...(nonce === undefined ? {} : { nonce }),
1763
1839
  ...cspOption,