@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.
- package/CHANGELOG.md +2970 -0
- package/dist/build-plan.d.ts +7 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +19 -0
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +1 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +51 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +12 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +43 -6
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -7
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +6 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +23 -5
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/copy-split.d.ts +27 -0
- package/dist/internal/copy-split.d.ts.map +1 -0
- package/dist/internal/copy-split.js +173 -0
- package/dist/internal/copy-split.js.map +1 -0
- package/dist/internal/page-execution.d.ts +19 -1
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +63 -8
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/render-document.d.ts +11 -2
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +38 -6
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/typescript-api.d.ts +4 -0
- package/dist/internal/typescript-api.d.ts.map +1 -0
- package/dist/internal/typescript-api.js +8 -0
- package/dist/internal/typescript-api.js.map +1 -0
- package/dist/internal/vite-plugin-compat.d.ts +28 -0
- package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
- package/dist/internal/vite-plugin-compat.js +82 -0
- package/dist/internal/vite-plugin-compat.js.map +1 -0
- package/dist/internal/web-app.d.ts +34 -0
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +22 -0
- package/dist/internal/web-app.js.map +1 -1
- package/dist/plugins/import-glob.d.ts +20 -0
- package/dist/plugins/import-glob.d.ts.map +1 -0
- package/dist/plugins/import-glob.js +401 -0
- package/dist/plugins/import-glob.js.map +1 -0
- package/dist/plugins/kit.d.ts +11 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +32 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/stylex.d.ts.map +1 -1
- package/dist/plugins/stylex.js +2 -2
- package/dist/plugins/stylex.js.map +1 -1
- package/dist/render-seam.d.ts +37 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +33 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +3 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +4 -4
- package/dist/router.js.map +1 -1
- package/dist/server-components/typescript.d.ts.map +1 -1
- package/dist/server-components/typescript.js +2 -7
- package/dist/server-components/typescript.js.map +1 -1
- package/dist/vite.d.ts +1 -28
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +3 -69
- package/dist/vite.js.map +1 -1
- package/package.json +5 -4
- package/src/build-plan.ts +21 -0
- package/src/build-vite.ts +60 -3
- package/src/build.ts +57 -4
- package/src/client.ts +15 -7
- package/src/index.ts +6 -0
- package/src/internal/codegen.ts +32 -3
- package/src/internal/copy-split.ts +220 -0
- package/src/internal/page-execution.ts +83 -7
- package/src/internal/render-document.ts +63 -6
- package/src/internal/typescript-api.ts +11 -0
- package/src/internal/vite-plugin-compat.ts +81 -0
- package/src/internal/web-app.ts +61 -1
- package/src/plugins/import-glob.ts +392 -0
- package/src/plugins/kit.ts +36 -0
- package/src/plugins/stylex.ts +2 -1
- package/src/render-seam.ts +60 -0
- package/src/router.ts +7 -6
- package/src/server-components/typescript.ts +2 -10
- package/src/vite.ts +3 -81
package/src/internal/codegen.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1385
|
+
assemblySlots = new Map()
|
|
1322
1386
|
assemblySlotMod = mod
|
|
1323
1387
|
assemblySlotLayouts = layouts
|
|
1324
1388
|
}
|
|
1325
|
-
|
|
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
|
|
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
|
|
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,
|