@nifrajs/web 1.13.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/build.d.ts +14 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/build.js +105 -40
  4. package/dist/build.js.map +1 -1
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/client.js +2 -10
  7. package/dist/client.js.map +1 -1
  8. package/dist/deferred.d.ts.map +1 -1
  9. package/dist/deferred.js +3 -0
  10. package/dist/deferred.js.map +1 -1
  11. package/dist/draft.d.ts +1 -1
  12. package/dist/draft.d.ts.map +1 -1
  13. package/dist/draft.js +1 -1
  14. package/dist/draft.js.map +1 -1
  15. package/dist/forms.d.ts +1 -1
  16. package/dist/forms.d.ts.map +1 -1
  17. package/dist/index.d.ts +11 -9
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +12 -22
  20. package/dist/index.js.map +1 -1
  21. package/dist/manifest.d.ts +2 -5
  22. package/dist/manifest.d.ts.map +1 -1
  23. package/dist/manifest.js +1 -6
  24. package/dist/manifest.js.map +1 -1
  25. package/dist/plugins/postcss.d.ts +50 -0
  26. package/dist/plugins/postcss.d.ts.map +1 -0
  27. package/dist/plugins/postcss.js +68 -0
  28. package/dist/plugins/postcss.js.map +1 -0
  29. package/dist/plugins/svg.d.ts +44 -0
  30. package/dist/plugins/svg.d.ts.map +1 -0
  31. package/dist/plugins/svg.js +110 -0
  32. package/dist/plugins/svg.js.map +1 -0
  33. package/dist/router.d.ts +3 -0
  34. package/dist/router.d.ts.map +1 -1
  35. package/dist/router.js +4 -4
  36. package/dist/router.js.map +1 -1
  37. package/package.json +13 -2
  38. package/src/build.ts +109 -41
  39. package/src/client.ts +2 -8
  40. package/src/deferred.ts +3 -0
  41. package/src/draft.ts +1 -1
  42. package/src/forms.ts +1 -1
  43. package/src/index.ts +18 -34
  44. package/src/manifest.ts +2 -8
  45. package/src/plugins/postcss.ts +129 -0
  46. package/src/plugins/svg.ts +156 -0
  47. package/src/router.ts +7 -4
package/src/client.ts CHANGED
@@ -44,16 +44,10 @@ export function installHistory(
44
44
  return raw
45
45
  }
46
46
  }
47
- // Resolve a fragment id to its element — by `id`, then a legacy `<a name>` — or null if absent.
47
+ // Resolve a fragment id to its element by `id`, or null if absent.
48
48
  const findAnchor = (id: string): Element | null => {
49
49
  if (id === "") return null
50
- const byId = document.getElementById(id)
51
- if (byId !== null) return byId
52
- try {
53
- return document.querySelector(`a[name="${CSS.escape(id)}"]`)
54
- } catch {
55
- return null // CSS/CSS.escape unavailable or an exotic id → no named-anchor match
56
- }
50
+ return document.getElementById(id)
57
51
  }
58
52
 
59
53
  // Wrap a navigation's render in a View Transition when the browser supports it — a graceful
