@nifrajs/web 1.12.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 +13 -36
- 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 +20 -37
|
@@ -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
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
* browser-only `installHistory` (history + link interception) wires on top. Kept DOM-free so it
|
|
5
5
|
* unit-tests without a browser and is safe to import from the SSR core's main entry.
|
|
6
6
|
*/
|
|
7
|
+
import {
|
|
8
|
+
compareRoutePatternSpecificity,
|
|
9
|
+
compileRoutePattern,
|
|
10
|
+
matchRoutePattern,
|
|
11
|
+
} from "@nifrajs/core/pattern"
|
|
7
12
|
import { parseNdjsonData } from "./deferred.ts"
|
|
8
13
|
|
|
9
14
|
/**
|
|
@@ -59,6 +64,9 @@ export interface RouterState {
|
|
|
59
64
|
readonly actionData?: unknown
|
|
60
65
|
/** True while a navigation or submit is in flight (drives loading UI). */
|
|
61
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
|
|
62
70
|
/** The in-flight submit (set during a `submit`, cleared when it settles) — for optimistic UI. */
|
|
63
71
|
readonly submission?: Submission
|
|
64
72
|
}
|
|
@@ -69,29 +77,6 @@ export interface RoutePattern {
|
|
|
69
77
|
readonly pattern: string
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
function compile(pattern: string): { regex: RegExp; keys: string[] } {
|
|
73
|
-
const keys: string[] = []
|
|
74
|
-
const src = pattern
|
|
75
|
-
.split("/")
|
|
76
|
-
.map((seg) => {
|
|
77
|
-
if (seg.startsWith(":")) {
|
|
78
|
-
keys.push(seg.slice(1))
|
|
79
|
-
return "([^/]+)"
|
|
80
|
-
}
|
|
81
|
-
if (seg.startsWith("*")) {
|
|
82
|
-
// Catch-all (`*slug`, or bare `*` → key "*"): capture the rest of the path, slashes included.
|
|
83
|
-
// Always the final segment (the manifest/core enforce it), so the greedy `.` is safe.
|
|
84
|
-
keys.push(seg.length === 1 ? "*" : seg.slice(1))
|
|
85
|
-
return "(.+)"
|
|
86
|
-
}
|
|
87
|
-
// Escape regex-special chars. `$`/`{`/`}` are ordered so no literal `${` appears (it can
|
|
88
|
-
// trip tooling that scans for template-literal interpolation, e.g. coverage sourcemaps).
|
|
89
|
-
return seg.replace(/[.*+?^()|[\]\\{}$]/g, "\\$&")
|
|
90
|
-
})
|
|
91
|
-
.join("/")
|
|
92
|
-
return { regex: new RegExp(`^${src}/?$`), keys }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
80
|
/**
|
|
96
81
|
* Build a matcher from route patterns (built from the SAME manifest the server routes from, so
|
|
97
82
|
* client and server agree). Returns the first matching route + decoded params, or null. The
|
|
@@ -100,22 +85,20 @@ function compile(pattern: string): { regex: RegExp; keys: string[] } {
|
|
|
100
85
|
export function createMatcher(
|
|
101
86
|
patterns: readonly RoutePattern[],
|
|
102
87
|
): (path: string) => RouteMatch | null {
|
|
103
|
-
const compiled = patterns
|
|
88
|
+
const compiled = patterns
|
|
89
|
+
.map((pattern) => ({ routeId: pattern.routeId, pattern: compileRoutePattern(pattern.pattern) }))
|
|
90
|
+
.sort((left, right) => compareRoutePatternSpecificity(left.pattern, right.pattern))
|
|
104
91
|
return (path) => {
|
|
105
92
|
// Strip the query without allocating a `split("?")` array — matcher runs per match.
|
|
106
93
|
const q = path.indexOf("?")
|
|
107
94
|
const clean = q === -1 ? path : path.slice(0, q)
|
|
108
95
|
for (const c of compiled) {
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
let i = 0
|
|
114
|
-
for (const k of c.keys) {
|
|
115
|
-
params[k] = decodeURIComponent(m[i + 1] ?? "")
|
|
116
|
-
i++
|
|
96
|
+
const match = matchRoutePattern(c.pattern, clean)
|
|
97
|
+
if (!match.matched) {
|
|
98
|
+
if (match.reason === "malformed") return null
|
|
99
|
+
continue
|
|
117
100
|
}
|
|
118
|
-
return { routeId: c.routeId, params }
|
|
101
|
+
return { routeId: c.routeId, params: match.params }
|
|
119
102
|
}
|
|
120
103
|
return null
|
|
121
104
|
}
|
|
@@ -500,7 +483,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
500
483
|
navAbort?.abort() // abandon any in-flight navigation's stream
|
|
501
484
|
const ac = new AbortController()
|
|
502
485
|
navAbort = ac
|
|
503
|
-
state = { ...state, pending: true }
|
|
486
|
+
state = { ...state, pending: true, pendingPath: path }
|
|
504
487
|
emit()
|
|
505
488
|
try {
|
|
506
489
|
// Use prefetched data when present (one-shot — drop it); else fetch. The chunk is loaded
|
|
@@ -523,10 +506,10 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
523
506
|
}
|
|
524
507
|
emit()
|
|
525
508
|
} catch (err) {
|
|
526
|
-
// Clear our pending flag (only if still current) and rethrow — the caller decides how
|
|
527
|
-
// 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).
|
|
528
511
|
if (mine === token) {
|
|
529
|
-
state = { ...state, pending: false }
|
|
512
|
+
state = { ...state, pending: false, pendingPath: undefined }
|
|
530
513
|
emit()
|
|
531
514
|
}
|
|
532
515
|
throw err
|