@nifrajs/web 2.2.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.
- package/README.md +3 -3
- package/dist/build-vite.d.ts +4 -4
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +20 -74
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +64 -42
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +100 -78
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +201 -29
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- package/dist/dev-port.d.ts +2 -2
- package/dist/dev-port.js +1 -1
- package/dist/dev.d.ts +3 -3
- package/dist/dev.js +9 -9
- package/dist/draft.d.ts +12 -12
- package/dist/draft.js +11 -11
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +1 -1
- package/dist/fs.js +1 -1
- package/dist/index.d.ts +151 -70
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +317 -139
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +1 -1
- package/dist/internal/timing-safe-equal.js +1 -1
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.js +7 -7
- package/dist/manifest.d.ts +73 -35
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -16
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +5 -5
- package/dist/module-graph.js +4 -4
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +1 -1
- package/dist/plugins/vite-leak-guard.js +1 -1
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +1 -1
- package/dist/route-manifest.js +2 -2
- package/dist/router.d.ts +30 -21
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +57 -28
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +15 -13
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +31 -21
- package/dist/vite.js.map +1 -1
- package/package.json +33 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +24 -77
- package/src/build.ts +137 -105
- package/src/bun-dev-entry.ts +1 -1
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +215 -32
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +2 -2
- package/src/dev.ts +12 -12
- package/src/draft.ts +15 -15
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +2 -2
- package/src/index.ts +460 -181
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +1 -1
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +17 -17
- package/src/manifest.ts +83 -42
- package/src/module-graph.ts +6 -6
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +2 -2
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +3 -3
- package/src/router.ts +91 -41
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +41 -29
package/src/manifest.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Route manifest
|
|
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}
|
|
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).
|
|
38
|
-
*
|
|
39
|
-
* `disabled` (rendered
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 |
|
|
66
|
+
readonly [attr: `data-${string}`]: string | undefined
|
|
61
67
|
}
|
|
62
68
|
|
|
63
|
-
/** One `<
|
|
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
|
-
* (`</`, `<!--`, `]]>`)
|
|
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?:
|
|
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
|
|
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
|
|
80
|
-
*
|
|
81
|
-
* loader-derived strings (LLM-authored `og:*`, user content) are XSS-safe
|
|
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?:
|
|
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
|
|
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"`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}")
|
|
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}")
|
|
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
|
|
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]`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/src/module-graph.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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
|
|
103
|
-
* not keep the as-written specifier per edge
|
|
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)
|
|
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
|
|
3
|
-
* (a route component, which must import only from the agnostic `@nifrajs/web` main entry
|
|
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
|
|
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
|
|
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
|
|
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
|
package/src/plugins/kit.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web/plugins` shared kit
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
40
|
-
* different machines or from different cwds
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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") {
|