@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.
- package/dist/build.d.ts +14 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +105 -40
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -10
- package/dist/client.js.map +1 -1
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +3 -0
- package/dist/deferred.js.map +1 -1
- package/dist/draft.d.ts +1 -1
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +1 -1
- package/dist/draft.js.map +1 -1
- package/dist/forms.d.ts +1 -1
- package/dist/forms.d.ts.map +1 -1
- package/dist/index.d.ts +11 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -22
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +2 -5
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +1 -6
- package/dist/manifest.js.map +1 -1
- package/dist/plugins/postcss.d.ts +50 -0
- package/dist/plugins/postcss.d.ts.map +1 -0
- package/dist/plugins/postcss.js +68 -0
- package/dist/plugins/postcss.js.map +1 -0
- package/dist/plugins/svg.d.ts +44 -0
- package/dist/plugins/svg.d.ts.map +1 -0
- package/dist/plugins/svg.js +110 -0
- package/dist/plugins/svg.js.map +1 -0
- package/dist/router.d.ts +3 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +4 -4
- package/dist/router.js.map +1 -1
- package/package.json +13 -2
- package/src/build.ts +109 -41
- package/src/client.ts +2 -8
- package/src/deferred.ts +3 -0
- package/src/draft.ts +1 -1
- package/src/forms.ts +1 -1
- package/src/index.ts +18 -34
- package/src/manifest.ts +2 -8
- package/src/plugins/postcss.ts +129 -0
- package/src/plugins/svg.ts +156 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
700
|
+
* @param options redirect status and whether an off-origin destination is intentional.
|
|
698
701
|
*/
|
|
699
|
-
export function redirect(
|
|
700
|
-
location
|
|
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:
|
|
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.
|
|
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`
|
|
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.
|
|
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
|
|
1037
|
-
*
|
|
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
|
|
1043
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|