@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.
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 +13 -36
  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 +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.map((p) => ({ routeId: p.routeId, ...compile(p.pattern) }))
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 m = c.regex.exec(clean)
110
- if (m === null) continue
111
- const params: Record<string, string> = {}
112
- // for-of (no per-match `forEach` closure); `i` indexes the capture groups (`m[0]` is the match).
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 to
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