@nifrajs/web 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/CHANGELOG.md +2970 -0
  2. package/README.md +8 -0
  3. package/dist/build-plan.d.ts +7 -0
  4. package/dist/build-plan.d.ts.map +1 -1
  5. package/dist/build-plan.js +19 -0
  6. package/dist/build-plan.js.map +1 -1
  7. package/dist/build-vite.d.ts +1 -0
  8. package/dist/build-vite.d.ts.map +1 -1
  9. package/dist/build-vite.js +51 -3
  10. package/dist/build-vite.js.map +1 -1
  11. package/dist/build.d.ts +12 -1
  12. package/dist/build.d.ts.map +1 -1
  13. package/dist/build.js +43 -6
  14. package/dist/build.js.map +1 -1
  15. package/dist/client.d.ts +7 -6
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +39 -22
  18. package/dist/client.js.map +1 -1
  19. package/dist/dev-port.d.ts +3 -2
  20. package/dist/dev-port.d.ts.map +1 -1
  21. package/dist/dev-port.js +2 -6
  22. package/dist/dev-port.js.map +1 -1
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/codegen.d.ts +6 -0
  28. package/dist/internal/codegen.d.ts.map +1 -1
  29. package/dist/internal/codegen.js +23 -5
  30. package/dist/internal/codegen.js.map +1 -1
  31. package/dist/internal/copy-split.d.ts +27 -0
  32. package/dist/internal/copy-split.d.ts.map +1 -0
  33. package/dist/internal/copy-split.js +173 -0
  34. package/dist/internal/copy-split.js.map +1 -0
  35. package/dist/internal/package-entries.d.ts +34 -0
  36. package/dist/internal/package-entries.d.ts.map +1 -0
  37. package/dist/internal/package-entries.js +324 -0
  38. package/dist/internal/package-entries.js.map +1 -0
  39. package/dist/internal/page-execution.d.ts +19 -1
  40. package/dist/internal/page-execution.d.ts.map +1 -1
  41. package/dist/internal/page-execution.js +123 -14
  42. package/dist/internal/page-execution.js.map +1 -1
  43. package/dist/internal/parity.d.ts.map +1 -1
  44. package/dist/internal/parity.js +15 -2
  45. package/dist/internal/parity.js.map +1 -1
  46. package/dist/internal/private-env.d.ts +8 -0
  47. package/dist/internal/private-env.d.ts.map +1 -1
  48. package/dist/internal/private-env.js +25 -10
  49. package/dist/internal/private-env.js.map +1 -1
  50. package/dist/internal/render-document.d.ts +15 -46
  51. package/dist/internal/render-document.d.ts.map +1 -1
  52. package/dist/internal/render-document.js +59 -62
  53. package/dist/internal/render-document.js.map +1 -1
  54. package/dist/internal/route-path.d.ts +6 -0
  55. package/dist/internal/route-path.d.ts.map +1 -1
  56. package/dist/internal/route-path.js +11 -0
  57. package/dist/internal/route-path.js.map +1 -1
  58. package/dist/internal/secret-scan.d.ts.map +1 -1
  59. package/dist/internal/secret-scan.js +103 -1
  60. package/dist/internal/secret-scan.js.map +1 -1
  61. package/dist/internal/typescript-api.d.ts +4 -0
  62. package/dist/internal/typescript-api.d.ts.map +1 -0
  63. package/dist/internal/typescript-api.js +8 -0
  64. package/dist/internal/typescript-api.js.map +1 -0
  65. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  66. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  67. package/dist/internal/vite-plugin-compat.js +82 -0
  68. package/dist/internal/vite-plugin-compat.js.map +1 -0
  69. package/dist/internal/web-app.d.ts +38 -2
  70. package/dist/internal/web-app.d.ts.map +1 -1
  71. package/dist/internal/web-app.js +30 -3
  72. package/dist/internal/web-app.js.map +1 -1
  73. package/dist/internal/without-query.d.ts +4 -0
  74. package/dist/internal/without-query.d.ts.map +1 -0
  75. package/dist/internal/without-query.js +9 -0
  76. package/dist/internal/without-query.js.map +1 -0
  77. package/dist/internal/zone-graph.d.ts.map +1 -1
  78. package/dist/internal/zone-graph.js +17 -6
  79. package/dist/internal/zone-graph.js.map +1 -1
  80. package/dist/isr.d.ts +1 -1
  81. package/dist/isr.d.ts.map +1 -1
  82. package/dist/isr.js +15 -2
  83. package/dist/isr.js.map +1 -1
  84. package/dist/manifest.d.ts +9 -0
  85. package/dist/manifest.d.ts.map +1 -1
  86. package/dist/manifest.js +1 -0
  87. package/dist/manifest.js.map +1 -1
  88. package/dist/plugins/import-glob.d.ts +20 -0
  89. package/dist/plugins/import-glob.d.ts.map +1 -0
  90. package/dist/plugins/import-glob.js +401 -0
  91. package/dist/plugins/import-glob.js.map +1 -0
  92. package/dist/plugins/kit.d.ts +11 -0
  93. package/dist/plugins/kit.d.ts.map +1 -1
  94. package/dist/plugins/kit.js +32 -0
  95. package/dist/plugins/kit.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts.map +1 -1
  97. package/dist/plugins/stylex.js +2 -2
  98. package/dist/plugins/stylex.js.map +1 -1
  99. package/dist/plugins/vite-leak-guard.d.ts +6 -0
  100. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  101. package/dist/plugins/vite-leak-guard.js +80 -7
  102. package/dist/plugins/vite-leak-guard.js.map +1 -1
  103. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  104. package/dist/plugins/vite-zone-guard.js +17 -21
  105. package/dist/plugins/vite-zone-guard.js.map +1 -1
  106. package/dist/plugins/zone-guard.d.ts.map +1 -1
  107. package/dist/plugins/zone-guard.js +44 -3
  108. package/dist/plugins/zone-guard.js.map +1 -1
  109. package/dist/prerender.d.ts +5 -1
  110. package/dist/prerender.d.ts.map +1 -1
  111. package/dist/prerender.js +41 -19
  112. package/dist/prerender.js.map +1 -1
  113. package/dist/render-seam.d.ts +60 -2
  114. package/dist/render-seam.d.ts.map +1 -1
  115. package/dist/render-seam.js +33 -0
  116. package/dist/render-seam.js.map +1 -1
  117. package/dist/router.d.ts +3 -2
  118. package/dist/router.d.ts.map +1 -1
  119. package/dist/router.js +13 -9
  120. package/dist/router.js.map +1 -1
  121. package/dist/seo.d.ts +49 -0
  122. package/dist/seo.d.ts.map +1 -0
  123. package/dist/seo.js +48 -0
  124. package/dist/seo.js.map +1 -0
  125. package/dist/server-components/typescript.d.ts.map +1 -1
  126. package/dist/server-components/typescript.js +2 -7
  127. package/dist/server-components/typescript.js.map +1 -1
  128. package/dist/vite.d.ts +1 -28
  129. package/dist/vite.d.ts.map +1 -1
  130. package/dist/vite.js +3 -69
  131. package/dist/vite.js.map +1 -1
  132. package/dist/zones.d.ts +30 -8
  133. package/dist/zones.d.ts.map +1 -1
  134. package/dist/zones.js +185 -46
  135. package/dist/zones.js.map +1 -1
  136. package/package.json +10 -4
  137. package/src/build-plan.ts +21 -0
  138. package/src/build-vite.ts +60 -3
  139. package/src/build.ts +57 -4
  140. package/src/client.ts +42 -22
  141. package/src/dev-port.ts +4 -10
  142. package/src/index.ts +8 -4
  143. package/src/internal/codegen.ts +32 -3
  144. package/src/internal/copy-split.ts +220 -0
  145. package/src/internal/package-entries.ts +331 -0
  146. package/src/internal/page-execution.ts +150 -13
  147. package/src/internal/parity.ts +15 -2
  148. package/src/internal/private-env.ts +27 -13
  149. package/src/internal/render-document.ts +100 -81
  150. package/src/internal/route-path.ts +16 -0
  151. package/src/internal/secret-scan.ts +101 -1
  152. package/src/internal/typescript-api.ts +11 -0
  153. package/src/internal/vite-plugin-compat.ts +81 -0
  154. package/src/internal/web-app.ts +71 -6
  155. package/src/internal/without-query.ts +8 -0
  156. package/src/internal/zone-graph.ts +19 -6
  157. package/src/isr.ts +13 -2
  158. package/src/manifest.ts +10 -0
  159. package/src/plugins/import-glob.ts +392 -0
  160. package/src/plugins/kit.ts +36 -0
  161. package/src/plugins/stylex.ts +2 -1
  162. package/src/plugins/vite-leak-guard.ts +99 -7
  163. package/src/plugins/vite-zone-guard.ts +21 -19
  164. package/src/plugins/zone-guard.ts +41 -2
  165. package/src/prerender.ts +39 -18
  166. package/src/render-seam.ts +88 -2
  167. package/src/router.ts +16 -11
  168. package/src/seo.ts +68 -0
  169. package/src/server-components/typescript.ts +2 -10
  170. package/src/vite.ts +3 -81
  171. package/src/zones.ts +237 -52
