@nifrajs/web 2.2.0 → 2.4.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 (179) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +4 -4
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +20 -74
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +64 -42
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +100 -78
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +1 -1
  11. package/dist/bun-dev-entry.js +1 -1
  12. package/dist/chunk-names.js +1 -1
  13. package/dist/client.d.ts +3 -9
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +201 -29
  16. package/dist/client.js.map +1 -1
  17. package/dist/conformance.d.ts +2 -2
  18. package/dist/conformance.js +3 -3
  19. package/dist/deferred.d.ts +11 -11
  20. package/dist/deferred.d.ts.map +1 -1
  21. package/dist/deferred.js +16 -16
  22. package/dist/dev-diagnostics.d.ts +20 -0
  23. package/dist/dev-diagnostics.d.ts.map +1 -0
  24. package/dist/dev-diagnostics.js +34 -0
  25. package/dist/dev-diagnostics.js.map +1 -0
  26. package/dist/dev-error.d.ts +10 -13
  27. package/dist/dev-error.d.ts.map +1 -1
  28. package/dist/dev-error.js +51 -37
  29. package/dist/dev-error.js.map +1 -1
  30. package/dist/dev-port.d.ts +3 -3
  31. package/dist/dev-port.d.ts.map +1 -1
  32. package/dist/dev-port.js +10 -4
  33. package/dist/dev-port.js.map +1 -1
  34. package/dist/dev.d.ts +4 -4
  35. package/dist/dev.d.ts.map +1 -1
  36. package/dist/dev.js +28 -12
  37. package/dist/dev.js.map +1 -1
  38. package/dist/diagnostic.d.ts +93 -0
  39. package/dist/diagnostic.d.ts.map +1 -0
  40. package/dist/diagnostic.js +205 -0
  41. package/dist/diagnostic.js.map +1 -0
  42. package/dist/draft.d.ts +12 -12
  43. package/dist/draft.js +11 -11
  44. package/dist/fn-state.d.ts +56 -0
  45. package/dist/fn-state.d.ts.map +1 -0
  46. package/dist/fn-state.js +77 -0
  47. package/dist/fn-state.js.map +1 -0
  48. package/dist/fn.d.ts +111 -0
  49. package/dist/fn.d.ts.map +1 -0
  50. package/dist/fn.js +125 -0
  51. package/dist/fn.js.map +1 -0
  52. package/dist/fonts-google.d.ts +9 -9
  53. package/dist/fonts-google.js +10 -10
  54. package/dist/fonts.d.ts +7 -7
  55. package/dist/fonts.js +1 -1
  56. package/dist/forms.d.ts +8 -8
  57. package/dist/fs.d.ts +1 -1
  58. package/dist/fs.js +1 -1
  59. package/dist/index.d.ts +151 -70
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +317 -139
  62. package/dist/index.js.map +1 -1
  63. package/dist/internal/head-attributes.d.ts +15 -0
  64. package/dist/internal/head-attributes.d.ts.map +1 -0
  65. package/dist/internal/head-attributes.js +99 -0
  66. package/dist/internal/head-attributes.js.map +1 -0
  67. package/dist/internal/script-types.d.ts +14 -0
  68. package/dist/internal/script-types.d.ts.map +1 -0
  69. package/dist/internal/script-types.js +17 -0
  70. package/dist/internal/script-types.js.map +1 -0
  71. package/dist/internal/server-boundary.d.ts +60 -0
  72. package/dist/internal/server-boundary.d.ts.map +1 -0
  73. package/dist/internal/server-boundary.js +174 -0
  74. package/dist/internal/server-boundary.js.map +1 -0
  75. package/dist/internal/timing-safe-equal.d.ts +1 -1
  76. package/dist/internal/timing-safe-equal.js +1 -1
  77. package/dist/internal/vite-import.d.ts +63 -0
  78. package/dist/internal/vite-import.d.ts.map +1 -0
  79. package/dist/internal/vite-import.js +94 -0
  80. package/dist/internal/vite-import.js.map +1 -0
  81. package/dist/islands.d.ts +5 -5
  82. package/dist/islands.js +5 -5
  83. package/dist/isr.d.ts +15 -15
  84. package/dist/isr.js +7 -7
  85. package/dist/manifest.d.ts +73 -35
  86. package/dist/manifest.d.ts.map +1 -1
  87. package/dist/manifest.js +10 -16
  88. package/dist/manifest.js.map +1 -1
  89. package/dist/module-graph.d.ts +5 -5
  90. package/dist/module-graph.js +4 -4
  91. package/dist/navigation.d.ts +120 -5
  92. package/dist/navigation.d.ts.map +1 -1
  93. package/dist/navigation.js +37 -5
  94. package/dist/navigation.js.map +1 -1
  95. package/dist/plugins/css-modules.d.ts +3 -3
  96. package/dist/plugins/css-modules.js +0 -0
  97. package/dist/plugins/kit.d.ts +8 -8
  98. package/dist/plugins/kit.js +9 -9
  99. package/dist/plugins/scss.js +3 -3
  100. package/dist/plugins/vite-leak-guard.d.ts +1 -1
  101. package/dist/plugins/vite-leak-guard.js +1 -1
  102. package/dist/plugins/vite-server-fn.d.ts +23 -0
  103. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  104. package/dist/plugins/vite-server-fn.js +51 -0
  105. package/dist/plugins/vite-server-fn.js.map +1 -0
  106. package/dist/plugins/vite-server-only.d.ts +49 -0
  107. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  108. package/dist/plugins/vite-server-only.js +57 -0
  109. package/dist/plugins/vite-server-only.js.map +1 -0
  110. package/dist/prerender.d.ts +5 -5
  111. package/dist/prerender.js +7 -7
  112. package/dist/query.d.ts +10 -10
  113. package/dist/query.js +15 -15
  114. package/dist/route-manifest.d.ts +1 -1
  115. package/dist/route-manifest.js +2 -2
  116. package/dist/router.d.ts +30 -21
  117. package/dist/router.d.ts.map +1 -1
  118. package/dist/router.js +62 -32
  119. package/dist/router.js.map +1 -1
  120. package/dist/search.d.ts +94 -0
  121. package/dist/search.d.ts.map +1 -0
  122. package/dist/search.js +232 -0
  123. package/dist/search.js.map +1 -0
  124. package/dist/server-only.d.ts +6 -6
  125. package/dist/server-only.js +7 -7
  126. package/dist/service-worker.d.ts +56 -0
  127. package/dist/service-worker.d.ts.map +1 -0
  128. package/dist/service-worker.js +127 -0
  129. package/dist/service-worker.js.map +1 -0
  130. package/dist/vite.d.ts +16 -14
  131. package/dist/vite.d.ts.map +1 -1
  132. package/dist/vite.js +51 -24
  133. package/dist/vite.js.map +1 -1
  134. package/package.json +38 -4
  135. package/server-manifest.d.ts +31 -0
  136. package/src/build-vite.ts +24 -77
  137. package/src/build.ts +137 -105
  138. package/src/bun-dev-entry.ts +1 -1
  139. package/src/chunk-names.ts +1 -1
  140. package/src/client.ts +215 -32
  141. package/src/conformance.ts +3 -3
  142. package/src/deferred.ts +17 -17
  143. package/src/dev-diagnostics.ts +47 -0
  144. package/src/dev-error.ts +64 -33
  145. package/src/dev-port.ts +17 -5
  146. package/src/dev.ts +36 -20
  147. package/src/diagnostic.ts +284 -0
  148. package/src/draft.ts +15 -15
  149. package/src/fn-state.ts +107 -0
  150. package/src/fn.ts +199 -0
  151. package/src/fonts-google.ts +13 -13
  152. package/src/fonts.ts +7 -7
  153. package/src/forms.ts +8 -8
  154. package/src/fs.ts +2 -2
  155. package/src/index.ts +460 -181
  156. package/src/internal/head-attributes.ts +109 -0
  157. package/src/internal/script-types.ts +18 -0
  158. package/src/internal/server-boundary.ts +187 -0
  159. package/src/internal/timing-safe-equal.ts +1 -1
  160. package/src/internal/vite-import.ts +92 -0
  161. package/src/islands.ts +7 -7
  162. package/src/isr.ts +17 -17
  163. package/src/manifest.ts +83 -42
  164. package/src/module-graph.ts +6 -6
  165. package/src/navigation.ts +151 -5
  166. package/src/plugins/css-modules.ts +0 -0
  167. package/src/plugins/kit.ts +11 -11
  168. package/src/plugins/scss.ts +3 -3
  169. package/src/plugins/vite-leak-guard.ts +2 -2
  170. package/src/plugins/vite-server-fn.ts +67 -0
  171. package/src/plugins/vite-server-only.ts +75 -0
  172. package/src/prerender.ts +10 -10
  173. package/src/query.ts +22 -22
  174. package/src/route-manifest.ts +3 -3
  175. package/src/router.ts +98 -45
  176. package/src/search.ts +297 -0
  177. package/src/server-only.ts +7 -7
  178. package/src/service-worker.ts +165 -0
  179. package/src/vite.ts +61 -32
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,49 +63,74 @@ 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[]
92
123
  /**
93
- * The document language — `<html lang="...">`. Nearest-wins like `title`, so a localized route can
124
+ * The document language - `<html lang="...">`. Nearest-wins like `title`, so a localized route can
94
125
  * override a layout's default. Defaults to `"en"` when no head in the chain sets it.
95
126
  *
96
127
  * This is the ONLY way to set it: the shell's `<html>` is framework-owned, so a multilingual app
97
- * otherwise serves every URL as `lang="en"` — which tells a screen reader to pronounce every locale
128
+ * otherwise serves every URL as `lang="en"` - which tells a screen reader to pronounce every locale
98
129
  * with an English voice, and suppresses the browser's translation offer.
99
130
  */
