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