@@ -6,21 +6,28 @@ import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, prepareDeferred } from "../defer
6
6
  import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "../isr.ts"
7
7
  import type {
8
8
  Loader as LayoutLoader,
9
- LinkDescriptor,
10
9
  Meta,
11
- MetaDescriptor,
12
10
  MetaInput,
13
11
  RouteModule,
14
- ScriptDescriptor,
15
12
  UnsafeScriptDescriptor,
16
13
  } from "../manifest.ts"
17
- import type { MatchChain, RenderAdapter, RenderProps } from "../render-seam.ts"
14
+ import type {
15
+ DocumentParts,
16
+ MatchChain,
17
+ PageLocale,
18
+ PageLocales,
19
+ RenderAdapter,
20
+ RenderProps,
21
+ } from "../render-seam.ts"
18
22
  import {
19
23
  ACTION_GLOBAL,
20
24
  BOUNDARY_GLOBAL,
25
+ COPY_CHUNKS_GLOBAL,
26
+ COPY_REALM,
21
27
  DATA_GLOBAL,
22
28
  HANDOVER_ID,
23
29
  LAYOUT_DATA_GLOBAL,
30
+ LOCALES_GLOBAL,
24
31
  ROOT_ATTRIBUTE,
25
32
  ROUTE_GLOBAL,
26
33
  } from "../render-seam.ts"