100
131
  readonly lang?: string
101
132
  /**
102
- * The document writing direction — `<html dir="...">`. Nearest-wins like `title`. Omitted from the
133
+ * The document writing direction - `<html dir="...">`. Nearest-wins like `title`. Omitted from the
103
134
  * shell when unset, which is HTML's `ltr` default, so an LTR app's output is byte-identical.
104
135
  *
105
136
  * Required for Arabic/Hebrew/Urdu/Persian: without it the browser lays the page out left-to-right
@@ -110,7 +141,7 @@ export interface Meta {
110
141
 
111
142
  /**
112
143
  * Args for a route's `meta` function: the loader's `data` + the route `params` + the request `origin`.
113
- * `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** -
114
145
  * `origin` is the only server-resolved fact it gets (so you needn't thread `siteUrl` through loader data
115
146
  * for absolute `og:url`/`canonical`/`og:image` URLs). See {@link origin}.
116
147
  */
@@ -118,10 +149,10 @@ export interface MetaArgs<Data = unknown> {
118
149
  readonly data: Data
119
150
  readonly params: Record<string, string>
120
151
  /**
121
- * 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
122
153
  * slash**. The single piece of server/env knowledge `meta()` otherwise can't see: it runs in BOTH
123
154
  * SSR and client navigation, so it has no `request`/`process.env`. The framework resolves it from the
124
- * 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
125
156
  * `og:url`/`canonical`/`og:image` built from it never drifts between the server-rendered `<head>` and
126
157
  * a soft-nav. Use it for absolute URLs (`origin + "/posts/" + slug`) instead of threading `siteUrl`
127
158
  * through loader data. Empty string (`""`) when the origin is unknown (e.g. a hand-built test render
@@ -131,7 +162,7 @@ export interface MetaArgs<Data = unknown> {
131
162
  }
132
163
 
133
164
  /** A route's `meta`: a static {@link Meta}, or a function of the loader data + params + the request
134
- * 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 -
135
166
  * it's resolved server-side from the request and matches the client's `location.origin`. */
136
167
  export type MetaInput = Meta | ((args: MetaArgs) => Meta)
137
168
 
@@ -146,7 +177,7 @@ export interface StaticPaths {
146
177
  readonly paths: readonly StaticPath[]
147
178
  /**
148
179
  * How a path NOT in `paths` is handled. `"ssr"` (default) → rendered on-demand by the worker
149
- * (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
150
181
  * listed paths exist. Recorded by `prerenderRoutes` for the deploy layer.
151
182
  */
152
183
  readonly fallback?: "ssr" | "404"
@@ -155,11 +186,21 @@ export interface StaticPaths {
155
186
  /** A dynamic route's build-time param enumeration (the SSG equivalent of "which pages exist"). */
156
187
  export type GetStaticPaths = () => StaticPaths | Promise<StaticPaths>
157
188
 
158
- /** A route module — the default component + optional loader / action / meta. */
189
+ /** A route module - the default component + optional loader / action / meta. */
159
190
  export interface RouteModule {
160
191
  readonly default: unknown
161
192
  readonly loader?: Loader
162
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[]
163
204
  readonly meta?: MetaInput
164
205
  /**
165
206
  * Opt this route out of nifra's full-document client hydration. The server still renders the full
@@ -171,12 +212,12 @@ export interface RouteModule {
171
212
  /**
172
213
  * Opt a **static** route (no `:param`/`*`) into build-time prerendering (SSG): `prerenderRoutes`
173
214
  * (from `@nifrajs/web/build`) renders it to a static `index.html` at build. The loader runs at build
174
- * 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
175
216
  * prerendered route resolves at build. For **dynamic** routes use {@link getStaticPaths} instead.
176
217
  */
177
218
  readonly prerender?: boolean
178
219
  /**
179
- * 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
180
221
  * (blogs/docs/etc.). Runs at build; `prerenderRoutes` renders one `index.html` per returned path.
181
222
  */
182
223
  readonly getStaticPaths?: GetStaticPaths
@@ -188,7 +229,7 @@ export interface RouteModule {
188
229
  readonly revalidate?: number
189
230
  /**
190
231
  * No-framework island bundles (`@nifrajs/web/islands`) to load on this route, as `<script
191
- * 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
192
233
  * `export const hydrate = false` for a static page that ships zero framework JS and mounts
193
234
  * interactivity through `<Island>` markers + `mountIslands` enhancers instead.
194
235
  */
@@ -198,7 +239,7 @@ export interface RouteModule {
198
239
  /** A layout (or `_404`/`_error`) entry: its source file (for client codegen) + a lazy loader.
199
240
  *
200
241
  * A `_layout.tsx` may export `meta` (static {@link Meta} or a function of the loader data + params,
201
- * 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
202
243
  * belong on every page under the layout. The layout chain's heads merge with the page's: arrays
203
244
  * (`meta`/`link`) concatenate outermost→innermost→page; scalars (`title`) are nearest-wins (the page
204
245
  * overrides an inner layout, which overrides an outer one). See `mergeHeads` in `@nifrajs/web`. */
@@ -247,7 +288,7 @@ export interface Manifest {
247
288
 
248
289
  // `.svelte` and `.vue` routes are supported too: their `default` export is the component and
249
290
  // `loader`/`action`/`meta` come from a module-level script block (Svelte `<script module>`, Vue's plain
250
- // `<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
251
292
  // package's Bun plugin (`@nifrajs/web-svelte/plugin`, `@nifrajs/web-vue/plugin`).
252
293
  const ROUTE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
253
294
  const PARAM = /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/
@@ -256,7 +297,7 @@ const CATCH_ALL = /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/
256
297
  // two patterns (`:lang` present / absent), so `[[lang]]/about` serves both `/about` and `/en/about`.
257
298
  const OPTIONAL = /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/
258
299
  // A route group: a `(name)` folder organizes routes (and can hold its own `_layout`) without
259
- // 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.
260
301
  const GROUP = /^\(.+\)$/
261
302
  // A terminal status page: `_410.tsx`, `_451.tsx`. `_404` is matched before this and stays its own
262
303
  // thing. Restricted to 3 digits so `_401k` or `_4` is treated as an ordinary underscore-prefixed
@@ -274,7 +315,7 @@ const errorIdFor = (dir: string): string => (dir === "" ? "_error" : `${dir}/_er
274
315
  * Derive **every** nifra router pattern a route file maps to (relative to the routes dir):
275
316
  * `index` → the parent path, `[id]` → `:id`, `[...slug]` → `*slug` (catch-all, captures the rest of
276
317
  * the path into one param), `(group)` folders are dropped from the URL (organization only), and an
277
- * 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
278
319
  * with no optionals yields exactly one pattern. Throws on an invalid param or a catch-all that isn't
279
320
  * the last segment.
280
321
  */
@@ -311,12 +352,12 @@ function mixedFileSegment(seg: string, file: string): string | undefined {
311
352
  const inner = marker[1] ?? ""
312
353
  if (inner.startsWith("...")) {
313
354
  throw new Error(
314
- `[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.`,
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.`,
315
356
  )
316
357
  }
317
358
  if (inner.startsWith("[")) {
318
359
  throw new Error(
319
- `[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.`,
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.`,
320
361
  )
321
362
  }
322
363
  if (!FILE_PARAM_NAME.test(inner)) {
@@ -372,11 +413,11 @@ export function filePathToRoutes(file: string): FileRoutePattern[] {
372
413
  continue
373
414
  }
374
415
  if (GROUP.test(seg)) {
375
- // Route group — no URL segment (its `_layout` still applies, keyed by dir).
416
+ // Route group - no URL segment (its `_layout` still applies, keyed by dir).
376
417
  mark()
377
418
  continue
378
419
  }
379
- // A segment that is part literal, part `[param]` — `[inKey].txt`, `post-[id].html`. Checked
420
+ // A segment that is part literal, part `[param]` - `[inKey].txt`, `post-[id].html`. Checked
380
421
  // before the whole-segment forms below, which only handle a marker spanning the entire segment.
381
422
  const mixed = mixedFileSegment(seg, file)
382
423
  if (mixed !== undefined) {
@@ -425,7 +466,7 @@ export function filePathToRoutes(file: string): FileRoutePattern[] {
425
466
  }
426
467
 
427
468
  /**
428
- * 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
429
470
  * optionals yields its one pattern. Use {@link filePathToPatterns} to get every pattern (optionals
430
471
  * expand the set).
431
472
  */
@@ -469,7 +510,7 @@ const ancestorDirs = (file: string): string[] => {
469
510
 
470
511
  /**
471
512
  * Build a manifest from route file paths (relative to the routes dir) + an `importer` that
472
- * 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
473
514
  * RouteConfigError ethos) on duplicate patterns. `_layout`/`_404`/`_error` files are special; other
474
515
  * `_`-prefixed files are ignored (private/colocated, never routed).
475
516
  */
@@ -577,7 +618,7 @@ export function fillRoutePattern(
577
618
  /** The static-routing facts a server needs from the route modules: which concrete paths are
578
619
  * prerendered, plus each dynamic route's `getStaticPaths` fallback policy. */
579
620
  export interface StaticRoutes {
580
- /** Concrete prerendered paths — static `prerender` routes + each `getStaticPaths` entry. */
621
+ /** Concrete prerendered paths - static `prerender` routes + each `getStaticPaths` entry. */
581
622
  readonly paths: string[]
582
623
  /** Per dynamic route pattern, its `getStaticPaths` `fallback` (`"ssr"` default). `createWebApp` uses
583
624
  * `"404"` to reject an unlisted path under that route at runtime (the path simply doesn't exist). */
@@ -585,7 +626,7 @@ export interface StaticRoutes {
585
626
  }
586
627
 
587
628
  /**
588
- * 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
589
630
  * `export const prerender = true`, each `getStaticPaths` entry of a dynamic route, and each dynamic
590
631
  * route's `fallback` policy. Pure (no rendering), so a server can compute what to hand `createWebApp`
591
632
  * (the prerendered set for the client's static-`_data.json` soft-nav + the fallback map for
@@ -82,10 +82,10 @@ export function fromBunMetafile(meta: BunMetafileLike | undefined): ClientModule
82
82
  /**
83
83
  * The slice of a Rollup/Vite output bundle this seam consumes. `OutputBundle` is
84
84
  * `Record<fileName, OutputChunk | OutputAsset>`; only chunks carry a module graph. Typed structurally
85
- * (no `rollup`/`vite` type dependency) — the fields are stable Rollup output API.
85
+ * (no `rollup`/`vite` type dependency) - the fields are stable Rollup output API.
86
86
  */
87
87
  export interface RollupChunkLike {
88
- /** `"chunk"` for JS output, `"asset"` for CSS/static — assets have no module graph. */
88
+ /** `"chunk"` for JS output, `"asset"` for CSS/static - assets have no module graph. */
89
89
  readonly type?: string
90
90
  /** The entry module id this chunk was built for, if it is an entry. Rollup's `facadeModuleId`. */
91
91
  readonly facadeModuleId?: string | null
@@ -95,12 +95,12 @@ export interface RollupChunkLike {
95
95
  export type RollupBundleLike = Readonly<Record<string, RollupChunkLike>>
96
96
 
97
97
  /**
98
- * Adapt a Rollup/Vite output bundle to the neutral graph — the second producer the seam was built for.
98
+ * Adapt a Rollup/Vite output bundle to the neutral graph - the second producer the seam was built for.
99
99
  *
100
100
  * The bundle records which modules landed in which chunk (`moduleIds`) but NOT each module's import
101
101
  * edges, so those come from `importsOf`, which the caller backs with `this.getModuleInfo(id).importedIds`
102
- * inside a plugin hook (a test backs it with a plain map). Edges carry only the RESOLVED id — Rollup does
103
- * not keep the as-written specifier per edge — so `path` is set and `original` is left undefined. Both
102
+ * inside a plugin hook (a test backs it with a plain map). Edges carry only the RESOLVED id - Rollup does
103
+ * not keep the as-written specifier per edge - so `path` is set and `original` is left undefined. Both
104
104
  * guards read the resolved `path` as their fallback (a `node:` prefix, the `server-only` basename), so
105
105
  * detection is unaffected; only the human-readable chain shows resolved paths instead of as-written ones.
106
106
  *
@@ -136,7 +136,7 @@ export function fromRollupBundle(
136
136
  }
137
137
  }
138
138
  chunks[fileName] = {
139
- // `facadeModuleId` is null for a shared/common chunk (no single entry) — omit it then, matching
139
+ // `facadeModuleId` is null for a shared/common chunk (no single entry) - omit it then, matching
140
140
  // how a non-entry Bun output omits `entryPoint`, so the guards' entry set stays the real entries.
141
141
  ...(output.facadeModuleId ? { entryPoint: output.facadeModuleId } : {}),
142
142
  modules: chunkModules,
package/src/navigation.ts CHANGED
@@ -1,23 +1,94 @@
1
1
  /**
2
- * `@nifrajs/web` navigation bridge — a tiny, DOM-free seam that lets an adapter's `useNavigate`
3
- * (a route component, which must import only from the agnostic `@nifrajs/web` main entry — never the
2
+ * `@nifrajs/web` navigation bridge - a tiny, DOM-free seam that lets an adapter's `useNavigate`
3
+ * (a route component, which must import only from the agnostic `@nifrajs/web` main entry - never the
4
4
  * DOM-only `/client`) reach the browser's history-aware navigate, WITHOUT the route component pulling
5
5
  * `@nifrajs/web/client` (and its `document`/`history` access) into a bundle that also renders on the
6
6
  * server.
7
7
  *
8
8
  * The browser layer (`installHistory`, in `./client.ts`) populates this on setup and clears it on
9
- * teardown; a framework binding reads it via {@link getBrowserNavigate}. Module-level singleton — the
9
+ * teardown; a framework binding reads it via {@link getBrowserNavigate}. Module-level singleton - the
10
10
  * browser mounts exactly one app per page (the same convention the adapters' `setMountedRouter` uses).
11
11
  * On the server (and before hydration) the getter returns `undefined`, so a binding degrades to the
12
- * native `<a href>` full-page navigation — progressive enhancement, no throw.
12
+ * native `<a href>` full-page navigation - progressive enhancement, no throw.
13
13
  */
14
14
 
15
+ import { serializeSearch } from "./search.ts"
16
+
15
17
  /** Options for a programmatic navigation. */
16
18
  export interface NavigateOptions {
17
19
  /** Replace the current history entry instead of pushing a new one (like `history.replaceState`). */
18
20
  readonly replace?: boolean
19
21
  }
20
22
 
23
+ /**
24
+ * The augmentable route -> search-type map for typed cross-route navigation. Empty by default (so an
25
+ * object-form navigate to any path is allowed with a loose `search`). A build step (`nifra sync-routes`)
26
+ * OR the app declares one entry per route path against its `searchSchema` output:
27
+ *
28
+ * ```ts
29
+ * declare module "@nifrajs/web" {
30
+ * interface RouteSearch {
31
+ * "/reports": { page: number; q: string }
32
+ * }
33
+ * }
34
+ * ```
35
+ *
36
+ * With that, `navigate({ to: "/reports", search: { page: 2 } })` type-checks `search` against
37
+ * `/reports`'s schema, and a wrong shape is a compile error. See {@link NavigateTarget}.
38
+ */
39
+ // biome-ignore lint/suspicious/noEmptyInterface: augmentation target - empty by design, apps/codegen fill it.
40
+ export interface RouteSearch {}
41
+
42
+ /**
43
+ * The `search` type for a navigate to `To`: the route's schema output when `To` is a mapped
44
+ * {@link RouteSearch} key, otherwise the loose `Record<string, unknown>` (so a navigate to any path is
45
+ * always allowed). Keyed on `To` rather than a union, so a mapped route can't fall back to the loose form
46
+ * with a wrong shape - `navigate({ to: "/reports", search: { page: "x" } })` is a compile error.
47
+ */
48
+ export type NavigateSearchOf<To extends string> = To extends keyof RouteSearch
49
+ ? RouteSearch[To]
50
+ : Record<string, unknown>
51
+
52
+ /** The runtime shape of an object-form navigate target (loose - the typed narrowing lives in
53
+ * {@link NavigateFunction}'s generic call signature). `to` is a bare pathname; `search` is serialized
54
+ * onto it; `replace` folds into the options. */
55
+ export interface NavigateTargetInput {
56
+ readonly to: string
57
+ readonly search?: Record<string, unknown>
58
+ readonly replace?: boolean
59
+ }
60
+
61
+ /**
62
+ * A programmatic navigate, shared by every adapter's `useNavigate`. Three forms: a string path (push, or
63
+ * replace via `{ replace: true }`), a history delta (`-1`/`1`), or an object target `{ to, search, replace }`
64
+ * whose `search` is typed against `to`'s route schema via {@link NavigateSearchOf} (a wrong shape for a
65
+ * mapped route is a compile error; an unmapped path takes a loose `search`). The object form's `search` is
66
+ * serialized onto `to`. A no-op on the server / before hydration.
67
+ */
68
+ export interface NavigateFunction {
69
+ (to: string | number, options?: NavigateOptions): void
70
+ <To extends string>(target: {
71
+ readonly to: To
72
+ readonly search?: NavigateSearchOf<To>
73
+ readonly replace?: boolean
74
+ }): void
75
+ }
76
+
77
+ /**
78
+ * Normalize a navigate argument to the bridge's `(to, options)`: a string path or history-delta passes
79
+ * through; an object target has its `search` serialized onto `to` and its `replace` folded into the
80
+ * options. The one place the object form becomes a URL, so every adapter's `navigate` resolves it
81
+ * identically.
82
+ */
83
+ export function resolveNavigate(
84
+ to: string | number | NavigateTargetInput,
85
+ options?: NavigateOptions,
86
+ ): { readonly to: string | number; readonly options: NavigateOptions | undefined } {
87
+ if (typeof to !== "object") return { to, options }
88
+ const query = to.search !== undefined ? serializeSearch(to.search) : ""
89
+ return { to: to.to + query, options: to.replace === true ? { replace: true } : options }
90
+ }
91
+
21
92
  /**
22
93
  * A history-aware navigate. A **string** `to` is a same-origin path (`/users/7?tab=a`) navigated to
23
94
  * (push, or replace with `{ replace: true }`); a **number** is a history delta (`-1` back, `1`
@@ -29,7 +100,7 @@ export type BrowserNavigate = (to: string | number, options?: NavigateOptions) =
29
100
  // per page; absent on the server and before hydration.
30
101
  let browserNavigate: BrowserNavigate | undefined
31
102
 
32
- /** Register (or clear, with `undefined`) the browser navigate — called by `installHistory`. Not for
103
+ /** Register (or clear, with `undefined`) the browser navigate - called by `installHistory`. Not for
33
104
  * app use. */
34
105
  export function setBrowserNavigate(navigate: BrowserNavigate | undefined): void {
35
106
  browserNavigate = navigate
@@ -40,3 +111,78 @@ export function setBrowserNavigate(navigate: BrowserNavigate | undefined): void
40
111
  export function getBrowserNavigate(): BrowserNavigate | undefined {
41
112
  return browserNavigate
42
113
  }
114
+
115
+ /** A parsed navigation target the {@link BlockerFunction} decides on. `pathname`/`search`/`hash` match
116
+ * the DOM `Location` shape (search keeps its `?`, hash its `#`). */
117
+ export interface BlockerLocation {
118
+ readonly pathname: string
119
+ readonly search: string
120
+ readonly hash: string
121
+ }
122
+
123
+ /**
124
+ * Decide whether a navigation should be halted. Receives where the app is (`currentLocation`) and where
125
+ * it's heading (`nextLocation`), so a guard can allow same-section moves and block only real exits. A
126
+ * boolean form (`useBlocker(isDirty)`) is sugar for `() => isDirty`. Runs synchronously at navigation
127
+ * time - return the current answer; the async "are you sure?" happens afterward via {@link Blocker}'s
128
+ * `proceed`/`reset`.
129
+ */
130
+ export type BlockerFunction = (args: {
131
+ readonly currentLocation: BlockerLocation
132
+ readonly nextLocation: BlockerLocation
133
+ }) => boolean
134
+
135
+ /**
136
+ * The blocker's lifecycle. `unblocked` - idle, nothing intercepted. `blocked` - a navigation was halted
137
+ * and is awaiting the app's decision (`proceed`/`reset` are live). `proceeding` - the app called
138
+ * `proceed`; the held navigation is being replayed.
139
+ */
140
+ export type BlockerState = "unblocked" | "blocked" | "proceeding"
141
+
142
+ /**
143
+ * A navigation guard, mirroring react-router's shape. When `state` is `blocked`, `proceed()` lets the
144
+ * held navigation through and `reset()` cancels it (staying put); both are `undefined` otherwise. The
145
+ * pair is what a boolean `when` can't express - the app shows its OWN async confirmation UI, then calls
146
+ * one of them, instead of the browser's synchronous `confirm()`.
147
+ */
148
+ export interface Blocker {
149
+ readonly state: BlockerState
150
+ readonly proceed: (() => void) | undefined
151
+ readonly reset: (() => void) | undefined
152
+ }
153
+
154
+ /** The idle blocker - a stable reference (no needless adapter re-renders while unblocked). */
155
+ export const IDLE_BLOCKER: Blocker = { state: "unblocked", proceed: undefined, reset: undefined }
156
+
157
+ /**
158
+ * The browser layer's blocker registry - installed by `installHistory` (which owns navigation and can
159
+ * therefore halt, restore, and replay it) and read by an adapter's `useBlocker` through
160
+ * {@link registerBlocker}. Kept here, DOM-free, for the same reason as {@link BrowserNavigate}: a route
161
+ * component's blocker hook must import only this agnostic entry.
162
+ */
163
+ export interface BlockerController {
164
+ register(shouldBlock: BlockerFunction, onChange: (blocker: Blocker) => void): () => void
165
+ }
166
+
167
+ // The active controller (set by `installHistory`, cleared on teardown). Module-scoped: one app per page.
168
+ let blockerController: BlockerController | undefined
169
+
170
+ /** Register (or clear, with `undefined`) the blocker controller - called by `installHistory`. Not for
171
+ * app use. */
172
+ export function setBlockerController(controller: BlockerController | undefined): void {
173
+ blockerController = controller
174
+ }
175
+
176
+ /**
177
+ * Register a navigation guard, returning an unregister function. `onChange` is called with the current
178
+ * {@link Blocker} whenever its state changes (so the adapter can re-render its confirmation UI). Before
179
+ * `installHistory` has run (SSR, pre-hydration), there's no controller: registration is a no-op and the
180
+ * caller stays on {@link IDLE_BLOCKER}, degrading to native navigation. Called by an adapter's
181
+ * `useBlocker`, not by app code directly.
182
+ */
183
+ export function registerBlocker(
184
+ shouldBlock: BlockerFunction,
185
+ onChange: (blocker: Blocker) => void,
186
+ ): () => void {
187
+ return blockerController?.register(shouldBlock, onChange) ?? (() => {})
188
+ }
Binary file
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `@nifrajs/web/plugins` shared kit — the small infrastructure every CSS-bearing asset plugin reuses, so
2
+ * `@nifrajs/web/plugins` shared kit - the small infrastructure every CSS-bearing asset plugin reuses, so
3
3
  * P1 (CSS Modules), P2 (SCSS), and any future P4 (LESS/Stylus/PostCSS) don't each re-implement the
4
4
  * CSS→client-bundle wiring, the deterministic hash, or the optional-peer guard. Internal to
5
5
  * `@nifrajs/web` (imported by the sibling plugin modules); not a public subpath.
@@ -8,7 +8,7 @@ import { existsSync } from "node:fs"
8
8
  import { dirname, relative } from "node:path"
9
9
  import type { BunPlugin } from "bun"
10
10
 
11
- /** The argument Bun passes to a plugin's `setup` — Bun doesn't export the type, so derive it. */
11
+ /** The argument Bun passes to a plugin's `setup` - Bun doesn't export the type, so derive it. */
12
12
  export type PluginBuilder = Parameters<BunPlugin["setup"]>[0]
13
13
 
14
14
  const packageRootCache = new Map<string, string>()
@@ -23,7 +23,7 @@ function packageRootOf(startDir: string): string {
23
23
  if (existsSync(`${dir}/package.json`)) break
24
24
  const parent = dirname(dir)
25
25
  if (parent === dir) {
26
- dir = startDir // reached the filesystem root with no package.json — anchor on the file's own dir
26
+ dir = startDir // reached the filesystem root with no package.json - anchor on the file's own dir
27
27
  break
28
28
  }
29
29
  dir = parent
@@ -33,11 +33,11 @@ function packageRootOf(startDir: string): string {
33
33
  }
34
34
 
35
35
  /**
36
- * A **package-root-relative**, forward-slashed form of an absolute path — the input to {@link hash8} for
36
+ * A **package-root-relative**, forward-slashed form of an absolute path - the input to {@link hash8} for
37
37
  * any build-stable identifier (e.g. CSS-module scoped names). Anchoring on the file's nearest
38
38
  * `package.json` (not the absolute path, not `process.cwd()`) makes the result independent of BOTH the
39
- * machine's directory layout AND the working directory: the dom build and the ssr runtime — even on
40
- * different machines or from different cwds — derive the SAME relative path for a given file, so their
39
+ * machine's directory layout AND the working directory: the dom build and the ssr runtime - even on
40
+ * different machines or from different cwds - derive the SAME relative path for a given file, so their
41
41
  * scoped class maps always agree. (Hashing the absolute path would differ across CI/host; a
42
42
  * cwd-relative path would silently desync if the SSR server started from a non-project-root cwd.)
43
43
  */
@@ -47,7 +47,7 @@ export function reproduciblePath(absolutePath: string): string {
47
47
  }
48
48
 
49
49
  /**
50
- * Deterministic 8-hex hash (djb2/xor). Stable across builds — no `Date.now`/`Math.random` — so build
50
+ * Deterministic 8-hex hash (djb2/xor). Stable across builds - no `Date.now`/`Math.random` - so build
51
51
  * output is reproducible. The single hash implementation behind CSS-module scoped names (and a drop-in
52
52
  * for any SFC scope id).
53
53
  */
@@ -58,7 +58,7 @@ export function hash8(input: string): string {
58
58
  }
59
59
 
60
60
  /**
61
- * Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module —
61
+ * Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module -
62
62
  * the idiom the Vue plugin established (`?vue-css`). Register one per plugin `setup`; call `emit` per
63
63
  * file to stash its CSS and get back the `import` line to append to the JS module.
64
64
  */
@@ -70,7 +70,7 @@ export interface StylesheetEmitter {
70
70
  /**
71
71
  * Wire the virtual-CSS-module handlers onto `build` for `namespace`, returning an {@link StylesheetEmitter}.
72
72
  * The `namespace` must be a plain identifier (letters/`-`); it's used verbatim as the import suffix and
73
- * the Bun namespace. Only the `"dom"` build should emit CSS — the `"ssr"` build ships no stylesheet.
73
+ * the Bun namespace. Only the `"dom"` build should emit CSS - the `"ssr"` build ships no stylesheet.
74
74
  */
75
75
  export function createStylesheetEmitter(
76
76
  build: PluginBuilder,
@@ -97,7 +97,7 @@ export function createStylesheetEmitter(
97
97
 
98
98
  /**
99
99
  * Load an optional peer compiler at build time, throwing a consistent, actionable install-hint error if
100
- * it's absent — the `@vue/compiler-sfc` peer pattern, centralized. Build-time only, so the dynamic
100
+ * it's absent - the `@vue/compiler-sfc` peer pattern, centralized. Build-time only, so the dynamic
101
101
  * `import` (which keeps the peer out of the package's hard dependencies) is correct here.
102
102
  */
103
103
  export async function requirePeer<T>(
@@ -107,7 +107,7 @@ export async function requirePeer<T>(
107
107
  try {
108
108
  return (await import(specifier)) as T
109
109
  } catch (err) {
110
- // Only a genuine resolution failure means "not installed" — surface anything else (a corrupt
110
+ // Only a genuine resolution failure means "not installed" - surface anything else (a corrupt
111
111
  // install, a native-binding error, a throw at module top-level) as the real error rather than
112
112
  // masking it behind a misleading "please install it" hint.
113
113
  if ((err as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND") {