@nifrajs/web 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +68 -0
  3. package/dist/build-vite.d.ts.map +1 -0
  4. package/dist/build-vite.js +433 -0
  5. package/dist/build-vite.js.map +1 -0
  6. package/dist/build.d.ts +174 -65
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +376 -135
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +83 -0
  11. package/dist/bun-dev-entry.d.ts.map +1 -0
  12. package/dist/bun-dev-entry.js +136 -0
  13. package/dist/bun-dev-entry.js.map +1 -0
  14. package/dist/chunk-names.js +1 -1
  15. package/dist/client.d.ts +3 -9
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +209 -27
  18. package/dist/client.js.map +1 -1
  19. package/dist/conformance.d.ts +2 -2
  20. package/dist/conformance.js +3 -3
  21. package/dist/deferred.d.ts +11 -11
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +16 -16
  24. package/dist/dev-error.d.ts +2 -2
  25. package/dist/dev-error.js +3 -3
  26. package/dist/dev-port.d.ts +36 -0
  27. package/dist/dev-port.d.ts.map +1 -0
  28. package/dist/dev-port.js +48 -0
  29. package/dist/dev-port.js.map +1 -0
  30. package/dist/dev.d.ts +62 -5
  31. package/dist/dev.d.ts.map +1 -1
  32. package/dist/dev.js +419 -60
  33. package/dist/dev.js.map +1 -1
  34. package/dist/draft.d.ts +44 -7
  35. package/dist/draft.d.ts.map +1 -1
  36. package/dist/draft.js +92 -9
  37. package/dist/draft.js.map +1 -1
  38. package/dist/fn-state.d.ts +56 -0
  39. package/dist/fn-state.d.ts.map +1 -0
  40. package/dist/fn-state.js +77 -0
  41. package/dist/fn-state.js.map +1 -0
  42. package/dist/fn.d.ts +111 -0
  43. package/dist/fn.d.ts.map +1 -0
  44. package/dist/fn.js +125 -0
  45. package/dist/fn.js.map +1 -0
  46. package/dist/fonts-google.d.ts +9 -9
  47. package/dist/fonts-google.js +10 -10
  48. package/dist/fonts.d.ts +7 -7
  49. package/dist/fonts.js +1 -1
  50. package/dist/forms.d.ts +8 -8
  51. package/dist/fs.d.ts +15 -2
  52. package/dist/fs.d.ts.map +1 -1
  53. package/dist/fs.js +8 -2
  54. package/dist/fs.js.map +1 -1
  55. package/dist/index.d.ts +266 -74
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +869 -179
  58. package/dist/index.js.map +1 -1
  59. package/dist/internal/head-attributes.d.ts +15 -0
  60. package/dist/internal/head-attributes.d.ts.map +1 -0
  61. package/dist/internal/head-attributes.js +99 -0
  62. package/dist/internal/head-attributes.js.map +1 -0
  63. package/dist/internal/script-types.d.ts +14 -0
  64. package/dist/internal/script-types.d.ts.map +1 -0
  65. package/dist/internal/script-types.js +17 -0
  66. package/dist/internal/script-types.js.map +1 -0
  67. package/dist/internal/server-boundary.d.ts +60 -0
  68. package/dist/internal/server-boundary.d.ts.map +1 -0
  69. package/dist/internal/server-boundary.js +174 -0
  70. package/dist/internal/server-boundary.js.map +1 -0
  71. package/dist/internal/timing-safe-equal.d.ts +16 -0
  72. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  73. package/dist/internal/timing-safe-equal.js +26 -0
  74. package/dist/internal/timing-safe-equal.js.map +1 -0
  75. package/dist/internal/vite-import.d.ts +63 -0
  76. package/dist/internal/vite-import.d.ts.map +1 -0
  77. package/dist/internal/vite-import.js +94 -0
  78. package/dist/internal/vite-import.js.map +1 -0
  79. package/dist/islands.d.ts +5 -5
  80. package/dist/islands.js +5 -5
  81. package/dist/isr.d.ts +15 -15
  82. package/dist/isr.d.ts.map +1 -1
  83. package/dist/isr.js +8 -21
  84. package/dist/isr.js.map +1 -1
  85. package/dist/llms-txt.d.ts.map +1 -1
  86. package/dist/llms-txt.js +12 -6
  87. package/dist/llms-txt.js.map +1 -1
  88. package/dist/manifest.d.ts +121 -40
  89. package/dist/manifest.d.ts.map +1 -1
  90. package/dist/manifest.js +136 -26
  91. package/dist/manifest.js.map +1 -1
  92. package/dist/module-graph.d.ts +91 -0
  93. package/dist/module-graph.d.ts.map +1 -0
  94. package/dist/module-graph.js +92 -0
  95. package/dist/module-graph.js.map +1 -0
  96. package/dist/navigation.d.ts +120 -5
  97. package/dist/navigation.d.ts.map +1 -1
  98. package/dist/navigation.js +37 -5
  99. package/dist/navigation.js.map +1 -1
  100. package/dist/plugins/css-modules.d.ts +3 -3
  101. package/dist/plugins/css-modules.d.ts.map +1 -1
  102. package/dist/plugins/css-modules.js +0 -0
  103. package/dist/plugins/css-modules.js.map +1 -1
  104. package/dist/plugins/kit.d.ts +8 -8
  105. package/dist/plugins/kit.js +9 -9
  106. package/dist/plugins/scss.js +3 -3
  107. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  108. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  109. package/dist/plugins/vite-leak-guard.js +67 -0
  110. package/dist/plugins/vite-leak-guard.js.map +1 -0
  111. package/dist/plugins/vite-server-fn.d.ts +23 -0
  112. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  113. package/dist/plugins/vite-server-fn.js +51 -0
  114. package/dist/plugins/vite-server-fn.js.map +1 -0
  115. package/dist/plugins/vite-server-only.d.ts +49 -0
  116. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  117. package/dist/plugins/vite-server-only.js +57 -0
  118. package/dist/plugins/vite-server-only.js.map +1 -0
  119. package/dist/prerender.d.ts +5 -5
  120. package/dist/prerender.js +7 -7
  121. package/dist/public-dir.d.ts +36 -0
  122. package/dist/public-dir.d.ts.map +1 -0
  123. package/dist/public-dir.js +89 -0
  124. package/dist/public-dir.js.map +1 -0
  125. package/dist/query.d.ts +10 -10
  126. package/dist/query.js +15 -15
  127. package/dist/route-manifest.d.ts +91 -0
  128. package/dist/route-manifest.d.ts.map +1 -0
  129. package/dist/route-manifest.js +128 -0
  130. package/dist/route-manifest.js.map +1 -0
  131. package/dist/router.d.ts +94 -22
  132. package/dist/router.d.ts.map +1 -1
  133. package/dist/router.js +170 -58
  134. package/dist/router.js.map +1 -1
  135. package/dist/search.d.ts +94 -0
  136. package/dist/search.d.ts.map +1 -0
  137. package/dist/search.js +232 -0
  138. package/dist/search.js.map +1 -0
  139. package/dist/server-only.d.ts +6 -6
  140. package/dist/server-only.js +7 -7
  141. package/dist/service-worker.d.ts +56 -0
  142. package/dist/service-worker.d.ts.map +1 -0
  143. package/dist/service-worker.js +127 -0
  144. package/dist/service-worker.js.map +1 -0
  145. package/dist/vite.d.ts +25 -17
  146. package/dist/vite.d.ts.map +1 -1
  147. package/dist/vite.js +75 -33
  148. package/dist/vite.js.map +1 -1
  149. package/package.json +48 -4
  150. package/server-manifest.d.ts +31 -0
  151. package/src/build-vite.ts +553 -0
  152. package/src/build.ts +563 -172
  153. package/src/bun-dev-entry.ts +184 -0
  154. package/src/chunk-names.ts +1 -1
  155. package/src/client.ts +221 -30
  156. package/src/conformance.ts +3 -3
  157. package/src/deferred.ts +17 -17
  158. package/src/dev-error.ts +3 -3
  159. package/src/dev-port.ts +63 -0
  160. package/src/dev.ts +468 -66
  161. package/src/draft.ts +124 -12
  162. package/src/fn-state.ts +107 -0
  163. package/src/fn.ts +199 -0
  164. package/src/fonts-google.ts +13 -13
  165. package/src/fonts.ts +7 -7
  166. package/src/forms.ts +8 -8
  167. package/src/fs.ts +24 -6
  168. package/src/index.ts +1174 -225
  169. package/src/internal/head-attributes.ts +109 -0
  170. package/src/internal/script-types.ts +18 -0
  171. package/src/internal/server-boundary.ts +187 -0
  172. package/src/internal/timing-safe-equal.ts +24 -0
  173. package/src/internal/vite-import.ts +92 -0
  174. package/src/islands.ts +7 -7
  175. package/src/isr.ts +18 -30
  176. package/src/llms-txt.ts +13 -5
  177. package/src/manifest.ts +264 -48
  178. package/src/module-graph.ts +146 -0
  179. package/src/navigation.ts +151 -5
  180. package/src/plugins/css-modules.ts +0 -0
  181. package/src/plugins/kit.ts +11 -11
  182. package/src/plugins/scss.ts +3 -3
  183. package/src/plugins/vite-leak-guard.ts +110 -0
  184. package/src/plugins/vite-server-fn.ts +67 -0
  185. package/src/plugins/vite-server-only.ts +75 -0
  186. package/src/prerender.ts +10 -10
  187. package/src/public-dir.ts +108 -0
  188. package/src/query.ts +22 -22
  189. package/src/route-manifest.ts +240 -0
  190. package/src/router.ts +288 -74
  191. package/src/search.ts +297 -0
  192. package/src/server-only.ts +7 -7
  193. package/src/service-worker.ts +165 -0
  194. package/src/vite.ts +103 -49
