@nifrajs/web 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +18 -0
- package/css-modules.d.ts +34 -0
- package/dist/build.d.ts +110 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +238 -0
- package/dist/build.js.map +1 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +204 -0
- package/dist/client.js.map +1 -0
- package/dist/deferred.d.ts +90 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +310 -0
- package/dist/deferred.js.map +1 -0
- package/dist/dev-error.d.ts +17 -0
- package/dist/dev-error.d.ts.map +1 -0
- package/dist/dev-error.js +59 -0
- package/dist/dev-error.js.map +1 -0
- package/dist/dev.d.ts +25 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +83 -0
- package/dist/dev.js.map +1 -0
- package/dist/draft.d.ts +58 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +55 -0
- package/dist/draft.js.map +1 -0
- package/dist/fonts-google.d.ts +109 -0
- package/dist/fonts-google.d.ts.map +1 -0
- package/dist/fonts-google.js +291 -0
- package/dist/fonts-google.js.map +1 -0
- package/dist/fonts.d.ts +67 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +103 -0
- package/dist/fonts.js.map +1 -0
- package/dist/fs.d.ts +10 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +24 -0
- package/dist/fs.js.map +1 -0
- package/dist/index.d.ts +280 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +784 -0
- package/dist/index.js.map +1 -0
- package/dist/islands.d.ts +37 -0
- package/dist/islands.d.ts.map +1 -0
- package/dist/islands.js +107 -0
- package/dist/islands.js.map +1 -0
- package/dist/isr.d.ts +154 -0
- package/dist/isr.d.ts.map +1 -0
- package/dist/isr.js +331 -0
- package/dist/isr.js.map +1 -0
- package/dist/manifest.d.ts +178 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +213 -0
- package/dist/manifest.js.map +1 -0
- package/dist/plugins/css-modules.d.ts +40 -0
- package/dist/plugins/css-modules.d.ts.map +1 -0
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -0
- package/dist/plugins/kit.d.ts +44 -0
- package/dist/plugins/kit.d.ts.map +1 -0
- package/dist/plugins/kit.js +105 -0
- package/dist/plugins/kit.js.map +1 -0
- package/dist/plugins/scss.d.ts +27 -0
- package/dist/plugins/scss.d.ts.map +1 -0
- package/dist/plugins/scss.js +78 -0
- package/dist/plugins/scss.js.map +1 -0
- package/dist/prerender.d.ts +77 -0
- package/dist/prerender.d.ts.map +1 -0
- package/dist/prerender.js +139 -0
- package/dist/prerender.js.map +1 -0
- package/dist/query.d.ts +62 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +184 -0
- package/dist/query.js.map +1 -0
- package/dist/router.d.ts +169 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +531 -0
- package/dist/router.js.map +1 -0
- package/dist/vite.d.ts +49 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +144 -0
- package/dist/vite.js.map +1 -0
- package/package.json +127 -0
- package/src/build.ts +375 -0
- package/src/client.ts +218 -0
- package/src/deferred.ts +339 -0
- package/src/dev-error.ts +62 -0
- package/src/dev.ts +108 -0
- package/src/draft.ts +82 -0
- package/src/fonts-google.ts +397 -0
- package/src/fonts.ts +147 -0
- package/src/fs.ts +26 -0
- package/src/index.ts +1202 -0
- package/src/islands.ts +121 -0
- package/src/isr.ts +461 -0
- package/src/manifest.ts +366 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +123 -0
- package/src/plugins/scss.ts +108 -0
- package/src/prerender.ts +209 -0
- package/src/query.ts +250 -0
- package/src/router.ts +676 -0
- package/src/vite.ts +212 -0
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route manifest — the fs-free heart of file-based routing. Maps route file paths to nifra
|
|
3
|
+
* router patterns + their nested layout chain. `discoverRoutes` (in `@nifrajs/web/fs`) scans
|
|
4
|
+
* the filesystem and feeds `buildManifest`; everything here is pure logic, so it stays
|
|
5
|
+
* portable (no fs, no DOM) and fully unit-testable. Edge deploys pre-build the manifest.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Context passed to a route `loader`. The `api` + `env` are injected by `createWebApp` and typed
|
|
9
|
+
* per-route via `@nifrajs/client`'s `LoaderArgs<Api, Env>` (here they are opaque to the agnostic core). */
|
|
10
|
+
export interface LoaderContext {
|
|
11
|
+
readonly params: Record<string, string>
|
|
12
|
+
readonly request: Request
|
|
13
|
+
readonly api: unknown
|
|
14
|
+
/** Platform bindings forwarded from the request `c.env` (Workers env/KV/D1). Opaque here. */
|
|
15
|
+
readonly env: unknown
|
|
16
|
+
/** `true` when the request carries a valid draft/preview cookie (only when `createWebApp` is given a
|
|
17
|
+
* `draftSecret`; otherwise always `false`). Branch on it to load unpublished content for editors. */
|
|
18
|
+
readonly draft: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A route's optional data loader: params/request in, data out. */
|
|
22
|
+
export type Loader = (ctx: LoaderContext) => unknown | Promise<unknown>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A route's optional mutation, run on POST. Shares the loader context (params/request/api);
|
|
26
|
+
* read the form/JSON body off `request`. Returns either a `Response` (e.g. a redirect —
|
|
27
|
+
* passed straight through) or data, surfaced to the page component as `actionData`.
|
|
28
|
+
*/
|
|
29
|
+
export type Action = (ctx: LoaderContext) => unknown | Promise<unknown>
|
|
30
|
+
|
|
31
|
+
/** The document head a route contributes — title + `<meta>`/`<link>` tag attribute sets. */
|
|
32
|
+
export interface Meta {
|
|
33
|
+
readonly title?: string
|
|
34
|
+
readonly meta?: ReadonlyArray<Record<string, string>>
|
|
35
|
+
readonly link?: ReadonlyArray<Record<string, string>>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Args for a route's `meta` function: the loader's data + the route params. */
|
|
39
|
+
export interface MetaArgs<Data = unknown> {
|
|
40
|
+
readonly data: Data
|
|
41
|
+
readonly params: Record<string, string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A route's `meta`: a static {@link Meta}, or a function of the loader data + params. */
|
|
45
|
+
export type MetaInput = Meta | ((args: MetaArgs) => Meta)
|
|
46
|
+
|
|
47
|
+
/** One concrete parameterization of a dynamic route, returned by {@link GetStaticPaths}. */
|
|
48
|
+
export interface StaticPath {
|
|
49
|
+
/** Values for the route's `:param` segments, e.g. `{ id: "7" }` for `/users/:id`. */
|
|
50
|
+
readonly params: Record<string, string>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What a route's `getStaticPaths` returns: the param sets to prerender + the unlisted-path policy. */
|
|
54
|
+
export interface StaticPaths {
|
|
55
|
+
readonly paths: readonly StaticPath[]
|
|
56
|
+
/**
|
|
57
|
+
* How a path NOT in `paths` is handled. `"ssr"` (default) → rendered on-demand by the worker
|
|
58
|
+
* (the natural hybrid behavior — an unlisted path simply isn't a static file); `"404"` → only the
|
|
59
|
+
* listed paths exist. Recorded by `prerenderRoutes` for the deploy layer.
|
|
60
|
+
*/
|
|
61
|
+
readonly fallback?: "ssr" | "404"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A dynamic route's build-time param enumeration (the SSG equivalent of "which pages exist"). */
|
|
65
|
+
export type GetStaticPaths = () => StaticPaths | Promise<StaticPaths>
|
|
66
|
+
|
|
67
|
+
/** A route module — the default component + optional loader / action / meta. */
|
|
68
|
+
export interface RouteModule {
|
|
69
|
+
readonly default: unknown
|
|
70
|
+
readonly loader?: Loader
|
|
71
|
+
readonly action?: Action
|
|
72
|
+
readonly meta?: MetaInput
|
|
73
|
+
/**
|
|
74
|
+
* Opt this route out of nifra's full-document client hydration. The server still renders the full
|
|
75
|
+
* HTML document, loaders/actions still run, and native links/forms still work; the generated app
|
|
76
|
+
* client, route chunks, and loader globals are omitted for hard navigations. Intended for static or
|
|
77
|
+
* island-hydrated pages where interactivity is mounted by smaller, explicit client entries.
|
|
78
|
+
*/
|
|
79
|
+
readonly hydrate?: boolean
|
|
80
|
+
/**
|
|
81
|
+
* Opt a **static** route (no `:param`/`*`) into build-time prerendering (SSG): `prerenderRoutes`
|
|
82
|
+
* (from `@nifrajs/web/build`) renders it to a static `index.html` at build. The loader runs at build
|
|
83
|
+
* with the in-process `api` (build-safe data only — no per-request cookies/secrets); `defer()` on a
|
|
84
|
+
* prerendered route resolves at build. For **dynamic** routes use {@link getStaticPaths} instead.
|
|
85
|
+
*/
|
|
86
|
+
readonly prerender?: boolean
|
|
87
|
+
/**
|
|
88
|
+
* Enumerate the concrete params to prerender for a **dynamic** (`:param`) route — the SSG path list
|
|
89
|
+
* (blogs/docs/etc.). Runs at build; `prerenderRoutes` renders one `index.html` per returned path.
|
|
90
|
+
*/
|
|
91
|
+
readonly getStaticPaths?: GetStaticPaths
|
|
92
|
+
/**
|
|
93
|
+
* ISR freshness for this route, in **seconds**. `createWebApp` emits it as an `x-nifra-revalidate`
|
|
94
|
+
* response header that `withISR` reads to set the page's cache TTL (overriding the wrapper's
|
|
95
|
+
* default). Older-than-`revalidate` cached pages are served stale while regenerating.
|
|
96
|
+
*/
|
|
97
|
+
readonly revalidate?: number
|
|
98
|
+
/**
|
|
99
|
+
* No-framework island bundles (`@nifrajs/web/islands`) to load on this route, as `<script
|
|
100
|
+
* type="module">` in the document tail. Loaded **regardless of `hydrate`** — pair with
|
|
101
|
+
* `export const hydrate = false` for a static page that ships zero framework JS and mounts
|
|
102
|
+
* interactivity through `<Island>` markers + `mountIslands` enhancers instead.
|
|
103
|
+
*/
|
|
104
|
+
readonly islandScripts?: readonly string[]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A layout (or `_404`/`_error`) entry: its source file (for client codegen) + a lazy loader. */
|
|
108
|
+
export interface LayoutEntry {
|
|
109
|
+
readonly file: string
|
|
110
|
+
readonly load: () => Promise<{ default: unknown }>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One matched route: pattern, nested layout ids (outermost → innermost), source file, loader. */
|
|
114
|
+
export interface RouteEntry {
|
|
115
|
+
readonly id: string
|
|
116
|
+
readonly pattern: string
|
|
117
|
+
readonly layoutIds: readonly string[]
|
|
118
|
+
/** `_error` boundary ids in this route's ancestor chain (outermost → innermost). The last is the
|
|
119
|
+
* **nearest** boundary, rendered when the route's loader throws. Always set by `buildManifest`
|
|
120
|
+
* (optional only so hand-built test manifests may omit it); absent/empty ⇒ no boundary (error 500s). */
|
|
121
|
+
readonly errorIds?: readonly string[]
|
|
122
|
+
readonly file: string
|
|
123
|
+
readonly load: () => Promise<RouteModule>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The full route manifest. */
|
|
127
|
+
export interface Manifest {
|
|
128
|
+
readonly routes: readonly RouteEntry[]
|
|
129
|
+
readonly layouts: Readonly<Record<string, LayoutEntry>>
|
|
130
|
+
/** Per-segment `_error` boundary components, keyed by id (`_error`, `a/_error`, …). Always set by
|
|
131
|
+
* `buildManifest` (optional only so hand-built test manifests may omit it). */
|
|
132
|
+
readonly errors?: Readonly<Record<string, LayoutEntry>>
|
|
133
|
+
readonly notFound?: LayoutEntry
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// `.svelte` and `.vue` routes are supported too: their `default` export is the component and
|
|
137
|
+
// `loader`/`action`/`meta` come from a module-level script block (Svelte `<script module>`, Vue's plain
|
|
138
|
+
// `<script>`) as named ESM exports — the same RouteModule shape as `.tsx`. Both compile via their
|
|
139
|
+
// package's Bun plugin (`@nifrajs/web-svelte/plugin`, `@nifrajs/web-vue/plugin`).
|
|
140
|
+
const ROUTE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
|
|
141
|
+
const PARAM = /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/
|
|
142
|
+
const CATCH_ALL = /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/
|
|
143
|
+
// An optional dynamic segment: `[[lang]]` matches with OR without the segment. It expands a file into
|
|
144
|
+
// two patterns (`:lang` present / absent), so `[[lang]]/about` serves both `/about` and `/en/about`.
|
|
145
|
+
const OPTIONAL = /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/
|
|
146
|
+
// A route group: a `(name)` folder organizes routes (and can hold its own `_layout`) without
|
|
147
|
+
// contributing a URL segment — mirrors Next/Remix. Requires content between the parens.
|
|
148
|
+
const GROUP = /^\(.+\)$/
|
|
149
|
+
|
|
150
|
+
const stripExt = (file: string): string => file.replace(ROUTE_EXT, "")
|
|
151
|
+
const baseName = (file: string): string => file.slice(file.lastIndexOf("/") + 1)
|
|
152
|
+
const dirOf = (file: string): string =>
|
|
153
|
+
file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : ""
|
|
154
|
+
const layoutIdFor = (dir: string): string => (dir === "" ? "_layout" : `${dir}/_layout`)
|
|
155
|
+
const errorIdFor = (dir: string): string => (dir === "" ? "_error" : `${dir}/_error`)
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Derive **every** nifra router pattern a route file maps to (relative to the routes dir):
|
|
159
|
+
* `index` → the parent path, `[id]` → `:id`, `[...slug]` → `*slug` (catch-all, captures the rest of
|
|
160
|
+
* the path into one param), `(group)` folders are dropped from the URL (organization only), and an
|
|
161
|
+
* optional `[[lang]]` expands the set — once with the segment present (`:lang`) and once absent. A file
|
|
162
|
+
* with no optionals yields exactly one pattern. Throws on an invalid param or a catch-all that isn't
|
|
163
|
+
* the last segment.
|
|
164
|
+
*/
|
|
165
|
+
export function filePathToPatterns(file: string): string[] {
|
|
166
|
+
// Each combo is a list of URL segments built so far; an optional segment doubles the set.
|
|
167
|
+
let combos: string[][] = [[]]
|
|
168
|
+
const segments = stripExt(file).split("/")
|
|
169
|
+
for (let i = 0; i < segments.length; i++) {
|
|
170
|
+
const seg = segments[i]!
|
|
171
|
+
if (seg === "index") continue
|
|
172
|
+
if (GROUP.test(seg)) continue // route group — no URL segment (its `_layout` still applies, keyed by dir)
|
|
173
|
+
if (seg.startsWith("[")) {
|
|
174
|
+
const catchAll = CATCH_ALL.exec(seg)
|
|
175
|
+
if (catchAll !== null) {
|
|
176
|
+
// The core router requires the wildcard to be the final segment. Enforce it at the file level
|
|
177
|
+
// (ignoring trailing `index`/`(group)`) so the error names the file, not a generated pattern.
|
|
178
|
+
const after = segments.slice(i + 1).filter((s) => s !== "index" && !GROUP.test(s))
|
|
179
|
+
if (after.length > 0) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`[nifra/web] catch-all "${seg}" must be the last segment in "${file}" (found "${after.join("/")}" after it)`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
combos = combos.map((c) => [...c, `*${catchAll[1]}`])
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
const optional = OPTIONAL.exec(seg)
|
|
188
|
+
if (optional !== null) {
|
|
189
|
+
// Optional segment: keep each existing combo (absent) AND a copy with `:name` appended (present).
|
|
190
|
+
combos = combos.flatMap((c) => [c, [...c, `:${optional[1]}`]])
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
const match = PARAM.exec(seg)
|
|
194
|
+
if (match === null) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`[nifra/web] invalid route param in "${file}": "${seg}" must be [name], [[name]], [...name], or a (group) folder`,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
combos = combos.map((c) => [...c, `:${match[1]}`])
|
|
200
|
+
} else {
|
|
201
|
+
combos = combos.map((c) => [...c, seg])
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return combos.map((c) => `/${c.join("/")}`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The **canonical** single pattern for a route file — all optional segments present. A file with no
|
|
209
|
+
* optionals yields its one pattern. Use {@link filePathToPatterns} to get every pattern (optionals
|
|
210
|
+
* expand the set).
|
|
211
|
+
*/
|
|
212
|
+
export function filePathToPattern(file: string): string {
|
|
213
|
+
const patterns = filePathToPatterns(file)
|
|
214
|
+
return patterns[patterns.length - 1]!
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The dir chain from root → the file's own dir, e.g. "a/b/p.tsx" → ["", "a", "a/b"]. */
|
|
218
|
+
const ancestorDirs = (file: string): string[] => {
|
|
219
|
+
const dirs = [""]
|
|
220
|
+
const dir = dirOf(file)
|
|
221
|
+
if (dir === "") return dirs
|
|
222
|
+
let acc = ""
|
|
223
|
+
for (const part of dir.split("/")) {
|
|
224
|
+
acc = acc === "" ? part : `${acc}/${part}`
|
|
225
|
+
dirs.push(acc)
|
|
226
|
+
}
|
|
227
|
+
return dirs
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build a manifest from route file paths (relative to the routes dir) + an `importer` that
|
|
232
|
+
* turns a path into a lazy module loader. Pure — no fs. Throws at boot (the loud-and-early
|
|
233
|
+
* RouteConfigError ethos) on duplicate patterns. `_layout`/`_404`/`_error` files are special; other
|
|
234
|
+
* `_`-prefixed files are ignored (private/colocated, never routed).
|
|
235
|
+
*/
|
|
236
|
+
export function buildManifest(
|
|
237
|
+
files: readonly string[],
|
|
238
|
+
importer: (file: string) => () => Promise<RouteModule>,
|
|
239
|
+
): Manifest {
|
|
240
|
+
const layoutDirs = new Set<string>()
|
|
241
|
+
const layouts: Record<string, LayoutEntry> = {}
|
|
242
|
+
const errorDirs = new Set<string>()
|
|
243
|
+
const errors: Record<string, LayoutEntry> = {}
|
|
244
|
+
let notFound: LayoutEntry | undefined
|
|
245
|
+
const routeFiles: string[] = []
|
|
246
|
+
|
|
247
|
+
for (const file of files) {
|
|
248
|
+
const stem = stripExt(baseName(file))
|
|
249
|
+
if (stem === "_layout") {
|
|
250
|
+
const dir = dirOf(file)
|
|
251
|
+
layoutDirs.add(dir)
|
|
252
|
+
layouts[layoutIdFor(dir)] = { file, load: importer(file) }
|
|
253
|
+
} else if (stem === "_error") {
|
|
254
|
+
const dir = dirOf(file)
|
|
255
|
+
errorDirs.add(dir)
|
|
256
|
+
errors[errorIdFor(dir)] = { file, load: importer(file) }
|
|
257
|
+
} else if (stem === "_404") {
|
|
258
|
+
notFound = { file, load: importer(file) }
|
|
259
|
+
} else if (!stem.startsWith("_")) {
|
|
260
|
+
routeFiles.push(file)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const byPattern = new Map<string, string>()
|
|
265
|
+
const routes: RouteEntry[] = []
|
|
266
|
+
for (const file of routeFiles) {
|
|
267
|
+
const dirs = ancestorDirs(file)
|
|
268
|
+
const layoutIds = dirs.filter((dir) => layoutDirs.has(dir)).map(layoutIdFor)
|
|
269
|
+
const errorIds = dirs.filter((dir) => errorDirs.has(dir)).map(errorIdFor)
|
|
270
|
+
const id = stripExt(file)
|
|
271
|
+
const load = importer(file) // one lazy loader per file, shared by its (possibly expanded) patterns
|
|
272
|
+
// An optional `[[x]]` segment expands a file into multiple patterns, all pointing at the same
|
|
273
|
+
// module (same id/load/layout chain). Distinct patterns ⇒ no match ambiguity (different lengths).
|
|
274
|
+
for (const pattern of filePathToPatterns(file)) {
|
|
275
|
+
const existing = byPattern.get(pattern)
|
|
276
|
+
if (existing !== undefined) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`[nifra/web] duplicate route: "${file}" and "${existing}" both map to "${pattern}"`,
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
byPattern.set(pattern, file)
|
|
282
|
+
routes.push({ id, pattern, layoutIds, errorIds, file, load })
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const base: Manifest = { routes, layouts, errors }
|
|
287
|
+
return notFound === undefined ? base : { ...base, notFound }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const FILL_PARAM = /:([A-Za-z_][A-Za-z0-9_]*)/g
|
|
291
|
+
|
|
292
|
+
const encodeRouteParam = (value: string): string => {
|
|
293
|
+
const encoded = encodeURIComponent(value)
|
|
294
|
+
// `encodeURIComponent(".")` and `encodeURIComponent("..")` intentionally leave dots alone, but those
|
|
295
|
+
// are filesystem path segments during prerender output. Keep them URL-equivalent while making them
|
|
296
|
+
// inert as path components.
|
|
297
|
+
return encoded === "." || encoded === ".." ? encoded.replace(/\./g, "%2E") : encoded
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Substitute a route pattern's `:param` segments with concrete values: `/users/:id` + `{id:"7"}` →
|
|
302
|
+
* `/users/7`. Returns the filled path plus any params that had no value (a `getStaticPaths` bug) so
|
|
303
|
+
* the caller can skip rather than emit a path with a literal `:name`. Shared by the SSG driver and
|
|
304
|
+
* {@link enumeratePrerenderedPaths}.
|
|
305
|
+
*/
|
|
306
|
+
export function fillRoutePattern(
|
|
307
|
+
pattern: string,
|
|
308
|
+
params: Record<string, string>,
|
|
309
|
+
): { path: string; missing: string[] } {
|
|
310
|
+
const missing: string[] = []
|
|
311
|
+
const path = pattern.replace(FILL_PARAM, (_m, name: string) => {
|
|
312
|
+
const value = params[name]
|
|
313
|
+
if (value === undefined) {
|
|
314
|
+
missing.push(name)
|
|
315
|
+
return `:${name}`
|
|
316
|
+
}
|
|
317
|
+
return encodeRouteParam(value)
|
|
318
|
+
})
|
|
319
|
+
return { path, missing }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The static-routing facts a server needs from the route modules: which concrete paths are
|
|
323
|
+
* prerendered, plus each dynamic route's `getStaticPaths` fallback policy. */
|
|
324
|
+
export interface StaticRoutes {
|
|
325
|
+
/** Concrete prerendered paths — static `prerender` routes + each `getStaticPaths` entry. */
|
|
326
|
+
readonly paths: string[]
|
|
327
|
+
/** Per dynamic route pattern, its `getStaticPaths` `fallback` (`"ssr"` default). `createWebApp` uses
|
|
328
|
+
* `"404"` to reject an unlisted path under that route at runtime (the path simply doesn't exist). */
|
|
329
|
+
readonly fallbacks: Record<string, "ssr" | "404">
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Enumerate the static-routing facts `prerenderRoutes` would produce — static routes opted in via
|
|
334
|
+
* `export const prerender = true`, each `getStaticPaths` entry of a dynamic route, and each dynamic
|
|
335
|
+
* route's `fallback` policy. Pure (no rendering), so a server can compute what to hand `createWebApp`
|
|
336
|
+
* (the prerendered set for the client's static-`_data.json` soft-nav + the fallback map for
|
|
337
|
+
* `"404"` enforcement). A production server may instead read the build's `prerendered.json` to avoid
|
|
338
|
+
* loading every route module at startup. Catch-all/wildcard routes and dynamic routes without
|
|
339
|
+
* `getStaticPaths` are omitted from `paths`.
|
|
340
|
+
*/
|
|
341
|
+
export async function enumerateStaticRoutes(routes: readonly RouteEntry[]): Promise<StaticRoutes> {
|
|
342
|
+
const paths: string[] = []
|
|
343
|
+
const fallbacks: Record<string, "ssr" | "404"> = {}
|
|
344
|
+
for (const route of routes) {
|
|
345
|
+
if (route.pattern.includes("*")) continue
|
|
346
|
+
const mod = await route.load()
|
|
347
|
+
if (route.pattern.includes(":")) {
|
|
348
|
+
if (mod.getStaticPaths === undefined) continue
|
|
349
|
+
const { paths: staticPaths, fallback = "ssr" } = await mod.getStaticPaths()
|
|
350
|
+
fallbacks[route.pattern] = fallback
|
|
351
|
+
for (const { params } of staticPaths) {
|
|
352
|
+
const { path, missing } = fillRoutePattern(route.pattern, params)
|
|
353
|
+
if (missing.length === 0) paths.push(path)
|
|
354
|
+
}
|
|
355
|
+
} else if (mod.prerender === true) {
|
|
356
|
+
paths.push(route.pattern)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return { paths, fallbacks }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The prerendered-path subset of {@link enumerateStaticRoutes} — kept for callers that only need the
|
|
363
|
+
* paths (e.g. injecting `window.__NIFRA_PRERENDERED__`). */
|
|
364
|
+
export async function enumeratePrerenderedPaths(routes: readonly RouteEntry[]): Promise<string[]> {
|
|
365
|
+
return (await enumerateStaticRoutes(routes)).paths
|
|
366
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/plugins` shared kit — the small infrastructure every CSS-bearing asset plugin reuses, so
|
|
3
|
+
* P1 (CSS Modules), P2 (SCSS), and any future P4 (LESS/Stylus/PostCSS) don't each re-implement the
|
|
4
|
+
* CSS→client-bundle wiring, the deterministic hash, or the optional-peer guard. Internal to
|
|
5
|
+
* `@nifrajs/web` (imported by the sibling plugin modules); not a public subpath.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from "node:fs"
|
|
8
|
+
import { dirname, relative } from "node:path"
|
|
9
|
+
import type { BunPlugin } from "bun"
|
|
10
|
+
|
|
11
|
+
/** The argument Bun passes to a plugin's `setup` — Bun doesn't export the type, so derive it. */
|
|
12
|
+
export type PluginBuilder = Parameters<BunPlugin["setup"]>[0]
|
|
13
|
+
|
|
14
|
+
const packageRootCache = new Map<string, string>()
|
|
15
|
+
|
|
16
|
+
/** The nearest ancestor directory of `startDir` that holds a `package.json` (the file's package root),
|
|
17
|
+
* cached. Falls back to `startDir` if none is found, so it never throws. */
|
|
18
|
+
function packageRootOf(startDir: string): string {
|
|
19
|
+
const cached = packageRootCache.get(startDir)
|
|
20
|
+
if (cached !== undefined) return cached
|
|
21
|
+
let dir = startDir
|
|
22
|
+
for (;;) {
|
|
23
|
+
if (existsSync(`${dir}/package.json`)) break
|
|
24
|
+
const parent = dirname(dir)
|
|
25
|
+
if (parent === dir) {
|
|
26
|
+
dir = startDir // reached the filesystem root with no package.json — anchor on the file's own dir
|
|
27
|
+
break
|
|
28
|
+
}
|
|
29
|
+
dir = parent
|
|
30
|
+
}
|
|
31
|
+
packageRootCache.set(startDir, dir)
|
|
32
|
+
return dir
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A **package-root-relative**, forward-slashed form of an absolute path — the input to {@link hash8} for
|
|
37
|
+
* any build-stable identifier (e.g. CSS-module scoped names). Anchoring on the file's nearest
|
|
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
|
|
41
|
+
* scoped class maps always agree. (Hashing the absolute path would differ across CI/host; a
|
|
42
|
+
* cwd-relative path would silently desync if the SSR server started from a non-project-root cwd.)
|
|
43
|
+
*/
|
|
44
|
+
export function reproduciblePath(absolutePath: string): string {
|
|
45
|
+
const root = packageRootOf(dirname(absolutePath))
|
|
46
|
+
return relative(root, absolutePath).replaceAll("\\", "/")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Deterministic 8-hex hash (djb2/xor). Stable across builds — no `Date.now`/`Math.random` — so build
|
|
51
|
+
* output is reproducible. The single hash implementation behind CSS-module scoped names (and a drop-in
|
|
52
|
+
* for any SFC scope id).
|
|
53
|
+
*/
|
|
54
|
+
export function hash8(input: string): string {
|
|
55
|
+
let h = 5381
|
|
56
|
+
for (let i = 0; i < input.length; i++) h = (Math.imul(h, 33) ^ input.charCodeAt(i)) >>> 0
|
|
57
|
+
return h.toString(16).padStart(8, "0")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module —
|
|
62
|
+
* the idiom the Vue plugin established (`?vue-css`). Register one per plugin `setup`; call `emit` per
|
|
63
|
+
* file to stash its CSS and get back the `import` line to append to the JS module.
|
|
64
|
+
*/
|
|
65
|
+
export interface StylesheetEmitter {
|
|
66
|
+
/** Store `css` for `path`, returning the `import "<path>?<namespace>"` line for the JS module. */
|
|
67
|
+
emit(path: string, css: string): string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wire the virtual-CSS-module handlers onto `build` for `namespace`, returning an {@link StylesheetEmitter}.
|
|
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.
|
|
74
|
+
*/
|
|
75
|
+
export function createStylesheetEmitter(
|
|
76
|
+
build: PluginBuilder,
|
|
77
|
+
namespace: string,
|
|
78
|
+
): StylesheetEmitter {
|
|
79
|
+
const suffix = `?${namespace}`
|
|
80
|
+
const cssByPath = new Map<string, string>()
|
|
81
|
+
// `\?${namespace}$`: namespaces are plain identifiers, so no regex metachars to escape.
|
|
82
|
+
build.onResolve({ filter: new RegExp(`\\?${namespace}$`) }, (args) => ({
|
|
83
|
+
path: args.path,
|
|
84
|
+
namespace,
|
|
85
|
+
}))
|
|
86
|
+
build.onLoad({ filter: /.*/, namespace }, (args) => ({
|
|
87
|
+
contents: cssByPath.get(args.path.slice(0, -suffix.length)) ?? "",
|
|
88
|
+
loader: "css",
|
|
89
|
+
}))
|
|
90
|
+
return {
|
|
91
|
+
emit(path, css) {
|
|
92
|
+
cssByPath.set(path, css)
|
|
93
|
+
return `import ${JSON.stringify(path + suffix)}\n`
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
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
|
|
101
|
+
* `import` (which keeps the peer out of the package's hard dependencies) is correct here.
|
|
102
|
+
*/
|
|
103
|
+
export async function requirePeer<T>(
|
|
104
|
+
specifier: string,
|
|
105
|
+
hint: { readonly feature: string; readonly install: string },
|
|
106
|
+
): Promise<T> {
|
|
107
|
+
try {
|
|
108
|
+
return (await import(specifier)) as T
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// Only a genuine resolution failure means "not installed" — surface anything else (a corrupt
|
|
111
|
+
// install, a native-binding error, a throw at module top-level) as the real error rather than
|
|
112
|
+
// masking it behind a misleading "please install it" hint.
|
|
113
|
+
if ((err as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND") {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`[nifra/web] ${hint.feature} requires the optional peer "${specifier}". Install it: ${hint.install}`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
throw new Error(
|
|
119
|
+
`[nifra/web] ${hint.feature}: the optional peer "${specifier}" is installed but failed to load.`,
|
|
120
|
+
{ cause: err },
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/plugins/scss` — a SASS/SCSS (`*.scss`, `*.sass`) Bun plugin, in its OWN module so the SSR
|
|
3
|
+
* preload registers it BEFORE any Sass file loads. Mirrors the `@nifrajs/web-vue/plugin` seam: pass
|
|
4
|
+
* `"dom"` for the client bundle (`buildClient({ plugins: [...] })`) and preload `"ssr"` for the server
|
|
5
|
+
* (`bun --preload`).
|
|
6
|
+
*
|
|
7
|
+
* The Dart Sass compiler is an **optional peer** (`sass`, or the faster `sass-embedded` — same API), not
|
|
8
|
+
* a hard dependency: it's loaded on first Sass file and fails loud with an install hint if absent. Pass
|
|
9
|
+
* your own `compiler` to override (e.g. `sass-embedded`, or a stub in tests).
|
|
10
|
+
*
|
|
11
|
+
* Composes with CSS Modules: a `*.module.scss` / `*.module.sass` file is compiled to CSS and then run
|
|
12
|
+
* through the same scoped-class transform as `@nifrajs/web/plugins/css-modules`, so its `import styles`
|
|
13
|
+
* yields the `{ original: scoped }` map (SSR/dom class-map parity included). A plain `*.scss` is a
|
|
14
|
+
* side-effect import — its CSS is bundled (dom) and it resolves to an empty module (ssr).
|
|
15
|
+
*/
|
|
16
|
+
import { dirname } from "node:path"
|
|
17
|
+
import { pathToFileURL } from "node:url"
|
|
18
|
+
import type { BunPlugin } from "bun"
|
|
19
|
+
import { transformCssModule } from "./css-modules.ts"
|
|
20
|
+
import { createStylesheetEmitter, reproduciblePath, requirePeer } from "./kit.ts"
|
|
21
|
+
|
|
22
|
+
const STYLE_NS = "nifra-scss"
|
|
23
|
+
|
|
24
|
+
/** The subset of the `sass` / `sass-embedded` API this plugin uses. Both packages satisfy it. */
|
|
25
|
+
export interface SassCompiler {
|
|
26
|
+
compileString(
|
|
27
|
+
source: string,
|
|
28
|
+
options?: {
|
|
29
|
+
readonly syntax?: "scss" | "indented"
|
|
30
|
+
readonly style?: "expanded" | "compressed"
|
|
31
|
+
readonly loadPaths?: readonly string[]
|
|
32
|
+
readonly url?: URL
|
|
33
|
+
},
|
|
34
|
+
): { readonly css: string }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ScssPluginOptions {
|
|
38
|
+
/** Inject the compiler (default: the `sass` optional peer, loaded lazily). Pass `sass-embedded` or a stub. */
|
|
39
|
+
readonly compiler?: SassCompiler
|
|
40
|
+
/** Output style handed to Sass. Default `"expanded"` (Bun minifies the final CSS bundle anyway). */
|
|
41
|
+
readonly style?: "expanded" | "compressed"
|
|
42
|
+
/** Extra import search paths for `@use`/`@import`, in addition to the source file's own directory. */
|
|
43
|
+
readonly loadPaths?: readonly string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isModuleRequest = (cleanPath: string): boolean => /\.module\.s[ac]ss$/.test(cleanPath)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The SASS/SCSS Bun plugin. `"dom"` → bundles the compiled CSS (and, for `*.module.scss`, exports the
|
|
50
|
+
* scoped class map); `"ssr"` → the class map only for `*.module.scss`, an empty module for plain Sass.
|
|
51
|
+
* Tolerates a trailing `?query` (dev servers append one to bust Bun's import cache).
|
|
52
|
+
*/
|
|
53
|
+
export function scssBunPlugin(generate: "dom" | "ssr", options: ScssPluginOptions = {}): BunPlugin {
|
|
54
|
+
return {
|
|
55
|
+
name: `nifra-scss-${generate}`,
|
|
56
|
+
setup(build) {
|
|
57
|
+
const stylesheet = createStylesheetEmitter(build, STYLE_NS)
|
|
58
|
+
let compiler = options.compiler
|
|
59
|
+
const getCompiler = async (): Promise<SassCompiler> => {
|
|
60
|
+
compiler ??= await requirePeer<SassCompiler>("sass", {
|
|
61
|
+
feature: "SASS/SCSS support",
|
|
62
|
+
install: "bun add -d sass",
|
|
63
|
+
})
|
|
64
|
+
return compiler
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
build.onLoad({ filter: /\.s[ac]ss(\?|$)/ }, async (args) => {
|
|
68
|
+
const path = args.path.split("?")[0] ?? args.path
|
|
69
|
+
const source = await Bun.file(path).text()
|
|
70
|
+
const sass = await getCompiler()
|
|
71
|
+
let css: string
|
|
72
|
+
try {
|
|
73
|
+
css = sass.compileString(source, {
|
|
74
|
+
syntax: path.endsWith(".sass") ? "indented" : "scss",
|
|
75
|
+
style: options.style ?? "expanded",
|
|
76
|
+
loadPaths: [dirname(path), ...(options.loadPaths ?? [])],
|
|
77
|
+
url: pathToFileURL(path),
|
|
78
|
+
}).css
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Attribute the Sass compile error to the file + package (Dart Sass errors carry line info).
|
|
81
|
+
throw new Error(
|
|
82
|
+
`[nifra/web] failed to compile ${path}: ${(err as Error)?.message ?? err}`,
|
|
83
|
+
{
|
|
84
|
+
cause: err,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (isModuleRequest(path)) {
|
|
90
|
+
// *.module.scss → compile, then scope class names through the shared CSS-modules transform.
|
|
91
|
+
// Scope off the cwd-relative path so names are reproducible across machines/CI.
|
|
92
|
+
const { exports, css: scopedCss } = transformCssModule(css, reproduciblePath(path))
|
|
93
|
+
const js = `export default ${JSON.stringify(exports)}\n`
|
|
94
|
+
return {
|
|
95
|
+
contents: generate === "dom" ? js + stylesheet.emit(path, scopedCss) : js,
|
|
96
|
+
loader: "js",
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Plain *.scss → side-effect import: bundle the CSS on dom, an empty module on ssr.
|
|
101
|
+
return {
|
|
102
|
+
contents: generate === "dom" ? stylesheet.emit(path, css) : "",
|
|
103
|
+
loader: "js",
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|