@@ -114,6 +121,15 @@ export interface RenderPageOptions {
114
121
  /** SSG: the URL of a JSON array of the prerendered paths, handed over in place of
115
122
  * {@link prerenderedPaths} so a large set is fetched once instead of riding in every page. */
116
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
117
133
  /** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
118
134
  * wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
119
135
  readonly revalidate?: number
@@ -187,7 +203,7 @@ export interface RenderPageOptions {
187
203
  * prefix/suffix, tail statics) on the first render and reuses afterwards, skipping their re-assembly.
188
204
  *
189
205
  * Only pass a cache when every shell-shaping input is IDENTICAL across the requests sharing it:
190
- * 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`,
191
207
  * `cssLoading`, `preload`, `islandScripts`, `clientEntry`, `rootId`, `hydrate`, and `prerenderedPaths`.
192
208
  * Per-request
193
209
  * values (`data`, `params`, `search`, `actionData`, `layoutData`, deferred state) are always assembled
@@ -201,8 +217,10 @@ export interface RenderPageOptions {
201
217
  export interface RenderAssemblyCache {
202
218
  /** Shell up to (not including) the deferred-runtime insertion point. */
203
219
  shellPre?: string
204
- /** Shell from after the deferred-runtime insertion point through the open `#root` container. */
220
+ /** Shell from after the deferred-runtime insertion point through `<body>`. */
205
221
  shellPost?: string
222
+ /** The open `#root` container. */
223
+ rootOpen?: string
206
224
  /** Tail opener: the handover script open tag + the data member's key. */
207
225
  tailPre?: string
208
226
  /** Tail from after the per-request handover members (the route id, prerendered paths, the handover
@@ -249,6 +267,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
249
267
  cssLoading: requestedCssLoading,
250
268
  prerenderedPaths = [],
251
269
  prerenderedListUrl,
270
+ copyChunks,
271
+ locale,
252
272
  revalidate,
253
273
  revalidateTags = [],
254
274
  routeId,
@@ -277,6 +297,13 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
277
297
  : prerenderedPaths.length === 0
278
298
  ? ""
279
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)
280
307
  // Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
281
308
  // data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
282
309
  // also `defer()` - split it too, continuing the id space so a single registry settles both. The
@@ -406,7 +433,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
406
433
  // `<html>` attributes. `lang` defaults to `"en"`; `dir` is omitted entirely when unset, which IS
407
434
  // HTML's `ltr` default. Both attribute-escaped. `client.ts`'s `applyHead` mirrors this exact
408
435
  // defaulting on soft-nav, so a hard load and a client navigation produce the same `<html>`.
409
- const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
436
+ const htmlAttrs = ` lang="${escapeAttr(lang)}"${dir === undefined ? "" : ` dir="${escapeAttr(dir)}"`}`
410
437
  // Marks the container for the client entry when the id is not the one the entry falls back to.
411
438
  const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
412
439
  // A non-hydrated page omits the adapter's hydration bootstrap entirely (Solid's `_$HY` registry
@@ -423,7 +450,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
423
450
  ? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}`
424
451
  : ""
425
452
  slot.shellPre = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head, nonce)}${styleLinks}${entryPreloads}`
426
- slot.shellPost = `${islandPreloads}${hydrationHead}</head><body><div id="${escapeAttr(rootId)}"${rootMarker}>`
453
+ slot.shellPost = `${islandPreloads}${hydrationHead}</head><body>`
454
+ slot.rootOpen = `<div id="${escapeAttr(rootId)}"${rootMarker}>`
427
455
  // Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
428
456
  // client but can still mount no-framework islands (`@nifrajs/web/islands`).
429
457
  let islandTags = ""
@@ -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,7 +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
- const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}`
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}`
478
+ const parts: DocumentParts | undefined =
479
+ adapter.documentParts === true ? { head: "", body: "" } : undefined
480
+ const shellOf = (): string =>
481
+ parts === undefined || (parts.head === "" && parts.body === "")
482
+ ? shellHtml
483
+ : `${slot.shellPre}${copyPreloads}${runtimeSeam}${parts.head}${slot.shellPost}${parts.body}${slot.rootOpen}`
445
484
  // Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
446
485
  // aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
447
486
  const closeRootHtml = "</div>"
@@ -451,6 +490,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
451
490
  ? `${slot.tailPre}${serializeData(forClient)}${action}${layoutTail}${boundaryTail}${componentTail}${slot.tailPost}`
452
491
  : (slot.tailPost as string)
453
492
  const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
493
+ if (locale !== undefined) headers["content-language"] = lang
454
494
  // Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
455
495
  // framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
456
496
  // not be able to mislabel the document or forge the freshness header by passing them here.
@@ -495,6 +535,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
495
535
  ? { boundaries: boundarySplit.forComponent as BoundaryStates }
496
536
  : {}),
497
537
  ...(options.matchChain !== undefined ? { matchChain: options.matchChain } : {}),
538
+ ...(locale === undefined ? {} : { locale }),
498
539
  }
499
540
 
500
541
  // Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
@@ -504,13 +545,22 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
504
545
  // gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
505
546
  // shell-readiness `await` does, so the `_error` boundary still maps it to a status.
506
547
  if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
507
- const out = adapter.renderToString(chain, renderProps)
548
+ const out =
549
+ parts !== undefined
550
+ ? adapter.renderToString(
551
+ chain,
552
+ renderProps,
553
+ nonce === undefined ? { parts } : { nonce, parts },
554
+ )
555
+ : nonce === undefined
556
+ ? adapter.renderToString(chain, renderProps)
557
+ : adapter.renderToString(chain, renderProps, { nonce })
508
558
  return typeof out === "string"
509
- ? new BufferedRenderedPage(shellHtml + out + closeRootHtml + tailHtml, status, headers)
559
+ ? new BufferedRenderedPage(shellOf() + out + closeRootHtml + tailHtml, status, headers)
510
560
  : out.then(
511
561
  (bodyHtml) =>
512
562
  new BufferedRenderedPage(
513
- shellHtml + bodyHtml + closeRootHtml + tailHtml,
563
+ shellOf() + bodyHtml + closeRootHtml + tailHtml,
514
564
  status,
515
565
  headers,
516
566
  ),
@@ -521,7 +571,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
521
571
  adapter,
522
572
  chain,
523
573
  renderProps,
524
- shellHtml,
574
+ shellOf,
575
+ parts,
525
576
  closeRootHtml,
526
577
  allDeferred,
527
578
  tailHtml,
@@ -536,7 +587,8 @@ async function renderStreamedPage(
536
587
  adapter: RenderAdapter,
537
588
  chain: readonly unknown[],
538
589
  renderProps: RenderProps,
539
- shellHtml: string,
590
+ shellOf: () => string,
591
+ parts: DocumentParts | undefined,
540
592
  closeRootHtml: string,
541
593
  allDeferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
542
594
  tailHtml: string,
@@ -550,13 +602,20 @@ async function renderStreamedPage(
550
602
  // (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
551
603
  // sent. A *mid*-stream failure errors the body instead.
552
604
  const enc = TEXT_ENCODER
553
- const shell = enc.encode(shellHtml)
554
605
  const closeRoot = enc.encode(closeRootHtml)
555
606
  const tail = enc.encode(tailHtml)
556
607
  const appStream =
557
- nonce === undefined
558
- ? await adapter.renderToStream(chain, renderProps)
559
- : await adapter.renderToStream(chain, renderProps, { nonce })
608
+ parts !== undefined
609
+ ? await adapter.renderToStream(
610
+ chain,
611
+ renderProps,
612
+ nonce === undefined ? { parts } : { nonce, parts },
613
+ )
614
+ : nonce === undefined
615
+ ? await adapter.renderToStream(chain, renderProps)
616
+ : await adapter.renderToStream(chain, renderProps, { nonce })
617
+ // After the render: an adapter fills its document parts before handing back the stream.
618
+ const shell = enc.encode(shellOf())
560
619
  const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr)
561
620
  return new Response(body, { status, headers })
562
621
  }
@@ -1123,68 +1182,6 @@ function headTags(head: Meta | undefined, documentNonce?: string): string {
1123
1182
  return out
1124
1183
  }
1125
1184
 
1126
- /**
1127
- * A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
1128
- * engines which URL is authoritative for a page (deduping query-string / tracking variants).
1129
- *
1130
- * ```ts
1131
- * export const meta = (a) => ({ link: [canonical(`https://site.com/posts/${a.params.slug}`)] })
1132
- * ```
1133
- */
1134
- export function canonical(href: string): LinkDescriptor {
1135
- return { rel: "canonical", href }
1136
- }
1137
-
1138
- /** Inputs for {@link openGraph} - the common Open Graph properties. All optional; only the provided
1139
- * ones become tags. `type` defaults to `"website"`. */
1140
- export interface OpenGraphInput {
1141
- readonly title?: string
1142
- readonly description?: string
1143
- /** Absolute URL of the share image (`og:image`). */
1144
- readonly image?: string
1145
- /** Canonical URL of the page (`og:url`). */
1146
- readonly url?: string
1147
- /** Object type (`og:type`) - `"website"`, `"article"`, … Default `"website"`. */
1148
- readonly type?: string
1149
- }
1150
-
1151
- /**
1152
- * Build the Open Graph `<meta property="og:*">` entries for a route's `meta.meta`. Returns only the
1153
- * properties you supplied (plus `og:type`, defaulting to `"website"`), so it composes with other meta.
1154
- *
1155
- * ```ts
1156
- * export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
1157
- * ```
1158
- */
1159
- export function openGraph(input: OpenGraphInput): MetaDescriptor[] {
1160
- const tags: MetaDescriptor[] = []
1161
- const add = (property: string, content: string | undefined): void => {
1162
- if (content !== undefined) tags.push({ property, content })
1163
- }
1164
- add("og:title", input.title)
1165
- add("og:description", input.description)
1166
- add("og:image", input.image)
1167
- add("og:url", input.url)
1168
- // `og:type` always present (the spec's required property) - default "website" when unset.
1169
- tags.push({ property: "og:type", content: input.type ?? "website" })
1170
- return tags
1171
- }
1172
-
1173
- /**
1174
- * Build a JSON-LD `<script type="application/ld+json">` entry for a route's `meta.script` from a plain
1175
- * object. `JSON.stringify` produces the body; the head renderer breakout-escapes it (see
1176
- * `escapeScriptContent`), so a string field containing `</script>` is embedded safely.
1177
- *
1178
- * ```ts
1179
- * export const meta = {
1180
- * script: [jsonLd({ "@context": "https://schema.org", "@type": "Article", headline: "Hi" })],
1181
- * }
1182
- * ```
1183
- */
1184
- export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
1185
- return { type: "application/ld+json", content: JSON.stringify(data) }
1186
- }
1187
-
1188
1185
  /**
1189
1186
  * Deliberately unsafe escape hatch for executable inline code. The required nonce keeps the result
1190
1187
  * compatible with a strict CSP and makes the security-sensitive choice visible at the call site.
@@ -1209,3 +1206,25 @@ export function unsafeInlineScript(
1209
1206
  content,
1210
1207
  }
1211
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
+ }
@@ -6,3 +6,19 @@
6
6
  */