package/src/deferred.ts CHANGED
@@ -28,6 +28,9 @@ export interface Deferred<T> {
28
28
  * component knows to `<Await>` it. The `id` is a placeholder until the server assigns the real one.
29
29
  */
30
30
  export function defer<T>(promise: Promise<T>): Deferred<T> {
31
+ // Claim the promise immediately: an eager rejection may otherwise be reported as unhandled before
32
+ // the SSR/NDJSON consumer attaches. Keep the original promise so consumers observe its rejection.
33
+ void promise.catch(() => {})
31
34
  return { __nifra_deferred: true, id: -1, promise }
32
35
  }
33
36
 
package/src/draft.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * createWebApp({ ..., draftSecret: env.DRAFT_SECRET }) // → loaders get ctx.draft
15
15
  * withISR(app, { ..., draftSecret: env.DRAFT_SECRET }) // → editors bypass the cache
16
16
  */
17
- import { type CookieOptions, parseCookies, signValue, unsignValue } from "@nifrajs/core"
17
+ import { type CookieOptions, parseCookies, signValue, unsignValue } from "@nifrajs/core/server"
18
18
 
19
19
  /** The cookie name nifra uses for draft/preview mode. */
20
20
  export const DRAFT_COOKIE = "__nifra_draft"
package/src/forms.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * runtime is a trivial pass-through — all the work is in the types. It checks the field KEY, never the
19
19
  * MEANING (a `field("email")` collecting a phone is still valid here); that stays an app/AI concern.
20
20
  */
21
- import type { RouteInfo, Server } from "@nifrajs/core"
21
+ import type { RouteInfo, Server } from "@nifrajs/core/server"
22
22
 
23
23
  // Derived from `FormData` itself (not the `FormDataEntryValue` global) so this file stays DOM-lib-free:
24
24
  // `@nifrajs/web`'s server surface is typechecked by the root config, which has no DOM lib.
package/src/index.ts CHANGED
@@ -7,12 +7,13 @@
7
7
  * the adapter interprets them. That keeps this package both framework-agnostic and free
8
8
  * of DOM types: it is pure server orchestration + string building.
9
9
  */
10
- import { server } from "@nifrajs/core"
10
+
11
11
  import {
12
12
  type BackendMount,
13
13
  type BackendMountHandler,
14
14
  NIFRA_BACKEND_MOUNT,
15
15
  } from "@nifrajs/core/mount"
16
+ import { server } from "@nifrajs/core/server"
16
17
  import {
17
18
  DEFERRED_ERROR_CODE,
18
19
  DEFERRED_RUNTIME,
@@ -86,7 +87,6 @@ export {
86
87
  export {
87
88
  type Action,
88
89
  buildManifest,
89
- enumeratePrerenderedPaths,
90
90
  enumerateStaticRoutes,
91
91
  filePathToPattern,
92
92
  filePathToPatterns,
@@ -201,6 +201,9 @@ export interface RenderProps {
201
201
  readonly actionData?: unknown
202
202
  /** True while a client navigation or submit is in flight (client-only; absent/false on SSR). */
203
203
  readonly pending?: boolean
204
+ /** The path a client navigation is transitioning TO while `pending` (client-only; absent on SSR and
205
+ * when idle). Lets an adapter tell a `NavLink` whether its own target is the one loading. */
206
+ readonly pendingPath?: string
204
207
  /** The in-flight client submit, for optimistic UI (client-only; absent on SSR + when idle). */
205
208
  readonly submission?: Submission
206
209
  /** The matched route's decoded path params (`/users/:id` → `{ id: "7" }`). Threaded identically on
@@ -687,21 +690,17 @@ function isSameOriginPath(location: string): boolean {
687
690
  /**
688
691
  * Build a redirect `Response` — return it from a route `action` for the Post/Redirect/Get
689
692
  * pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
690
- * Defaults to 303 (See Other); pass `307`/`308` (or `{ status }`) to preserve the method.
693
+ * Defaults to 303 (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
691
694
  *
692
695
  * **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
693
696
  * off-origin/absolute destination throws unless you pass `{ external: true }` — this closes the
694
697
  * open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
695
698
  * which returns the action's `Response` verbatim.
696
699
  *
697
- * @param statusOrOptions a status number (back-compat) or `{ status?, external? }`.
700
+ * @param options redirect status and whether an off-origin destination is intentional.
698
701
  */
699
- export function redirect(
700
- location: string,
701
- statusOrOptions: number | RedirectOptions = 303,
702
- ): Response {
703
- const opts = typeof statusOrOptions === "number" ? { status: statusOrOptions } : statusOrOptions
704
- if (opts.external !== true && !isSameOriginPath(location)) {
702
+ export function redirect(location: string, options: RedirectOptions = {}): Response {
703
+ if (options.external !== true && !isSameOriginPath(location)) {
705
704
  throw new Error(
706
705
  `[nifra/web] redirect(${JSON.stringify(location)}) is not a same-origin path. Use a path beginning with "/" (not "//"), or redirect(location, { external: true }) for a deliberate off-origin redirect. This guards against open redirects from unvalidated input.`,
707
706
  )
@@ -715,7 +714,7 @@ export function redirect(
715
714
  `[nifra/web] redirect location contains a CR/LF character — refusing to emit a header-injecting redirect.`,
716
715
  )
717
716
  }
718
- return new Response(null, { status: opts.status ?? 303, headers: { location } })
717
+ return new Response(null, { status: options.status ?? 303, headers: { location } })
719
718
  }
720
719
 
721
720
  /** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
@@ -967,15 +966,14 @@ export interface CreateWebAppOptions {
967
966
  * mount interface from `@nifrajs/core/mount`; `createWebApp` also serves that backend over HTTP at
968
967
  * {@link apiPrefix} (default `/api`): a request whose pathname starts with the prefix is dispatched
969
968
  * before page routing with the same `env`/`waitUntil` platform context, and the backend's `Response`
970
- * is returned untouched. Legacy custom `.fetch(url, init)` bridges remain mountable but cannot
971
- * receive platform context. The mount runs in `nifra dev` too. Pass `apiPrefix: ""` to disable it. */
969
+ * is returned untouched. The mount runs in `nifra dev` too. Pass `apiPrefix: ""` to disable it. */
972
970
  readonly api?: unknown
973
971
  /** HTTP path prefix the {@link api} backend is auto-mounted at (default `"/api"`). A request whose
974
972
  * pathname is exactly the prefix or starts with `prefix + "/"` is dispatched to the backend before
975
973
  * page routing; the backend therefore defines its routes at the **full** path (`server().post("/api/
976
974
  * sync", …)`), matching the in-process `inProcessClient` call sites. Set to `""` to disable the
977
975
  * auto-mount entirely (the app serves pages only and `api` stays a loader-only `ctx.api`). Mounting
978
- * is also a no-op when `api` has neither the symbol mount nor a legacy callable `.fetch`. */
976
+ * is also a no-op when `api` does not expose the symbol mount. */
979
977
  readonly apiPrefix?: string
980
978
  /** Secret for **draft / preview mode** (see `enableDraft`). When set, a request carrying a valid
981
979
  * signed `__nifra_draft` cookie gets `ctx.draft === true` in loaders/actions (else always `false`).
@@ -994,7 +992,7 @@ export interface CreateWebAppOptions {
994
992
  * of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
995
993
  * page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
996
994
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
997
- /** SSG: the prerendered-path set (e.g. from `enumeratePrerenderedPaths` or the build's
995
+ /** SSG: the prerendered-path set (e.g. `enumerateStaticRoutes(routes).paths` or the build's
998
996
  * `prerendered.json`). Injected as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
999
997
  * into a prerendered route fetches its static `_data.json` instead of hitting the worker. */
1000
998
  readonly prerenderedPaths?: readonly string[]
@@ -1027,24 +1025,15 @@ interface RouteContext {
1027
1025
  readonly env: unknown
1028
1026
  }
1029
1027
 
1030
- /** Legacy pre-symbol mount bridge. Kept for custom integrations; it cannot receive platform context. */
1031
- interface LegacyMountableApi {
1032
- readonly fetch: (url: string, init?: RequestInit) => Response | Promise<Response>
1033
- }
1034
-
1035
1028
  /**
1036
- * Resolve the explicit backend mount interface, then fall back to the released `.fetch(url, init)`
1037
- * convention. The symbol seam forwards platform context without making web depend on client.
1029
+ * Resolve the explicit symbol-keyed backend mount interface. The symbol seam forwards platform
1030
+ * context without making web depend on client.
1038
1031
  */
1039
1032
  function backendMountOf(api: unknown): BackendMountHandler | undefined {
1040
1033
  if ((typeof api !== "object" && typeof api !== "function") || api === null) return undefined
1041
1034
  const explicit = (api as Partial<BackendMount>)[NIFRA_BACKEND_MOUNT]
1042
- if (typeof explicit === "function") {
1043
- return (request, platform) => explicit.call(api, request, platform)
1044
- }
1045
- const legacy = (api as Partial<LegacyMountableApi>).fetch
1046
- if (typeof legacy !== "function") return undefined
1047
- return (request) => legacy.call(api, request.url, request)
1035
+ if (typeof explicit !== "function") return undefined
1036
+ return (request, platform) => explicit.call(api, request, platform)
1048
1037
  }
1049
1038
 
1050
1039
  /**
@@ -1095,13 +1084,8 @@ export function createWebApp<Env = unknown>(
1095
1084
  // surfaces as the backend's own response, never the page 404. We dispatch the SAME `Request` object
1096
1085
  // (its body unread) so streamed/large bodies pass through untouched; the backend defines its routes
1097
1086
  // at the full prefixed path (`server().post("/api/sync", …)`), matching the `inProcessClient` call
1098
- // sites. `apiPrefix: ""` opts out; a non-mountable `api` (no `.fetch`) is left pages-only. The hook
1087
+ // sites. `apiPrefix: ""` opts out; a non-mountable `api` (no symbol mount) is left pages-only. The hook
1099
1088
  // is registered ONLY when both hold, so a pages-only app keeps core's synchronous no-hook fast path.
1100
- //
1101
- // Backward-compat: an app that still hand-dispatches `/api/*` in its server entry (the old
1102
- // `if (pathname.startsWith("/api/")) return backend.fetch(req)`) intercepts BEFORE `app.fetch` is
1103
- // ever called, so that branch returns first and this hook never sees the request — no double
1104
- // handling. Apps that drop the hand-dispatch now rely on this mount instead (dev + prod alike).
1105
1089
  const apiPrefix = options.apiPrefix ?? "/api"
1106
1090
  const mountedApi = backendMountOf(api)
1107
1091
  if (apiPrefix !== "" && mountedApi !== undefined) {
package/src/manifest.ts CHANGED
@@ -108,7 +108,7 @@ export interface MetaArgs<Data = unknown> {
108
108
  * `og:url`/`canonical`/`og:image` built from it never drifts between the server-rendered `<head>` and
109
109
  * a soft-nav. Use it for absolute URLs (`origin + "/posts/" + slug`) instead of threading `siteUrl`
110
110
  * through loader data. Empty string (`""`) when the origin is unknown (e.g. a hand-built test render
111
- * with no request URL) — back-compat-safe: a `meta()` that ignores `origin` is unchanged.
111
+ * with no request URL). A `meta()` that ignores `origin` is unchanged.
112
112
  */
113
113
  readonly origin: string
114
114
  }
@@ -381,7 +381,7 @@ const encodeRouteParam = (value: string): string => {
381
381
  * Substitute a route pattern's `:param` segments with concrete values: `/users/:id` + `{id:"7"}` →
382
382
  * `/users/7`. Returns the filled path plus any params that had no value (a `getStaticPaths` bug) so
383
383
  * the caller can skip rather than emit a path with a literal `:name`. Shared by the SSG driver and
384
- * {@link enumeratePrerenderedPaths}.
384
+ * {@link enumerateStaticRoutes}.
385
385
  */
386
386
  export function fillRoutePattern(
387
387
  pattern: string,
@@ -438,9 +438,3 @@ export async function enumerateStaticRoutes(routes: readonly RouteEntry[]): Prom
438
438
  }
439
439
  return { paths, fallbacks }
440
440
  }
441
-
442
- /** The prerendered-path subset of {@link enumerateStaticRoutes} — kept for callers that only need the
443
- * paths (e.g. injecting `window.__NIFRA_PRERENDERED__`). */
444
- export async function enumeratePrerenderedPaths(routes: readonly RouteEntry[]): Promise<string[]> {
445
- return (await enumerateStaticRoutes(routes)).paths
446
- }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `@nifrajs/web/plugins/postcss` - a PostCSS (`*.css`, `*.pcss`, `*.postcss`) Bun plugin, in its OWN
3
+ * module so the SSR preload registers it BEFORE any CSS file loads. Mirrors `@nifrajs/web/plugins/scss`:
4
+ * pass `"dom"` for the client bundle (`buildClient({ plugins: [...] })`) and preload `"ssr"` for the
5
+ * server (`bun --preload`).
6
+ *
7
+ * PostCSS (`postcss`) is an **optional peer**, not a hard dependency: it's loaded on the first CSS file
8
+ * and fails loud with an install hint if absent. The plugin list comes from an explicit `plugins`
9
+ * option, or - when omitted - from the project's `postcss.config.js`, loaded via the optional
10
+ * `postcss-load-config` peer. This is the Tailwind v4 path: a config with `@tailwindcss/postcss` runs
11
+ * inside PostCSS, so `app.css` importing `tailwindcss` is compiled during `Bun.build` with no
12
+ * framework-specific code (v4 bundles its own Lightning CSS, so no separate autoprefixer step).
13
+ *
14
+ * Composes with CSS Modules: a `*.module.css` / `*.module.pcss` file is processed and then run through
15
+ * the same scoped-class transform as `@nifrajs/web/plugins/css-modules`, so `import styles` yields the
16
+ * `{ original: scoped }` map (SSR/dom class-map parity). A plain CSS file is a side-effect import - its
17
+ * CSS is bundled (dom) and it resolves to an empty module (ssr).
18
+ */
19
+ import type { BunPlugin } from "bun"
20
+ import { transformCssModule } from "./css-modules.ts"
21
+ import { createStylesheetEmitter, reproduciblePath, requirePeer } from "./kit.ts"
22
+
23
+ const STYLE_NS = "nifra-postcss"
24
+
25
+ /** The subset of the `postcss` API this plugin uses (structural, so no hard dependency on its types). */
26
+ export type PostcssProcessor = (plugins?: readonly unknown[]) => {
27
+ process(
28
+ css: string,
29
+ options: { readonly from?: string; readonly to?: string },
30
+ ): PromiseLike<{ readonly css: string }>
31
+ }
32
+
33
+ /** The subset of `postcss-load-config` this plugin uses when no explicit `plugins` are given. */
34
+ export type PostcssConfigLoader = (
35
+ ctx?: Record<string, unknown>,
36
+ path?: string,
37
+ ) => Promise<{ readonly plugins?: readonly unknown[]; readonly options?: Record<string, unknown> }>
38
+
39
+ export interface PostcssPluginOptions {
40
+ /** Inject the `postcss` function (default: the optional peer, loaded lazily). Pass a stub in tests. */
41
+ readonly postcss?: PostcssProcessor
42
+ /** PostCSS plugins to run. When omitted, the project's `postcss.config.js` is loaded instead. */
43
+ readonly plugins?: readonly unknown[]
44
+ /** Directory (or file) to load `postcss.config.js` from, when `plugins` is omitted. Default: cwd. */
45
+ readonly config?: string
46
+ /** Inject the config loader (default: the `postcss-load-config` optional peer). */
47
+ readonly loadConfig?: PostcssConfigLoader
48
+ }
49
+
50
+ const CSS_FILTER = /\.(css|pcss|postcss)(\?|$)/
51
+ const isModuleRequest = (cleanPath: string): boolean =>
52
+ /\.module\.(css|pcss|postcss)$/.test(cleanPath)
53
+
54
+ /**
55
+ * The PostCSS Bun plugin. `"dom"` → bundles the processed CSS (and, for `*.module.*`, exports the
56
+ * scoped class map); `"ssr"` → the class map only for `*.module.*`, an empty module for plain CSS.
57
+ * Tolerates a trailing `?query` (dev servers append one to bust Bun's import cache).
58
+ */
59
+ export function postcssBunPlugin(
60
+ generate: "dom" | "ssr",
61
+ options: PostcssPluginOptions = {},
62
+ ): BunPlugin {
63
+ return {
64
+ name: `nifra-postcss-${generate}`,
65
+ setup(build) {
66
+ const stylesheet = createStylesheetEmitter(build, STYLE_NS)
67
+ let processor = options.postcss
68
+ let pluginList: readonly unknown[] | undefined = options.plugins
69
+
70
+ const getProcessor = async (): Promise<PostcssProcessor> => {
71
+ processor ??= (
72
+ await requirePeer<{ default: PostcssProcessor }>("postcss", {
73
+ feature: "PostCSS/Tailwind support",
74
+ install: "bun add -d postcss",
75
+ })
76
+ ).default
77
+ return processor
78
+ }
79
+
80
+ const getPlugins = async (): Promise<readonly unknown[]> => {
81
+ if (pluginList !== undefined) return pluginList
82
+ const load =
83
+ options.loadConfig ??
84
+ (
85
+ await requirePeer<{ default: PostcssConfigLoader }>("postcss-load-config", {
86
+ feature: "loading postcss.config.js (or pass `plugins` explicitly)",
87
+ install: "bun add -d postcss-load-config",
88
+ })
89
+ ).default
90
+ const loaded = await load({}, options.config)
91
+ pluginList = loaded.plugins ?? []
92
+ return pluginList
93
+ }
94
+
95
+ build.onLoad({ filter: CSS_FILTER }, async (args) => {
96
+ const path = args.path.split("?")[0] ?? args.path
97
+ const source = await Bun.file(path).text()
98
+ const postcss = await getProcessor()
99
+ const plugins = await getPlugins()
100
+ let css: string
101
+ try {
102
+ css = (await postcss(plugins).process(source, { from: path })).css
103
+ } catch (err) {
104
+ throw new Error(
105
+ `[nifra/web] failed to process ${path}: ${(err as Error)?.message ?? err}`,
106
+ { cause: err },
107
+ )
108
+ }
109
+
110
+ if (isModuleRequest(path)) {
111
+ // *.module.css → process, then scope class names through the shared CSS-modules transform.
112
+ // Scope off the cwd-relative path so names are reproducible across machines/CI.
113
+ const { exports, css: scopedCss } = transformCssModule(css, reproduciblePath(path))
114
+ const js = `export default ${JSON.stringify(exports)}\n`
115
+ return {
116
+ contents: generate === "dom" ? js + stylesheet.emit(path, scopedCss) : js,
117
+ loader: "js",
118
+ }
119
+ }
120
+
121
+ // Plain CSS → side-effect import: bundle the CSS on dom, an empty module on ssr.
122
+ return {
123
+ contents: generate === "dom" ? stylesheet.emit(path, css) : "",
124
+ loader: "js",
125
+ }
126
+ })
127
+ },
128
+ }
129
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `@nifrajs/web/plugins/svg` - import an SVG as a component: `import Icon from "./icon.svg?component"`,
3
+ * then `<Icon className="w-6 h-6 text-blue-500" />`. Props spread onto the root `<svg>`, matching the
4
+ * standard Vite `svgr` workflow. In its OWN module so the SSR preload registers it before any SVG
5
+ * loads; pass `"dom"` for the client bundle and preload `"ssr"` for the server (both emit the same
6
+ * isomorphic component).
7
+ *
8
+ * SCOPE: emits an **automatic-JSX-runtime** component, so it works out of the box for the frameworks
9
+ * whose build supplies a `jsxImportSource` - **React and Preact**. Solid (needs `babel-preset-solid`),
10
+ * Svelte, and Vue are not JSX and are intentionally out of v1; a plain `import "./icon.svg"` (asset URL)
11
+ * is untouched - only the `?component` marker is intercepted.
12
+ *
13
+ * Optimization is optional: pass `svgo` (or install the `svgo` peer) to minify/clean the SVG first.
14
+ * The transform is regex-based and targets the common icon-set shape (well-formed, self-closed tags);
15
+ * it camelCases hyphenated + `xlink:`/`xmlns:` attributes, maps `class` → `className`, parses an inline
16
+ * `style="..."` into an object, and drops XML declarations/comments so the result is valid JSX.
17
+ */
18
+ import type { BunPlugin } from "bun"
19
+ import { requirePeer } from "./kit.ts"
20
+
21
+ /** The subset of the `svgo` API this plugin uses (structural, so no hard dependency on its types). */
22
+ export interface SvgOptimizer {
23
+ optimize(input: string, options?: Record<string, unknown>): { readonly data: string }
24
+ }
25
+
26
+ export interface SvgPluginOptions {
27
+ /** Optimize each SVG first. `true` loads the `svgo` peer; pass an optimizer to inject one (or a stub). */
28
+ readonly svgo?: boolean | SvgOptimizer
29
+ }
30
+
31
+ /** SVG/HTML attribute names that JSX expects camelCased (the common subset on icons). */
32
+ const CAMEL_ATTRS = new Map(
33
+ [
34
+ "clip-path",
35
+ "clip-rule",
36
+ "fill-opacity",
37
+ "fill-rule",
38
+ "stroke-width",
39
+ "stroke-linecap",
40
+ "stroke-linejoin",
41
+ "stroke-opacity",
42
+ "stroke-dasharray",
43
+ "stroke-dashoffset",
44
+ "stroke-miterlimit",
45
+ "font-family",
46
+ "font-size",
47
+ "font-weight",
48
+ "text-anchor",
49
+ "stop-color",
50
+ "stop-opacity",
51
+ "color-interpolation-filters",
52
+ "flood-color",
53
+ "flood-opacity",
54
+ "vector-effect",
55
+ ].map((name) => [name, name.replace(/-([a-z])/g, (_m, c) => (c as string).toUpperCase())]),
56
+ )
57
+
58
+ function styleToObject(style: string): string {
59
+ const entries = style
60
+ .split(";")
61
+ .map((decl) => decl.trim())
62
+ .filter(Boolean)
63
+ .map((decl) => {
64
+ const idx = decl.indexOf(":")
65
+ const prop = decl
66
+ .slice(0, idx)
67
+ .trim()
68
+ .replace(/-([a-z])/g, (_m, c) => (c as string).toUpperCase())
69
+ const value = decl.slice(idx + 1).trim()
70
+ return `${JSON.stringify(prop)}:${JSON.stringify(value)}`
71
+ })
72
+ return `{{${entries.join(",")}}}`
73
+ }
74
+
75
+ export interface SvgToJsxOptions {
76
+ /** The JSX prop name for `class`. `"className"` for React/Preact; `"class"` for Solid. */
77
+ readonly classProp?: string
78
+ }
79
+
80
+ /** Convert an SVG XML string into a JSX-safe `<svg>…</svg>` element with `{...props}` spread on the root. */
81
+ export function svgToJsx(xml: string, options: SvgToJsxOptions = {}): string {
82
+ const classProp = options.classProp ?? "className"
83
+ let out = xml
84
+ .replace(/<\?xml[\s\S]*?\?>/g, "") // XML declaration
85
+ .replace(/<!--[\s\S]*?-->/g, "") // comments
86
+ .replace(/<!DOCTYPE[^>]*>/gi, "")
87
+ .trim()
88
+
89
+ // Namespaced attributes → camelCase (xlink:href → xlinkHref, xmlns:xlink → xmlnsXlink).
90
+ out = out.replace(/\b([a-z]+):([a-z]+)=/gi, (_m, ns: string, name: string) => {
91
+ return `${ns.toLowerCase()}${name.charAt(0).toUpperCase()}${name.slice(1)}=`
92
+ })
93
+ // Known hyphenated attributes → camelCase.
94
+ for (const [kebab, camel] of CAMEL_ATTRS) {
95
+ out = out.replace(new RegExp(`\\b${kebab}=`, "g"), `${camel}=`)
96
+ }
97
+ // class → the framework's prop (className for React/Preact, class for Solid).
98
+ if (classProp !== "class") out = out.replace(/\bclass=/g, `${classProp}=`)
99
+ // Inline style string → JSX object.
100
+ out = out.replace(/\bstyle="([^"]*)"/g, (_m, css: string) => `style=${styleToObject(css)}`)
101
+ // Spread props onto the root <svg> tag (after the tag name, before its attributes).
102
+ out = out.replace(/^<svg\b/, "<svg {...props}")
103
+ return out
104
+ }
105
+
106
+ /** Emit the component module source for a `?component` SVG import. Identical on dom + ssr (isomorphic). */
107
+ export function svgComponentSource(xml: string, options: SvgToJsxOptions = {}): string {
108
+ return `export default function SvgComponent(props){return (${svgToJsx(xml, options)})}\n`
109
+ }
110
+
111
+ /** The Bun `onLoad` filter every adapter's SVG-component plugin matches: `*.svg?component`. */
112
+ export const SVG_COMPONENT_FILTER = /\.svg\?component(&|$)/
113
+ const SVG_FILTER = SVG_COMPONENT_FILTER
114
+
115
+ /**
116
+ * The SVG-as-component Bun plugin (React/Preact). `generate` is accepted for parity with the other
117
+ * plugin pairs; the emitted component is the same on `"dom"` and `"ssr"`.
118
+ */
119
+ export function svgComponentBunPlugin(
120
+ _generate: "dom" | "ssr",
121
+ options: SvgPluginOptions = {},
122
+ ): BunPlugin {
123
+ return {
124
+ name: "nifra-svg-component",
125
+ setup(build) {
126
+ let optimizer: SvgOptimizer | undefined =
127
+ typeof options.svgo === "object" ? options.svgo : undefined
128
+ const wantOptimize = options.svgo !== undefined && options.svgo !== false
129
+ const getOptimizer = async (): Promise<SvgOptimizer | undefined> => {
130
+ if (!wantOptimize) return undefined
131
+ optimizer ??= await requirePeer<SvgOptimizer>("svgo", {
132
+ feature: "SVG optimization (svgo)",
133
+ install: "bun add -d svgo",
134
+ })
135
+ return optimizer
136
+ }
137
+
138
+ build.onLoad({ filter: SVG_FILTER }, async (args) => {
139
+ const path = args.path.split("?")[0] ?? args.path
140
+ let xml = await Bun.file(path).text()
141
+ const svgo = await getOptimizer()
142
+ if (svgo !== undefined) {
143
+ try {
144
+ xml = svgo.optimize(xml, { path }).data
145
+ } catch (err) {
146
+ throw new Error(
147
+ `[nifra/web] failed to optimize ${path}: ${(err as Error)?.message ?? err}`,
148
+ { cause: err },
149
+ )
150
+ }
151
+ }
152
+ return { contents: svgComponentSource(xml), loader: "jsx" }
153
+ })
154
+ },
155
+ }
156
+ }
package/src/router.ts CHANGED
@@ -64,6 +64,9 @@ export interface RouterState {
64
64
  readonly actionData?: unknown
65
65
  /** True while a navigation or submit is in flight (drives loading UI). */
66
66
  readonly pending: boolean
67
+ /** The path a navigation is transitioning TO while `pending` (cleared when it settles). Lets a
68
+ * `NavLink` know whether its own `to` is the one loading; `undefined` when idle. */
69
+ readonly pendingPath?: string | undefined
67
70
  /** The in-flight submit (set during a `submit`, cleared when it settles) — for optimistic UI. */
68
71
  readonly submission?: Submission
69
72
  }
@@ -480,7 +483,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
480
483
  navAbort?.abort() // abandon any in-flight navigation's stream
481
484
  const ac = new AbortController()
482
485
  navAbort = ac
483
- state = { ...state, pending: true }
486
+ state = { ...state, pending: true, pendingPath: path }
484
487
  emit()
485
488
  try {
486
489
  // Use prefetched data when present (one-shot — drop it); else fetch. The chunk is loaded
@@ -503,10 +506,10 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
503
506
  }
504
507
  emit()
505
508
  } catch (err) {
506
- // Clear our pending flag (only if still current) and rethrow — the caller decides how to
507
- // recover (the history layer falls back to a full-page navigation).
509
+ // Clear our pending flag + target (only if still current) and rethrow — the caller decides how
510
+ // to recover (the history layer falls back to a full-page navigation).
508
511
  if (mine === token) {
509
- state = { ...state, pending: false }
512
+ state = { ...state, pending: false, pendingPath: undefined }
510
513
  emit()
511
514
  }
512
515
  throw err