package/src/llms-txt.ts CHANGED
@@ -91,11 +91,19 @@ export async function generateLlmsTxt(
91
91
  // 1. Project Guidelines
92
92
  let agentsMd = ""
93
93
  try {
94
- if (typeof Bun !== "undefined") {
95
- agentsMd = await Bun.file("AGENTS.md").text()
96
- } else {
97
- const fs = await import("node:fs/promises")
98
- agentsMd = await fs.readFile("AGENTS.md", "utf-8")
94
+ // Edge bundles define this flag and dead-code-eliminate the filesystem branch. Keeping the import
95
+ // behind a runtime-only `typeof Bun` check is insufficient: Rollup must still emit an unresolved
96
+ // `node:` dependency, which workerd cannot load.
97
+ const edgeRuntime = (
98
+ globalThis as typeof globalThis & { readonly __NIFRA_EDGE_RUNTIME__?: boolean }
99
+ ).__NIFRA_EDGE_RUNTIME__
100
+ if (edgeRuntime !== true) {
101
+ if (typeof Bun !== "undefined") {
102
+ agentsMd = await Bun.file("AGENTS.md").text()
103
+ } else {
104
+ const fs = await import("node:fs/promises")
105
+ agentsMd = await fs.readFile("AGENTS.md", "utf-8")
106
+ }
99
107
  }
100
108
  } catch {
101
109
  // ignore
package/src/manifest.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  /**
2
- * Route manifest — the fs-free heart of file-based routing. Maps route file paths to nifra
2
+ * Route manifest - the fs-free heart of file-based routing. Maps route file paths to nifra
3
3
  * router patterns + their nested layout chain. `discoverRoutes` (in `@nifrajs/web/fs`) scans
4
4
  * the filesystem and feeds `buildManifest`; everything here is pure logic, so it stays
5
5
  * portable (no fs, no DOM) and fully unit-testable. Edge deploys pre-build the manifest.
6
6
  */
7
+ import type { StandardSchemaV1 } from "@nifrajs/core/server"
7
8
 
8
9
  /** Context passed to a route `loader`. The `api` + `env` are injected by `createWebApp` and typed
9
10
  * per-route via `@nifrajs/client`'s `LoaderArgs<Api, Env>` (here they are opaque to the agnostic core). */
10
11
  export interface LoaderContext {
11
12
  readonly params: Record<string, string>
12
13
  readonly request: Request
13
- /** Alias of {@link request} — mirrors a route handler's `c.req` so the same name works in both. */
14
+ /** Alias of {@link request} - mirrors a route handler's `c.req` so the same name works in both. */
14
15
  readonly req: Request
15
16
  readonly api: unknown
16
17
  /** Platform bindings forwarded from the request `c.env` (Workers env/KV/D1). Opaque here. */
@@ -18,6 +19,10 @@ export interface LoaderContext {
18
19
  /** `true` when the request carries a valid draft/preview cookie (only when `createWebApp` is given a
19
20
  * `draftSecret`; otherwise always `false`). Branch on it to load unpublished content for editors. */
20
21
  readonly draft: boolean
22
+ /** The URL search params, validated against the route's `searchSchema` (a Standard Schema) when it
23
+ * declares one - failing closed to the schema's defaults - else the raw parsed query. Typed per-route
24
+ * via `@nifrajs/client`'s `LoaderArgs<Api, Env, Search>`. */
25
+ readonly search: Record<string, unknown>
21
26
  }
22
27
 
23
28
  /** A route's optional data loader: params/request in, data out. */
@@ -25,20 +30,20 @@ export type Loader = (ctx: LoaderContext) => unknown | Promise<unknown>
25
30
 
26
31
  /**
27
32
  * A route's optional mutation, run on POST. Shares the loader context (params/request/api);
28
- * read the form/JSON body off `request`. Returns either a `Response` (e.g. a redirect —
33
+ * read the form/JSON body off `request`. Returns either a `Response` (e.g. a redirect -
29
34
  * passed straight through) or data, surfaced to the page component as `actionData`.
30
35
  */
31
36
  export type Action = (ctx: LoaderContext) => unknown | Promise<unknown>
32
37
 
33
38
  /**
34
39
  * One `<link>` tag's attributes for a route/layout's `meta.link`. The common HTML `<link>` attributes
35
- * are spelled out and **optional** so a typed partial like `{ rel, href, hreflang }` is assignable —
40
+ * are spelled out and **optional** so a typed partial like `{ rel, href, hreflang }` is assignable -
36
41
  * the previous `Record<string, string>` required *every* value to be a present string, which rejected
37
- * exactly that idiomatic shape (the bug this fixes). The index signature keeps custom/`data-*` attrs
38
- * (and any future standard attr) passing without a cast; `boolean` covers boolean attributes like
39
- * `disabled` (rendered as a bare attribute when `true`, omitted when `false`), and `undefined` lets a
40
- * caller spread in a conditionally-absent attribute. Attribute *names* are still shape-validated and
41
- * values HTML-escaped at render — a widened type never widens the injection surface.
42
+ * exactly that idiomatic shape (the bug this fixes). Standard attributes are explicit and the template
43
+ * index signature admits inert `data-*` metadata without opening executable `on*` attributes. `boolean`
44
+ * covers `disabled` (rendered bare when `true`, omitted when `false`), and `undefined` lets a caller
45
+ * spread in a conditionally absent attribute. SSR and soft navigation apply one runtime allowlist too,
46
+ * so a cast or untyped route cannot widen the injection surface.
42
47
  */
43
48
  export interface LinkDescriptor {
44
49
  readonly rel?: string
@@ -46,6 +51,7 @@ export interface LinkDescriptor {
46
51
  readonly hreflang?: string
47
52
  readonly crossorigin?: string
48
53
  readonly media?: string
54
+ readonly nonce?: string
49
55
  readonly sizes?: string
50
56
  readonly type?: string
51
57
  readonly as?: string
@@ -57,43 +63,85 @@ export interface LinkDescriptor {
57
63
  readonly imagesizes?: string
58
64
  readonly color?: string
59
65
  readonly disabled?: boolean
60
- readonly [attr: string]: string | boolean | undefined
66
+ readonly [attr: `data-${string}`]: string | undefined
61
67
  }
62
68
 
63
- /** One `<script>` element a route contributes to `<head>` — for structured data (JSON-LD) and other
69
+ /** One managed `<meta>` tag. Standard attributes and inert `data-*` metadata only. */
70
+ export interface MetaDescriptor {
71
+ readonly charset?: string
72
+ readonly content?: string
73
+ readonly "http-equiv"?: string
74
+ readonly itemprop?: string
75
+ readonly media?: string
76
+ readonly name?: string
77
+ readonly property?: string
78
+ readonly scheme?: string
79
+ readonly [attr: `data-${string}`]: string | undefined
80
+ }
81
+
82
+ /** One `<script>` element a route contributes to `<head>` - for structured data (JSON-LD) and other
64
83
  * inert, non-executable head scripts. The `content` is the script body; `type` defaults to
65
84
  * `"application/ld+json"` (the common case). The renderer escapes `content` against an HTML breakout
66
- * (`</`, `<!--`, `]]>`) — see `escapeScriptContent` — so a JSON-LD payload can never close the
85
+ * (`</`, `<!--`, `]]>`) - see `escapeScriptContent` - so a JSON-LD payload can never close the
67
86
  * `<script>` element early. `content` is **JSON/text, never raw HTML**: this slot is not an XSS escape
68
87
  * hatch for arbitrary markup. */
88
+ export type InertScriptType = "application/ld+json" | "application/json"
89
+
69
90
  export interface ScriptDescriptor {
70
91
  /** The script's `type` attribute. Default `"application/ld+json"`. */
71
- readonly type?: string
92
+ readonly type?: InertScriptType
72
93
  /** The script body (e.g. a `JSON.stringify`'d JSON-LD object). Escaped for safe `<script>` embedding. */
73
94
  readonly content: string
74
95
  }
75
96
 
97
+ /** Explicit escape hatch for executable inline code. A CSP nonce is mandatory. */
98
+ export interface UnsafeScriptDescriptor {
99
+ readonly unsafe: true
100
+ readonly type: "module" | "text/javascript"
101
+ readonly nonce: string
102
+ readonly content: string
103
+ }
104
+
76
105
  /**
77
- * The document head a route contributes — title + `<meta>`/`<link>`/`<script>` tag sets. Returned by a
106
+ * The document head a route contributes - title + `<meta>`/`<link>`/`<script>` tag sets. Returned by a
78
107
  * route/layout `meta` (statically, or from a {@link MetaArgs} function). Every value is serialized into
79
- * managed (`data-nifra`) head tags: attribute *names* are shape-validated and *values* HTML-escaped at
80
- * render (see `tagAttrs`/`headTags` in `@nifrajs/web`), and `script[].content` is breakout-escaped — so
81
- * loader-derived strings (LLM-authored `og:*`, user content) are XSS-safe in the head by construction.
108
+ * managed (`data-nifra`) head tags: tag-specific attribute allowlists reject event handlers and active
109
+ * URL/refresh contexts, values are HTML-escaped at render, and `script[].content` is breakout-escaped -
110
+ * so loader-derived strings (LLM-authored `og:*`, user content) are XSS-safe by construction.
82
111
  * Layout-chain heads merge with the page's via `mergeHeads` (arrays concat outermost→page; `title` is
83
112
  * nearest-wins). Build `og:*`/`twitter:*` with `openGraph(...)`, canonical with `canonical(...)`, and
84
113
  * JSON-LD with `jsonLd(...)` (all from `@nifrajs/web`) rather than hand-writing the records.
85
114
  */
86
115
  export interface Meta {
87
116
  readonly title?: string
88
- readonly meta?: ReadonlyArray<Record<string, string>>
117
+ readonly meta?: readonly MetaDescriptor[]
89
118
  readonly link?: readonly LinkDescriptor[]
90
119
  /** Inert head `<script>`s (JSON-LD structured data, etc.). See {@link ScriptDescriptor}. */
91
120
  readonly script?: readonly ScriptDescriptor[]
121
+ /** Executable inline scripts. Prefer external modules; construct only with `unsafeInlineScript()`. */
122
+ readonly unsafeScript?: readonly UnsafeScriptDescriptor[]
123
+ /**
124
+ * The document language - `<html lang="...">`. Nearest-wins like `title`, so a localized route can
125
+ * override a layout's default. Defaults to `"en"` when no head in the chain sets it.
126
+ *
127
+ * This is the ONLY way to set it: the shell's `<html>` is framework-owned, so a multilingual app
128
+ * otherwise serves every URL as `lang="en"` - which tells a screen reader to pronounce every locale
129
+ * with an English voice, and suppresses the browser's translation offer.
130
+ */
131
+ readonly lang?: string
132
+ /**
133
+ * The document writing direction - `<html dir="...">`. Nearest-wins like `title`. Omitted from the
134
+ * shell when unset, which is HTML's `ltr` default, so an LTR app's output is byte-identical.
135
+ *
136
+ * Required for Arabic/Hebrew/Urdu/Persian: without it the browser lays the page out left-to-right
137
+ * and mis-orders trailing punctuation, so the text renders wrong rather than merely unstyled.
138
+ */
139
+ readonly dir?: "ltr" | "rtl" | "auto"
92
140
  }
93
141
 
94
142
  /**
95
143
  * Args for a route's `meta` function: the loader's `data` + the route `params` + the request `origin`.
96
- * `meta()` runs in BOTH SSR and client navigation, so it has **no `request`/`process.env`/server access** —
144
+ * `meta()` runs in BOTH SSR and client navigation, so it has **no `request`/`process.env`/server access** -
97
145
  * `origin` is the only server-resolved fact it gets (so you needn't thread `siteUrl` through loader data
98
146
  * for absolute `og:url`/`canonical`/`og:image` URLs). See {@link origin}.
99
147
  */
@@ -101,10 +149,10 @@ export interface MetaArgs<Data = unknown> {
101
149
  readonly data: Data
102
150
  readonly params: Record<string, string>
103
151
  /**
104
- * The site origin — scheme + host (+ port), e.g. `"https://news.example.com"`, **with no trailing
152
+ * The site origin - scheme + host (+ port), e.g. `"https://news.example.com"`, **with no trailing
105
153
  * slash**. The single piece of server/env knowledge `meta()` otherwise can't see: it runs in BOTH
106
154
  * SSR and client navigation, so it has no `request`/`process.env`. The framework resolves it from the
107
- * request URL during SSR and from `location.origin` on client nav — and they match, so an absolute
155
+ * request URL during SSR and from `location.origin` on client nav - and they match, so an absolute
108
156
  * `og:url`/`canonical`/`og:image` built from it never drifts between the server-rendered `<head>` and
109
157
  * a soft-nav. Use it for absolute URLs (`origin + "/posts/" + slug`) instead of threading `siteUrl`
110
158
  * through loader data. Empty string (`""`) when the origin is unknown (e.g. a hand-built test render
@@ -114,7 +162,7 @@ export interface MetaArgs<Data = unknown> {
114
162
  }
115
163
 
116
164
  /** A route's `meta`: a static {@link Meta}, or a function of the loader data + params + the request
117
- * origin ({@link MetaArgs}). Use the `origin` arg for absolute `canonical`/`og:url`/`og:image` URLs —
165
+ * origin ({@link MetaArgs}). Use the `origin` arg for absolute `canonical`/`og:url`/`og:image` URLs -
118
166
  * it's resolved server-side from the request and matches the client's `location.origin`. */
119
167
  export type MetaInput = Meta | ((args: MetaArgs) => Meta)
120
168
 
@@ -129,7 +177,7 @@ export interface StaticPaths {
129
177
  readonly paths: readonly StaticPath[]
130
178
  /**
131
179
  * How a path NOT in `paths` is handled. `"ssr"` (default) → rendered on-demand by the worker
132
- * (the natural hybrid behavior — an unlisted path simply isn't a static file); `"404"` → only the
180
+ * (the natural hybrid behavior - an unlisted path simply isn't a static file); `"404"` → only the
133
181
  * listed paths exist. Recorded by `prerenderRoutes` for the deploy layer.
134
182
  */
135
183
  readonly fallback?: "ssr" | "404"
@@ -138,11 +186,21 @@ export interface StaticPaths {
138
186
  /** A dynamic route's build-time param enumeration (the SSG equivalent of "which pages exist"). */
139
187
  export type GetStaticPaths = () => StaticPaths | Promise<StaticPaths>
140
188
 
141
- /** A route module — the default component + optional loader / action / meta. */
189
+ /** A route module - the default component + optional loader / action / meta. */
142
190
  export interface RouteModule {
143
191
  readonly default: unknown
144
192
  readonly loader?: Loader
145
193
  readonly action?: Action
194
+ /** A Standard Schema validating this route's URL search params. When present, `ctx.search` is parsed +
195
+ * validated against it (failing closed to the schema's defaults on invalid input); type it into the
196
+ * loader with `LoaderArgs<Api, Env, typeof searchSchema>`. */
197
+ readonly searchSchema?: StandardSchemaV1<unknown, Record<string, unknown>>
198
+ /** Search keys that are purely client-side UI (`"tab"`, a client-side `"sort"`, `"modal"`) and do NOT
199
+ * affect this route's loader. When a client navigation stays on the same route + pathname and changes
200
+ * ONLY these keys, the router updates the URL (so `useSearch` re-renders) WITHOUT re-running the loader.
201
+ * Any other key change revalidates as usual - so omitting a data-affecting key here can never serve
202
+ * stale data, it only forgoes the optimization. Client-only: the server always renders fresh. */
203
+ readonly searchClientKeys?: readonly string[]
146
204
  readonly meta?: MetaInput
147
205
  /**
148
206
  * Opt this route out of nifra's full-document client hydration. The server still renders the full
@@ -154,12 +212,12 @@ export interface RouteModule {
154
212
  /**
155
213
  * Opt a **static** route (no `:param`/`*`) into build-time prerendering (SSG): `prerenderRoutes`
156
214
  * (from `@nifrajs/web/build`) renders it to a static `index.html` at build. The loader runs at build
157
- * with the in-process `api` (build-safe data only — no per-request cookies/secrets); `defer()` on a
215
+ * with the in-process `api` (build-safe data only - no per-request cookies/secrets); `defer()` on a
158
216
  * prerendered route resolves at build. For **dynamic** routes use {@link getStaticPaths} instead.
159
217
  */
160
218
  readonly prerender?: boolean
161
219
  /**
162
- * Enumerate the concrete params to prerender for a **dynamic** (`:param`) route — the SSG path list
220
+ * Enumerate the concrete params to prerender for a **dynamic** (`:param`) route - the SSG path list
163
221
  * (blogs/docs/etc.). Runs at build; `prerenderRoutes` renders one `index.html` per returned path.
164
222
  */
165
223
  readonly getStaticPaths?: GetStaticPaths
@@ -171,7 +229,7 @@ export interface RouteModule {
171
229
  readonly revalidate?: number
172
230
  /**
173
231
  * No-framework island bundles (`@nifrajs/web/islands`) to load on this route, as `<script
174
- * type="module">` in the document tail. Loaded **regardless of `hydrate`** — pair with
232
+ * type="module">` in the document tail. Loaded **regardless of `hydrate`** - pair with
175
233
  * `export const hydrate = false` for a static page that ships zero framework JS and mounts
176
234
  * interactivity through `<Island>` markers + `mountIslands` enhancers instead.
177
235
  */
@@ -181,7 +239,7 @@ export interface RouteModule {
181
239
  /** A layout (or `_404`/`_error`) entry: its source file (for client codegen) + a lazy loader.
182
240
  *
183
241
  * A `_layout.tsx` may export `meta` (static {@link Meta} or a function of the loader data + params,
184
- * same shape as a route's) to contribute sitewide `<head>` tags — `hreflang`/`preconnect`/etc. that
242
+ * same shape as a route's) to contribute sitewide `<head>` tags - `hreflang`/`preconnect`/etc. that
185
243
  * belong on every page under the layout. The layout chain's heads merge with the page's: arrays
186
244
  * (`meta`/`link`) concatenate outermost→innermost→page; scalars (`title`) are nearest-wins (the page
187
245
  * overrides an inner layout, which overrides an outer one). See `mergeHeads` in `@nifrajs/web`. */
@@ -195,6 +253,16 @@ export interface RouteEntry {
195
253
  readonly id: string
196
254
  readonly pattern: string
197
255
  readonly layoutIds: readonly string[]
256
+ /**
257
+ * Param names each layout in {@link layoutIds} owns, aligned by index.
258
+ *
259
+ * A layout wraps a URL prefix, so these are the params it may read and the ones whose change should
260
+ * re-run its loader - everything deeper belongs to routes beneath it. Derived per (route, layout)
261
+ * pair rather than per layout, because one layout can own different params on different expanded
262
+ * patterns: `[[lang]]/docs/_layout` owns nothing on `/docs/:slug` and `{lang}` on `/:lang/docs/:slug`.
263
+ * Optional only so hand-built test manifests may omit it.
264
+ */
265
+ readonly layoutParams?: ReadonlyArray<readonly string[]>
198
266
  /** `_error` boundary ids in this route's ancestor chain (outermost → innermost). The last is the
199
267
  * **nearest** boundary, rendered when the route's loader throws. Always set by `buildManifest`
200
268
  * (optional only so hand-built test manifests may omit it); absent/empty ⇒ no boundary (error 500s). */
@@ -211,11 +279,16 @@ export interface Manifest {
211
279
  * `buildManifest` (optional only so hand-built test manifests may omit it). */
212
280
  readonly errors?: Readonly<Record<string, LayoutEntry>>
213
281
  readonly notFound?: LayoutEntry
282
+ /** Per-status terminal pages from `_<status>.tsx` at the routes root (`_410`, `_451`, …), keyed by
283
+ * the status as a string. Rendered by a loader's `gone()` / `statusPage(n)`; a status with no page
284
+ * falls back to `_404`, and then to plain text. `_404` itself stays on {@link notFound} - it is
285
+ * reached by unmatched paths too, which these are not. */
286
+ readonly statusPages?: Readonly<Record<string, LayoutEntry>>
214
287
  }
215
288
 
216
289
  // `.svelte` and `.vue` routes are supported too: their `default` export is the component and
217
290
  // `loader`/`action`/`meta` come from a module-level script block (Svelte `<script module>`, Vue's plain
218
- // `<script>`) as named ESM exports — the same RouteModule shape as `.tsx`. Both compile via their
291
+ // `<script>`) as named ESM exports - the same RouteModule shape as `.tsx`. Both compile via their
219
292
  // package's Bun plugin (`@nifrajs/web-svelte/plugin`, `@nifrajs/web-vue/plugin`).
220
293
  const ROUTE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
221
294
  const PARAM = /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/
@@ -224,8 +297,12 @@ const CATCH_ALL = /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/
224
297
  // two patterns (`:lang` present / absent), so `[[lang]]/about` serves both `/about` and `/en/about`.
225
298
  const OPTIONAL = /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/
226
299
  // A route group: a `(name)` folder organizes routes (and can hold its own `_layout`) without
227
- // contributing a URL segment — mirrors Next/Remix. Requires content between the parens.
300
+ // contributing a URL segment - mirrors Next/Remix. Requires content between the parens.
228
301
  const GROUP = /^\(.+\)$/
302
+ // A terminal status page: `_410.tsx`, `_451.tsx`. `_404` is matched before this and stays its own
303
+ // thing. Restricted to 3 digits so `_401k` or `_4` is treated as an ordinary underscore-prefixed
304
+ // file (ignored) rather than silently becoming a status page.
305
+ const STATUS_PAGE = /^_[1-5][0-9][0-9]$/
229
306
 
230
307
  const stripExt = (file: string): string => file.replace(ROUTE_EXT, "")
231
308
  const baseName = (file: string): string => file.slice(file.lastIndexOf("/") + 1)
@@ -238,18 +315,116 @@ const errorIdFor = (dir: string): string => (dir === "" ? "_error" : `${dir}/_er
238
315
  * Derive **every** nifra router pattern a route file maps to (relative to the routes dir):
239
316
  * `index` → the parent path, `[id]` → `:id`, `[...slug]` → `*slug` (catch-all, captures the rest of
240
317
  * the path into one param), `(group)` folders are dropped from the URL (organization only), and an
241
- * optional `[[lang]]` expands the set — once with the segment present (`:lang`) and once absent. A file
318
+ * optional `[[lang]]` expands the set - once with the segment present (`:lang`) and once absent. A file
242
319
  * with no optionals yields exactly one pattern. Throws on an invalid param or a catch-all that isn't
243
320
  * the last segment.
244
321
  */
322
+ /** Every `[…]` marker in a file segment, so a part-literal segment can be spliced into a pattern. */
323
+ const FILE_MARKER = /\[([^\]]*)\]/g
324
+ const FILE_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
325
+
326
+ /**
327
+ * Translate a part-literal-part-`[param]` file segment into its route pattern, or `undefined` when
328
+ * the segment is wholly literal or wholly a marker (both already handled by the caller).
329
+ *
330
+ * `[inKey].txt` becomes `:inKey.txt`. Needed because IndexNow-style verification files, per-locale
331
+ * sitemaps, and static-site URL parity (`post-[id].html`) all put a variable INSIDE a filename, and
332
+ * `stripExt` already reduces `[inKey].txt.tsx` to `[inKey].txt` - `robots.txt.tsx` proves literal
333
+ * dots survive into a segment, so this was only ever a parser gap.
334
+ *
335
+ * `[[optional]]` and `[...catchAll]` are **rejected** inside a mixed segment rather than given a
336
+ * meaning. There is no sensible absent form for `/[[locale]]-feed.xml` - `/-feed.xml` and dropping
337
+ * the segment are both surprising - and a catch-all captures the remaining path verbatim, which
338
+ * conflicts with a trailing literal. A clear rejection beats semantics nobody can predict.
339
+ */
340
+ function mixedFileSegment(seg: string, file: string): string | undefined {
341
+ // A segment that is ENTIRELY one of the existing whole-segment forms belongs to the caller, not
342
+ // here. Checked against the real patterns rather than by comparing the first marker to the
343
+ // segment: `[[lang]]` does not equal its own first `[…]` match, so a naive comparison sends every
344
+ // optional segment down the mixed path and rejects it.
345
+ if (PARAM.test(seg) || OPTIONAL.test(seg) || CATCH_ALL.test(seg)) return undefined
346
+ const markers = [...seg.matchAll(FILE_MARKER)]
347
+ if (markers.length === 0) return undefined
348
+
349
+ let pattern = ""
350
+ let cursor = 0
351
+ for (const marker of markers) {
352
+ const inner = marker[1] ?? ""
353
+ if (inner.startsWith("...")) {
354
+ throw new Error(
355
+ `[nifra/web] catch-all "[${inner}]" cannot be combined with literal text in "${file}" (segment "${seg}") - a catch-all captures the rest of the path, so a trailing literal can never match. Give it its own segment.`,
356
+ )
357
+ }
358
+ if (inner.startsWith("[")) {
359
+ throw new Error(
360
+ `[nifra/web] optional "[${inner}]]" cannot be combined with literal text in "${file}" (segment "${seg}") - there is no sensible form for the segment when it is absent. Use a required [name], or give the optional its own segment.`,
361
+ )
362
+ }
363
+ if (!FILE_PARAM_NAME.test(inner)) {
364
+ throw new Error(
365
+ `[nifra/web] invalid route param in "${file}": "[${inner}]" must be [name], [[name]], [...name], or a (group) folder`,
366
+ )
367
+ }
368
+ pattern += `${seg.slice(cursor, marker.index)}:${inner}`
369
+ cursor = (marker.index ?? 0) + marker[0].length
370
+ }
371
+ return pattern + seg.slice(cursor)
372
+ }
373
+
245
374
  export function filePathToPatterns(file: string): string[] {
246
- // Each combo is a list of URL segments built so far; an optional segment doubles the set.
247
- let combos: string[][] = [[]]
375
+ return filePathToRoutes(file).map((route) => route.pattern)
376
+ }
377
+
378
+ /** One URL pattern a route file expands to, plus the scope information layouts need. */
379
+ export interface FileRoutePattern {
380
+ readonly pattern: string
381
+ /**
382
+ * URL segments contributed by the first *k* path parts of the file, indexed by *k*.
383
+ *
384
+ * This is what makes a layout's scope derivable without turning layouts into router nodes. A layout
385
+ * lives at a directory; the segments that directory contributes are exactly the prefix of the URL it
386
+ * wraps, so `depths[partsIn(layoutDir)]` is how many leading pattern segments it owns - and the
387
+ * params inside that prefix are the ones it may read.
388
+ *
389
+ * Per EXPANDED pattern, not per file, because the two diverge: `[[lang]]/docs/_layout` owns nothing
390
+ * on `/docs/:slug` and `{lang}` on `/:lang/docs/:slug`. A route group contributes no segment, so
391
+ * `(marketing)/_layout` owns nothing at all.
392
+ */
393
+ readonly depths: readonly number[]
394
+ }
395
+
396
+ /** Expand a route file into its URL patterns, each carrying per-directory segment counts. */
397
+ export function filePathToRoutes(file: string): FileRoutePattern[] {
398
+ // Each combo is the URL segments built so far, plus the running per-path-part segment count.
399
+ let combos: Array<{ segs: string[]; depths: number[] }> = [{ segs: [], depths: [0] }]
248
400
  const segments = stripExt(file).split("/")
401
+ // Record the running segment count after each path part, on every combo. Called once per part so
402
+ // `depths` stays index-aligned with the file's path parts even for parts that emit nothing.
403
+ const mark = (): void => {
404
+ for (const combo of combos) combo.depths.push(combo.segs.length)
405
+ }
406
+ const push = (segment: string): void => {
407
+ for (const combo of combos) combo.segs.push(segment)
408
+ }
249
409
  for (let i = 0; i < segments.length; i++) {
250
410
  const seg = segments[i]!
251
- if (seg === "index") continue
252
- if (GROUP.test(seg)) continue // route group — no URL segment (its `_layout` still applies, keyed by dir)
411
+ if (seg === "index") {
412
+ mark()
413
+ continue
414
+ }
415
+ if (GROUP.test(seg)) {
416
+ // Route group - no URL segment (its `_layout` still applies, keyed by dir).
417
+ mark()
418
+ continue
419
+ }
420
+ // A segment that is part literal, part `[param]` - `[inKey].txt`, `post-[id].html`. Checked
421
+ // before the whole-segment forms below, which only handle a marker spanning the entire segment.
422
+ const mixed = mixedFileSegment(seg, file)
423
+ if (mixed !== undefined) {
424
+ push(mixed)
425
+ mark()
426
+ continue
427
+ }
253
428
  if (seg.startsWith("[")) {
254
429
  const catchAll = CATCH_ALL.exec(seg)
255
430
  if (catchAll !== null) {
@@ -261,13 +436,18 @@ export function filePathToPatterns(file: string): string[] {
261
436
  `[nifra/web] catch-all "${seg}" must be the last segment in "${file}" (found "${after.join("/")}" after it)`,
262
437
  )
263
438
  }
264
- combos = combos.map((c) => [...c, `*${catchAll[1]}`])
439
+ push(`*${catchAll[1]}`)
440
+ mark()
265
441
  continue
266
442
  }
267
443
  const optional = OPTIONAL.exec(seg)
268
444
  if (optional !== null) {
269
445
  // Optional segment: keep each existing combo (absent) AND a copy with `:name` appended (present).
270
- combos = combos.flatMap((c) => [c, [...c, `:${optional[1]}`]])
446
+ combos = combos.flatMap((c) => [
447
+ { segs: c.segs, depths: c.depths },
448
+ { segs: [...c.segs, `:${optional[1]}`], depths: [...c.depths] },
449
+ ])
450
+ mark()
271
451
  continue
272
452
  }
273
453
  const match = PARAM.exec(seg)
@@ -276,16 +456,17 @@ export function filePathToPatterns(file: string): string[] {
276
456
  `[nifra/web] invalid route param in "${file}": "${seg}" must be [name], [[name]], [...name], or a (group) folder`,
277
457
  )
278
458
  }
279
- combos = combos.map((c) => [...c, `:${match[1]}`])
459
+ push(`:${match[1]}`)
280
460
  } else {
281
- combos = combos.map((c) => [...c, seg])
461
+ push(seg)
282
462
  }
463
+ mark()
283
464
  }
284
- return combos.map((c) => `/${c.join("/")}`)
465
+ return combos.map((c) => ({ pattern: `/${c.segs.join("/")}`, depths: c.depths }))
285
466
  }
286
467
 
287
468
  /**
288
- * The **canonical** single pattern for a route file — all optional segments present. A file with no
469
+ * The **canonical** single pattern for a route file - all optional segments present. A file with no
289
470
  * optionals yields its one pattern. Use {@link filePathToPatterns} to get every pattern (optionals
290
471
  * expand the set).
291
472
  */
@@ -295,6 +476,26 @@ export function filePathToPattern(file: string): string {
295
476
  }
296
477
 
297
478
  /** The dir chain from root → the file's own dir, e.g. "a/b/p.tsx" → ["", "a", "a/b"]. */
479
+ /**
480
+ * Param names in the first `count` segments of a pattern - the params a layout at that depth owns.
481
+ *
482
+ * This is the whole scoping mechanism: a layout wraps a URL prefix, so the params inside that prefix
483
+ * are the ones it may read, and the ones whose change should re-run its loader. Everything deeper
484
+ * belongs to routes below it and must not invalidate it.
485
+ */
486
+ function paramsInPrefix(pattern: string, count: number): string[] {
487
+ if (count === 0) return []
488
+ const names: string[] = []
489
+ const segments = pattern === "/" ? [] : pattern.slice(1).split("/")
490
+ for (const segment of segments.slice(0, count)) {
491
+ // Mirrors the router's own grammar: `:name`, `*name`, and a mixed segment's embedded `:name`s.
492
+ for (const match of segment.matchAll(/[:*]([A-Za-z_][A-Za-z0-9_]*)/g)) {
493
+ names.push(match[1] as string)
494
+ }
495
+ }
496
+ return names
497
+ }
498
+
298
499
  const ancestorDirs = (file: string): string[] => {
299
500
  const dirs = [""]
300
501
  const dir = dirOf(file)
@@ -309,7 +510,7 @@ const ancestorDirs = (file: string): string[] => {
309
510
 
310
511
  /**
311
512
  * Build a manifest from route file paths (relative to the routes dir) + an `importer` that
312
- * turns a path into a lazy module loader. Pure — no fs. Throws at boot (the loud-and-early
513
+ * turns a path into a lazy module loader. Pure - no fs. Throws at boot (the loud-and-early
313
514
  * RouteConfigError ethos) on duplicate patterns. `_layout`/`_404`/`_error` files are special; other
314
515
  * `_`-prefixed files are ignored (private/colocated, never routed).
315
516
  */
@@ -322,6 +523,7 @@ export function buildManifest(
322
523
  const errorDirs = new Set<string>()
323
524
  const errors: Record<string, LayoutEntry> = {}
324
525
  let notFound: LayoutEntry | undefined
526
+ const statusPages: Record<string, LayoutEntry> = {}
325
527
  const routeFiles: string[] = []
326
528
 
327
529
  for (const file of files) {
@@ -336,6 +538,11 @@ export function buildManifest(
336
538
  errors[errorIdFor(dir)] = { file, load: importer(file) }
337
539
  } else if (stem === "_404") {
338
540
  notFound = { file, load: importer(file) }
541
+ } else if (STATUS_PAGE.test(stem) && dirOf(file) === "") {
542
+ // `_410.tsx`, `_451.tsx`, … at the routes root. Root-only: unlike `_error`, these are not
543
+ // resolved per segment - a terminal status is a property of the outcome, not of where in the
544
+ // tree the route lives, and a per-segment variant would need a precedence rule nobody asked for.
545
+ statusPages[stem.slice(1)] = { file, load: importer(file) }
339
546
  } else if (!stem.startsWith("_")) {
340
547
  routeFiles.push(file)
341
548
  }
@@ -345,13 +552,14 @@ export function buildManifest(
345
552
  const routes: RouteEntry[] = []
346
553
  for (const file of routeFiles) {
347
554
  const dirs = ancestorDirs(file)
348
- const layoutIds = dirs.filter((dir) => layoutDirs.has(dir)).map(layoutIdFor)
555
+ const layoutDirsForFile = dirs.filter((dir) => layoutDirs.has(dir))
556
+ const layoutIds = layoutDirsForFile.map(layoutIdFor)
349
557
  const errorIds = dirs.filter((dir) => errorDirs.has(dir)).map(errorIdFor)
350
558
  const id = stripExt(file)
351
559
  const load = importer(file) // one lazy loader per file, shared by its (possibly expanded) patterns
352
560
  // An optional `[[x]]` segment expands a file into multiple patterns, all pointing at the same
353
561
  // module (same id/load/layout chain). Distinct patterns ⇒ no match ambiguity (different lengths).
354
- for (const pattern of filePathToPatterns(file)) {
562
+ for (const { pattern, depths } of filePathToRoutes(file)) {
355
563
  const existing = byPattern.get(pattern)
356
564
  if (existing !== undefined) {
357
565
  throw new Error(
@@ -359,11 +567,19 @@ export function buildManifest(
359
567
  )
360
568
  }
361
569
  byPattern.set(pattern, file)
362
- routes.push({ id, pattern, layoutIds, errorIds, file, load })
570
+ const layoutParams = layoutDirsForFile.map((dir) =>
571
+ paramsInPrefix(pattern, depths[dir === "" ? 0 : dir.split("/").length] ?? 0),
572
+ )
573
+ routes.push({ id, pattern, layoutIds, layoutParams, errorIds, file, load })
363
574
  }
364
575
  }
365
576
 
366
- const base: Manifest = { routes, layouts, errors }
577
+ const base: Manifest = {
578
+ routes,
579
+ layouts,
580
+ errors,
581
+ ...(Object.keys(statusPages).length > 0 ? { statusPages } : {}),
582
+ }
367
583
  return notFound === undefined ? base : { ...base, notFound }
368
584
  }
369
585
 
@@ -402,7 +618,7 @@ export function fillRoutePattern(
402
618
  /** The static-routing facts a server needs from the route modules: which concrete paths are
403
619
  * prerendered, plus each dynamic route's `getStaticPaths` fallback policy. */
404
620
  export interface StaticRoutes {
405
- /** Concrete prerendered paths — static `prerender` routes + each `getStaticPaths` entry. */
621
+ /** Concrete prerendered paths - static `prerender` routes + each `getStaticPaths` entry. */
406
622
  readonly paths: string[]
407
623
  /** Per dynamic route pattern, its `getStaticPaths` `fallback` (`"ssr"` default). `createWebApp` uses
408
624
  * `"404"` to reject an unlisted path under that route at runtime (the path simply doesn't exist). */
@@ -410,7 +626,7 @@ export interface StaticRoutes {
410
626
  }
411
627
 
412
628
  /**
413
- * Enumerate the static-routing facts `prerenderRoutes` would produce — static routes opted in via
629
+ * Enumerate the static-routing facts `prerenderRoutes` would produce - static routes opted in via
414
630
  * `export const prerender = true`, each `getStaticPaths` entry of a dynamic route, and each dynamic
415
631
  * route's `fallback` policy. Pure (no rendering), so a server can compute what to hand `createWebApp`
416
632
  * (the prerendered set for the client's static-`_data.json` soft-nav + the fallback map for