7
7
  export const requestPattern = (pattern: string): string =>
8
8
  pattern.normalize("NFC").replace(/[^!-~]/gu, encodeURIComponent)
9
+
10
+ // This module also compiles without the DOM lib; at runtime the name is the browser's own global.
11
+ declare const location: { readonly origin: string } | undefined
12
+
13
+ /**
14
+ * An app path as a URL `fetch`, `history` and `location` resolve on this document's own origin. A path
15
+ * starting `//` or `/\` is the path of a page here (`https://shop.example//evil.example/x`), but resolved
16
+ * as written it is a protocol-relative URL to another host, so it is anchored to this origin.
17
+ */
18
+ export const appUrl = (path: string): string =>
19
+ path.charCodeAt(0) === 47 /* / */ &&
20
+ (path.charCodeAt(1) === 47 || path.charCodeAt(1) === 92) /* / or \ */ &&
21
+ typeof location !== "undefined" &&
22
+ location.origin !== "null"
23
+ ? location.origin + path
24
+ : path
@@ -293,6 +293,98 @@ function formatMatches(text: string): Match[] {
293
293
  return matches
294
294
  }
295
295
 
296
+ /** Words that mark a whole `_`/`-` segment of a value as a stand-in: `your_signing_secret`. */
297
+ const PLACEHOLDER_SEGMENT =
298
+ /^(?:your|example|placeholder|sample|dummy|changeme|replace|generated|x{3,})$/
299
+
300
+ const placeholderSegments = (value: string): boolean =>
301
+ value
302
+ .toLowerCase()
303
+ .split(/[_-]/)
304
+ .some((segment) => PLACEHOLDER_SEGMENT.test(segment))
305
+
306
+ /** A word-cased label: letters and marks, hyphen-joined words, an acronym. No digits, no runs of
307
+ * mixed case, no credential punctuation. */
308
+ const LABEL = /^(?:\p{Lu}{1,4}|\p{Lu}?[\p{Ll}\p{M}]+)(?:-(?:\p{Lu}{1,4}|\p{Lu}?[\p{Ll}\p{M}]+))*$/u
309
+
310
+ /** How many string values a catalog holds at least, and the share of them that is prose. */
311
+ const CATALOG_VALUES = 10
312
+
313
+ /** Where a `/` starts a regular expression: after anything but a value's end. */
314
+ const VALUE_END = /[\w$)\]}"'`]/
315
+
316
+ /**
317
+ * The spans of top-level object literals that read as message catalogs: at least
318
+ * {@link CATALOG_VALUES} string property values, most of them prose with whitespace. A lexer, not a
319
+ * parser, so a build scans without TypeScript; strings, comments and regular expressions are skipped.
320
+ */
321
+ function catalogSpans(text: string): Array<readonly [number, number]> {
322
+ const spans: Array<readonly [number, number]> = []
323
+ let depth = 0
324
+ let start = 0
325
+ let values = 0
326
+ let prose = 0
327
+ // Whether the open top-level brace starts an object literal, not a block such as a function body.
328
+ let literal = false
329
+ let last = ""
330
+ let i = 0
331
+ while (i < text.length) {
332
+ const c = text.charAt(i)
333
+ if (c === "/" && text[i + 1] === "/") {
334
+ const end = text.indexOf("\n", i)
335
+ i = end === -1 ? text.length : end
336
+ continue
337
+ }
338
+ if (c === "/" && text[i + 1] === "*") {
339
+ const end = text.indexOf("*/", i + 2)
340
+ i = end === -1 ? text.length : end + 2
341
+ continue
342
+ }
343
+ if (c === '"' || c === "'" || c === "`" || (c === "/" && !VALUE_END.test(last))) {
344
+ let j = i + 1
345
+ let inClass = false
346
+ for (; j < text.length; j++) {
347
+ const d = text[j]
348
+ if (d === "\\") {
349
+ j++
350
+ continue
351
+ }
352
+ if (c !== "`" && d === "\n") break
353
+ if (c !== "/") {
354
+ if (d === c) break
355
+ continue
356
+ }
357
+ if (d === "[") inClass = true
358
+ else if (d === "]") inClass = false
359
+ else if (d === "/" && !inClass) break
360
+ }
361
+ // A string after `:` is a property's value.
362
+ if (depth > 0 && c !== "/" && c !== "`" && last === ":") {
363
+ values++
364
+ if (/\s/.test(text.slice(i + 1, j))) prose++
365
+ }
366
+ last = c
367
+ i = j + 1
368
+ continue
369
+ }
370
+ if (c === "{") {
371
+ if (depth === 0) {
372
+ start = i
373
+ values = 0
374
+ prose = 0
375
+ const word = /([\w$]+)\s*$/.exec(text.slice(Math.max(0, i - 16), i))?.[1]
376
+ literal = last === "" || "=(,:[?".includes(last) || word === "default"
377
+ }
378
+ depth++
379
+ } else if (c === "}" && depth > 0 && --depth === 0) {
380
+ if (literal && values >= CATALOG_VALUES && prose * 2 > values) spans.push([start, i + 1])
381
+ }
382
+ if (!/\s/.test(c)) last = c
383
+ i++
384
+ }
385
+ return spans
386
+ }
387
+
296
388
  /** The credential a literal given to `name` carries, when the name is secret-like and the literal
297
389
  * looks random. */
