@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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/css-modules.d.ts +34 -0
  4. package/dist/build.d.ts +110 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +238 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/client.d.ts +32 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +204 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/deferred.d.ts +90 -0
  13. package/dist/deferred.d.ts.map +1 -0
  14. package/dist/deferred.js +310 -0
  15. package/dist/deferred.js.map +1 -0
  16. package/dist/dev-error.d.ts +17 -0
  17. package/dist/dev-error.d.ts.map +1 -0
  18. package/dist/dev-error.js +59 -0
  19. package/dist/dev-error.js.map +1 -0
  20. package/dist/dev.d.ts +25 -0
  21. package/dist/dev.d.ts.map +1 -0
  22. package/dist/dev.js +83 -0
  23. package/dist/dev.js.map +1 -0
  24. package/dist/draft.d.ts +58 -0
  25. package/dist/draft.d.ts.map +1 -0
  26. package/dist/draft.js +55 -0
  27. package/dist/draft.js.map +1 -0
  28. package/dist/fonts-google.d.ts +109 -0
  29. package/dist/fonts-google.d.ts.map +1 -0
  30. package/dist/fonts-google.js +291 -0
  31. package/dist/fonts-google.js.map +1 -0
  32. package/dist/fonts.d.ts +67 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +103 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/fs.d.ts +10 -0
  37. package/dist/fs.d.ts.map +1 -0
  38. package/dist/fs.js +24 -0
  39. package/dist/fs.js.map +1 -0
  40. package/dist/index.d.ts +280 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +784 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/islands.d.ts +37 -0
  45. package/dist/islands.d.ts.map +1 -0
  46. package/dist/islands.js +107 -0
  47. package/dist/islands.js.map +1 -0
  48. package/dist/isr.d.ts +154 -0
  49. package/dist/isr.d.ts.map +1 -0
  50. package/dist/isr.js +331 -0
  51. package/dist/isr.js.map +1 -0
  52. package/dist/manifest.d.ts +178 -0
  53. package/dist/manifest.d.ts.map +1 -0
  54. package/dist/manifest.js +213 -0
  55. package/dist/manifest.js.map +1 -0
  56. package/dist/plugins/css-modules.d.ts +40 -0
  57. package/dist/plugins/css-modules.d.ts.map +1 -0
  58. package/dist/plugins/css-modules.js +0 -0
  59. package/dist/plugins/css-modules.js.map +1 -0
  60. package/dist/plugins/kit.d.ts +44 -0
  61. package/dist/plugins/kit.d.ts.map +1 -0
  62. package/dist/plugins/kit.js +105 -0
  63. package/dist/plugins/kit.js.map +1 -0
  64. package/dist/plugins/scss.d.ts +27 -0
  65. package/dist/plugins/scss.d.ts.map +1 -0
  66. package/dist/plugins/scss.js +78 -0
  67. package/dist/plugins/scss.js.map +1 -0
  68. package/dist/prerender.d.ts +77 -0
  69. package/dist/prerender.d.ts.map +1 -0
  70. package/dist/prerender.js +139 -0
  71. package/dist/prerender.js.map +1 -0
  72. package/dist/query.d.ts +62 -0
  73. package/dist/query.d.ts.map +1 -0
  74. package/dist/query.js +184 -0
  75. package/dist/query.js.map +1 -0
  76. package/dist/router.d.ts +169 -0
  77. package/dist/router.d.ts.map +1 -0
  78. package/dist/router.js +531 -0
  79. package/dist/router.js.map +1 -0
  80. package/dist/vite.d.ts +49 -0
  81. package/dist/vite.d.ts.map +1 -0
  82. package/dist/vite.js +144 -0
  83. package/dist/vite.js.map +1 -0
  84. package/package.json +127 -0
  85. package/src/build.ts +375 -0
  86. package/src/client.ts +218 -0
  87. package/src/deferred.ts +339 -0
  88. package/src/dev-error.ts +62 -0
  89. package/src/dev.ts +108 -0
  90. package/src/draft.ts +82 -0
  91. package/src/fonts-google.ts +397 -0
  92. package/src/fonts.ts +147 -0
  93. package/src/fs.ts +26 -0
  94. package/src/index.ts +1202 -0
  95. package/src/islands.ts +121 -0
  96. package/src/isr.ts +461 -0
  97. package/src/manifest.ts +366 -0
  98. package/src/plugins/css-modules.ts +0 -0
  99. package/src/plugins/kit.ts +123 -0
  100. package/src/plugins/scss.ts +108 -0
  101. package/src/prerender.ts +209 -0
  102. package/src/query.ts +250 -0
  103. package/src/router.ts +676 -0
  104. package/src/vite.ts +212 -0
@@ -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
+ }