@nifrajs/web 1.13.0 → 2.1.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 (48) 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 +3 -2
  9. package/dist/deferred.d.ts.map +1 -1
  10. package/dist/deferred.js +14 -8
  11. package/dist/deferred.js.map +1 -1
  12. package/dist/draft.d.ts +1 -1
  13. package/dist/draft.d.ts.map +1 -1
  14. package/dist/draft.js +1 -1
  15. package/dist/draft.js.map +1 -1
  16. package/dist/forms.d.ts +1 -1
  17. package/dist/forms.d.ts.map +1 -1
  18. package/dist/index.d.ts +11 -9
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +12 -22
  21. package/dist/index.js.map +1 -1
  22. package/dist/manifest.d.ts +2 -5
  23. package/dist/manifest.d.ts.map +1 -1
  24. package/dist/manifest.js +1 -6
  25. package/dist/manifest.js.map +1 -1
  26. package/dist/plugins/postcss.d.ts +50 -0
  27. package/dist/plugins/postcss.d.ts.map +1 -0
  28. package/dist/plugins/postcss.js +68 -0
  29. package/dist/plugins/postcss.js.map +1 -0
  30. package/dist/plugins/svg.d.ts +44 -0
  31. package/dist/plugins/svg.d.ts.map +1 -0
  32. package/dist/plugins/svg.js +110 -0
  33. package/dist/plugins/svg.js.map +1 -0
  34. package/dist/router.d.ts +3 -0
  35. package/dist/router.d.ts.map +1 -1
  36. package/dist/router.js +4 -4
  37. package/dist/router.js.map +1 -1
  38. package/package.json +13 -2
  39. package/src/build.ts +109 -41
  40. package/src/client.ts +2 -8
  41. package/src/deferred.ts +16 -6
  42. package/src/draft.ts +1 -1
  43. package/src/forms.ts +1 -1
  44. package/src/index.ts +18 -34
  45. package/src/manifest.ts +2 -8
  46. package/src/plugins/postcss.ts +129 -0
  47. package/src/plugins/svg.ts +156 -0
  48. package/src/router.ts +7 -4
@@ -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