298
390
  function credentialOf(name: string, value: string): string | undefined {
@@ -304,6 +396,7 @@ function credentialOf(name: string, value: string): string | undefined {
304
396
  secret === undefined ||
305
397
  PUBLIC_VALUE.test(secret) ||
306
398
  PLACEHOLDER.test(secret) ||
399
+ placeholderSegments(secret) ||
307
400
  !looksRandom(secret, 16)
308
401
  )
309
402
  return undefined
@@ -312,9 +405,16 @@ function credentialOf(name: string, value: string): string | undefined {
312
405
 
313
406
  function assignedMatches(text: string): Match[] {
314
407
  const matches: Match[] = []
408
+ let catalogs: ReadonlyArray<readonly [number, number]> | undefined
409
+ /** A label in a message catalog: `password: "Lykilorð"` names the field in another language. */
410
+ const catalogLabel = (value: string, valueAt: number): boolean => {
411
+ if (!LABEL.test(value)) return false
412
+ catalogs ??= catalogSpans(text)
413
+ return catalogs.some(([start, end]) => valueAt > start && valueAt < end)
414
+ }
315
415
  const add = (name: string, value: string, valueAt: number): void => {
316
416
  const secret = credentialOf(name, value)
317
- if (secret === undefined) return
417
+ if (secret === undefined || catalogLabel(value, valueAt)) return
318
418
  matches.push({
319
419
  rule: "assigned-secret",
320
420
  // The credential is the value's tail: a header value's scheme is not part of it.
@@ -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
+ }