@pyreon/zero 0.18.0 → 0.20.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/src/favicon.ts CHANGED
@@ -1,8 +1,43 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import type { Plugin } from 'vite'
5
5
 
6
+ /**
7
+ * Stable content hash (FNV-1a, 32-bit) of the favicon source file(s),
8
+ * rendered as a `?v=<hex>` cache-bust query for the injected `<head>`
9
+ * links. Browsers cache favicons extremely aggressively (often per-
10
+ * session / effectively forever), so with a stable URL a changed icon
11
+ * is never re-fetched by returning visitors. Same source bytes →
12
+ * identical query (no needless cache churn); changed bytes → new query
13
+ * → browser re-downloads. Falls back to `''` (no query, prior
14
+ * behaviour) if a source can't be read — never break the build over a
15
+ * cache-bust nicety. NOTE: this versions everything referenced via
16
+ * `<link>` (svg/png/apple-touch/manifest). The bare `/favicon.ico`
17
+ * convention request (browsers fetch it with no link tag) and the
18
+ * `site.webmanifest`'s internal icon entries keep stable URLs — those
19
+ * rely on host cache headers / are re-resolved on PWA (re)install.
20
+ */
21
+ export function faviconVersionQuery(paths: string[]): string {
22
+ let h = 0x811c9dc5
23
+ let any = false
24
+ for (const p of paths) {
25
+ let buf: Buffer
26
+ try {
27
+ buf = readFileSync(p)
28
+ } catch {
29
+ continue
30
+ }
31
+ any = true
32
+ for (let i = 0; i < buf.length; i++) {
33
+ h ^= buf[i]!
34
+ h = Math.imul(h, 0x01000193)
35
+ }
36
+ }
37
+ if (!any) return ''
38
+ return `?v=${(h >>> 0).toString(16).padStart(8, '0')}`
39
+ }
40
+
6
41
  let sharpWarned = false
7
42
  function warnSharpMissing() {
8
43
  if (sharpWarned) return
@@ -126,6 +161,17 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
126
161
 
127
162
  let root = ''
128
163
  let isBuild = false
164
+ // Lazily computed once per build/dev session (source rarely changes
165
+ // within a run; recomputing per index.html transform is wasteful).
166
+ let versionQuery: string | null = null
167
+ function getVersionQuery(): string {
168
+ if (versionQuery === null) {
169
+ const paths = [join(root, config.source)]
170
+ if (config.darkSource) paths.push(join(root, config.darkSource))
171
+ versionQuery = faviconVersionQuery(paths)
172
+ }
173
+ return versionQuery
174
+ }
129
175
 
130
176
  return {
131
177
  name: 'pyreon-zero-favicon',
@@ -156,7 +202,11 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
156
202
  }
157
203
 
158
204
  server.middlewares.use(async (req, res, next) => {
159
- const url = req.url ?? ''
205
+ // Strip the `?v=<hash>` cache-bust query (and any query) before
206
+ // matching — the injected links carry it; dev serves fresh
207
+ // (`Cache-Control: no-cache`) so the version is irrelevant here,
208
+ // but a query in the path would break every name match below.
209
+ const url = (req.url ?? '').split('?')[0]!
160
210
 
161
211
  // Resolve locale-specific source
162
212
  const localeSource = resolveLocaleSource(url, config, root)
@@ -316,12 +366,44 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
316
366
  } as any)
317
367
  }
318
368
 
369
+ // Cache-bust: stamp the source content hash onto every injected
370
+ // favicon/manifest link href so a changed icon is actually
371
+ // re-downloaded by returning visitors (theme-swap toggles `media`,
372
+ // not `href`, so this is orthogonal to the light/dark variants).
373
+ const v = getVersionQuery()
374
+ if (v) {
375
+ for (const t of tags) {
376
+ if (t.tag === 'link' && t.attrs.href) t.attrs.href += v
377
+ }
378
+ }
379
+
319
380
  return tags
320
381
  },
321
382
 
322
383
  async generateBundle() {
323
384
  if (!isBuild) return
324
385
 
386
+ // `faviconPlugin` is in the plugin list and a `source` is configured
387
+ // (it's a required field), so the user clearly WANTS favicons. If
388
+ // `sharp` is missing, the old behaviour was a single swallow-able
389
+ // `console.warn` + emit nothing — i.e. silently ship a production
390
+ // site with zero favicons. That's the footgun. Fail the build loudly
391
+ // with an actionable message instead. Dev keeps the soft warning
392
+ // (see `warnSharpMissing`) so local iteration isn't blocked.
393
+ try {
394
+ await import('sharp')
395
+ } catch {
396
+ this.error(
397
+ '[Pyreon] faviconPlugin: a favicon `source` is configured but ' +
398
+ '`sharp` is not installed — NO favicons would be generated and ' +
399
+ 'the production build would silently ship none.\n' +
400
+ ' Fix: bun add -D sharp (or: npm i -D sharp)\n' +
401
+ ` Source: ${config.source}\n` +
402
+ 'To intentionally build without favicons, remove faviconPlugin() ' +
403
+ 'from your Vite plugins.',
404
+ )
405
+ }
406
+
325
407
  // Generate favicons for the base (default) source
326
408
  await generateFaviconSet.call(this, root, config.source, config.darkSource, '', config, themeColor, backgroundColor, generateManifest)
327
409
 
package/src/fs-router.ts CHANGED
@@ -1100,7 +1100,13 @@ export function generateRouteModuleFromRoutes(
1100
1100
  const opts: string[] = []
1101
1101
  if (loadingName) opts.push(`loading: ${loadingName}`)
1102
1102
  if (errorName) opts.push(`error: ${errorName}`)
1103
- const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''
1103
+ // `hmrId` lets `@pyreon/router`'s dev HMR coordinator map a
1104
+ // hot-updated module back to its route record(s) for an in-place
1105
+ // component swap (no page reload, signals preserved). Inert in
1106
+ // production — the coordinator is only registered in a dev browser,
1107
+ // so `_hmrId` is dead metadata once built.
1108
+ opts.push(`hmrId: ${JSON.stringify(fullPath)}`)
1109
+ const optsStr = `, { ${opts.join(', ')} }`
1104
1110
  imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`)
1105
1111
  return name
1106
1112
  }
package/src/icon.tsx ADDED
@@ -0,0 +1,182 @@
1
+ import type { PyreonHTMLAttributes, SvgAttributes, VNodeChild } from '@pyreon/core'
2
+ import { splitProps } from '@pyreon/core'
3
+
4
+ // ─── Icon ───────────────────────────────────────────────────────────────────
5
+ //
6
+ // Renders a FULL, already-complete SVG that you loaded — it does NOT
7
+ // synthesize its own <svg> wrapper around hand-authored <path> children.
8
+ // You load an svg (it contains the <svg> root itself); Icon renders it and
9
+ // makes it container-sizable + theme-aware.
10
+ //
11
+ // Two ways to hand it the loaded svg (you chose: support both):
12
+ // • `as` — an imported SVG *component* (`import X from './x.svg?component'`).
13
+ // Rendered directly — NO host wrapper. Recommended form: it's a
14
+ // real <svg> element, so container-fill is reliable.
15
+ // • `svg` — the raw `<svg>…</svg>` *markup string*
16
+ // (`import x from './x.svg?raw'`). Inlined via a single `<span>`
17
+ // host (a markup string can't mount without a parent element —
18
+ // this one host is unavoidable for the string form).
19
+ //
20
+ // Either way:
21
+ // • Container-filling defaults (`fill="currentColor"`,
22
+ // `display:block;width:100%;height:100%`) — every consumer prop spreads
23
+ // through and OVERRIDES them (`style`, `class`, `fill`, `aria-*`, …).
24
+ // • No fixed size → it fills its container; the consumer's wrapper
25
+ // (`<span style="width:2rem"><Icon/></span>`, a flex/grid cell,
26
+ // `font-size`) controls the size.
27
+ // • `fill="currentColor"` → CSS `color` themes it (dark mode for free).
28
+ //
29
+ // Two layers (mirrors createLink/Link, createImage/Image):
30
+ // 1. createIcon(source) — factory: one component per loaded glyph
31
+ // 2. Icon — generic shell for a one-off loaded svg
32
+ //
33
+ // There is intentionally no `useIcon` — an icon has no composable behaviour
34
+ // (no async, no state, no router). A hook layer would be surface for its
35
+ // own sake.
36
+
37
+ const FILL_STYLE = 'display:block;width:100%;height:100%'
38
+
39
+ /** An imported SVG component (`import X from './x.svg?component'`). */
40
+ export type SvgComponent = (props: SvgAttributes) => VNodeChild
41
+
42
+ /**
43
+ * Props for {@link Icon}. The standard `<svg>` attribute surface
44
+ * (`fill`, `class`, `style`, `aria-*`, `onClick`, …) — every one passed
45
+ * straight through and overriding the container-fill defaults — plus the
46
+ * two source props.
47
+ */
48
+ export interface IconProps extends SvgAttributes {
49
+ /**
50
+ * An imported SVG component, e.g. `import X from './icon.svg?component'`.
51
+ * Rendered directly with no host wrapper. Recommended over `svg`.
52
+ */
53
+ as?: SvgComponent | undefined
54
+ /**
55
+ * A full `<svg>…</svg>` markup string, e.g.
56
+ * `import x from './icon.svg?raw'`. Inlined inside a single `<span>` host.
57
+ */
58
+ svg?: string | undefined
59
+ }
60
+
61
+ /**
62
+ * Render a loaded SVG — container-filling, theme-aware, props-transparent.
63
+ *
64
+ * @example
65
+ * import Check from './check.svg?component'
66
+ * <span style="width:2rem"><Icon as={Check} /></span>
67
+ *
68
+ * @example
69
+ * import check from './check.svg?raw'
70
+ * <span style="width:2rem"><Icon svg={check} /></span>
71
+ */
72
+ export function Icon(props: IconProps): VNodeChild {
73
+ const [own, rest] = splitProps(props, ['as', 'svg'])
74
+
75
+ // Component form — render the imported SVG directly, no host wrapper.
76
+ // Defaults first so consumer `rest` (spread) overrides them; JSX spread
77
+ // is reactivity-safe (compiler wraps it with `_wrapSpread`).
78
+ if (own.as) {
79
+ const As = own.as
80
+ return <As fill="currentColor" style={FILL_STYLE} {...rest} />
81
+ }
82
+
83
+ // Raw-markup form — the string already contains its own <svg>, so we
84
+ // inline it via a single <span> host. `dangerouslySetInnerHTML` last so
85
+ // it can't be clobbered by a stray spread key.
86
+ if (own.svg) {
87
+ // svg-only props (`fill`, `viewBox`, …) are inapplicable to the host
88
+ // span AND can't reach the opaque inlined markup — only host-level
89
+ // attrs (`class`, `style`, `aria-*`, events) are meaningfully
90
+ // forwardable here. Narrow the spread to the host's real surface.
91
+ const hostRest = rest as unknown as PyreonHTMLAttributes<HTMLElement>
92
+ return (
93
+ <span
94
+ style={FILL_STYLE}
95
+ {...hostRest}
96
+ dangerouslySetInnerHTML={{ __html: own.svg }}
97
+ />
98
+ )
99
+ }
100
+
101
+ return null
102
+ }
103
+
104
+ /**
105
+ * Build a reusable icon component from a loaded svg — a markup string OR an
106
+ * imported SVG component. The result is still just `<Icon>`, so it's
107
+ * container-sizable + theme-aware with every prop passed through.
108
+ *
109
+ * @example
110
+ * import check from './check.svg?raw'
111
+ * export const Check = createIcon(check)
112
+ *
113
+ * import StarSvg from './star.svg?component'
114
+ * export const Star = createIcon(StarSvg)
115
+ *
116
+ * // …sized + themed entirely by the consumer:
117
+ * <span style="width:48px"><Check class="text-green-600" /></span>
118
+ */
119
+ export function createIcon(
120
+ source: string | SvgComponent,
121
+ ): (props: SvgAttributes) => VNodeChild {
122
+ return (props: SvgAttributes) =>
123
+ typeof source === 'string' ? (
124
+ <Icon svg={source} {...props} />
125
+ ) : (
126
+ <Icon as={source} {...props} />
127
+ )
128
+ }
129
+
130
+ // ─── createNamedIcon — typed icon-set runtime ────────────────────────────────
131
+ //
132
+ // The runtime half of `iconsPlugin`. The plugin scans a folder and writes a
133
+ // generated file that calls this with a registry literal; `keyof typeof
134
+ // REGISTRY` makes `name` a strict union (autocompletes, rejects typos) and
135
+ // gives real go-to-definition — zero per-app wiring.
136
+
137
+ /** How a named icon set renders each entry. */
138
+ export type IconMode = 'inline' | 'image'
139
+
140
+ /** Props of a component built by {@link createNamedIcon}. */
141
+ export type NamedIconProps<R extends Record<string, string>> = {
142
+ /** A name from the scanned set — strictly typed to the available files. */
143
+ name: keyof R & string
144
+ /** `<img>` alt text (image mode). Defaults to `""` (decorative). */
145
+ alt?: string
146
+ } & Omit<IconProps, 'as' | 'svg'>
147
+
148
+ /**
149
+ * Build a strictly-typed `<Icon name="…" />` from a name→source registry.
150
+ *
151
+ * - `mode: 'inline'` (default) — `source` is raw `<svg>` markup; rendered via
152
+ * {@link Icon} so it's `currentColor`-themeable (system icons you recolor).
153
+ * - `mode: 'image'` — `source` is an asset URL; rendered as `<img>` with NO
154
+ * svg mutation, original colors preserved (colorful / brand icons).
155
+ *
156
+ * Either way it stays container-filling + props-transparent. Not called by
157
+ * hand normally — `iconsPlugin` emits the generated file that calls it.
158
+ *
159
+ * @example
160
+ * // icons.gen.tsx (auto-generated):
161
+ * export const Icon = createNamedIcon({ 'check-circle': '<svg…' })
162
+ * // app:
163
+ * <span style="width:2rem"><Icon name="check-circle" /></span>
164
+ */
165
+ export function createNamedIcon<R extends Record<string, string>>(
166
+ registry: R,
167
+ options: { mode?: IconMode } = {},
168
+ ): (props: NamedIconProps<R>) => VNodeChild {
169
+ const mode = options.mode ?? 'inline'
170
+ return (props: NamedIconProps<R>) => {
171
+ const [own, rest] = splitProps(props, ['name', 'alt'])
172
+ const source = registry[own.name]
173
+ if (mode === 'image') {
174
+ // svg-only props can't apply to an <img>; only host attrs forward.
175
+ const hostRest = rest as unknown as PyreonHTMLAttributes<HTMLImageElement>
176
+ return (
177
+ <img src={source} alt={own.alt ?? ''} style={FILL_STYLE} {...hostRest} />
178
+ )
179
+ }
180
+ return <Icon svg={source} {...rest} />
181
+ }
182
+ }
@@ -0,0 +1,296 @@
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { basename, dirname, join, relative } from 'node:path'
4
+ import type { Plugin } from 'vite'
5
+
6
+ import type { IconMode } from './icon'
7
+
8
+ // ─── iconsPlugin — folder → strictly-typed icon set ─────────────────────────
9
+ //
10
+ // Point it at a folder of `*.svg` files; it writes a generated `icons.gen.tsx`
11
+ // that exports a strictly-typed `<Icon name="…" />`. Add an svg → the `name`
12
+ // union widens; remove one → a now-invalid `name` fails typecheck. The
13
+ // generated file calls `createNamedIcon(REGISTRY)` so `keyof typeof REGISTRY`
14
+ // IS the type surface (autocomplete + real go-to-definition, zero per-app
15
+ // wiring — same one-touch shape as fs-router / islands auto-registry).
16
+ //
17
+ // Two render modes (per the colorful-vs-system split):
18
+ // • mode: 'inline' (default) — system icons. Each svg inlined as raw markup;
19
+ // `currentColor`-themeable, recolor via CSS `color`.
20
+ // • mode: 'image' — colorful / brand icons. Each svg emitted as a
21
+ // static asset, rendered `<img>`. NO mutation, original colors preserved.
22
+ //
23
+ // import { iconsPlugin } from '@pyreon/zero/server'
24
+ // iconsPlugin({ dir: './src/icons' }) // → src/icons.gen.tsx
25
+ // // app:
26
+ // import { Icon } from './icons.gen'
27
+ // <span style="width:2rem"><Icon name="check-circle" /></span>
28
+
29
+ /** One named set in the multi-set form. */
30
+ export interface IconSetConfig {
31
+ /** Folder of `*.svg` files to scan for this set. */
32
+ dir: string
33
+ /**
34
+ * `'inline'` (default — system icons, `currentColor`-themeable) or
35
+ * `'image'` (colorful / brand icons, rendered `<img>`, no mutation).
36
+ */
37
+ mode?: IconMode
38
+ }
39
+
40
+ export interface IconsPluginConfig {
41
+ /**
42
+ * Single-set form: a folder of `*.svg` files → one `<Icon name="…" />`
43
+ * with a single `IconName` union. Mutually exclusive with `sets`.
44
+ */
45
+ dir?: string
46
+ /**
47
+ * Named multi-set form: `{ ui: { dir }, brand: { dir, mode } }` → one
48
+ * generated file exporting a strictly-typed component PER set with
49
+ * NAMESPACED types so they never clash:
50
+ * `ui` → `<UiIcon name="…" />` + `type UiIconName`
51
+ * `brand` → `<BrandIcon name="…" />` + `type BrandIconName`
52
+ * Mutually exclusive with `dir`.
53
+ */
54
+ sets?: Record<string, IconSetConfig>
55
+ /**
56
+ * Where to write the generated `.tsx`. Single-set default: `icons.gen.tsx`
57
+ * next to `dir` (e.g. `src/icons` → `src/icons.gen.tsx`). Multi-set
58
+ * default: `src/icons.gen.tsx` under the project root. Recommend
59
+ * gitignoring it — it's a build artifact.
60
+ */
61
+ out?: string
62
+ /** Single-set form only — render mode (`'inline'` default | `'image'`). */
63
+ mode?: IconMode
64
+ }
65
+
66
+ /** Set key → exported component name. `ui` → `UiIcon`, `brand-marks` → `BrandMarksIcon`. */
67
+ export function componentNameFromSetKey(key: string): string {
68
+ const pascal = key
69
+ .split(/[-_/\s]+/)
70
+ .filter(Boolean)
71
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
72
+ .join('')
73
+ const safe = pascal.replace(/[^A-Za-z0-9_$]/g, '')
74
+ const base = /^[A-Za-z_$]/.test(safe) ? safe : `Set${safe}`
75
+ return `${base}Icon`
76
+ }
77
+
78
+ /** Filename stem → registry key. `Check-Circle.svg` → `check-circle`. */
79
+ export function iconNameFromFile(file: string): string {
80
+ return basename(file, '.svg')
81
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
82
+ .replace(/[\s_]+/g, '-')
83
+ .toLowerCase()
84
+ }
85
+
86
+ /** Registry key → safe JS import binding. `check-circle` → `checkCircle`. */
87
+ function bindingFromName(name: string): string {
88
+ const camel = name.replace(/[-/](.)/g, (_, c: string) => c.toUpperCase())
89
+ const safe = camel.replace(/[^A-Za-z0-9_$]/g, '_')
90
+ return /^[A-Za-z_$]/.test(safe) ? safe : `_${safe}`
91
+ }
92
+
93
+ /** List the `*.svg` filenames in `dir` (sorted, stable). Empty if missing. */
94
+ export function scanIconDir(dir: string): string[] {
95
+ if (!existsSync(dir)) return []
96
+ return readdirSync(dir)
97
+ .filter((f) => f.toLowerCase().endsWith('.svg'))
98
+ .sort()
99
+ }
100
+
101
+ /**
102
+ * Render the generated `.tsx` source for a set of svg filenames. Pure —
103
+ * unit-tested directly; the plugin only adds fs + watch around it.
104
+ */
105
+ export function generateIconSetSource(
106
+ files: string[],
107
+ opts: { mode: IconMode; importDir: string },
108
+ ): string {
109
+ const query = opts.mode === 'image' ? '' : '?raw'
110
+ const seen = new Map<string, string>() // binding → name (collision guard)
111
+ const entries: { key: string; binding: string; file: string }[] = []
112
+ for (const file of files) {
113
+ const key = iconNameFromFile(file)
114
+ let binding = bindingFromName(key)
115
+ while (seen.has(binding)) binding = `${binding}_`
116
+ seen.set(binding, key)
117
+ entries.push({ key, binding, file })
118
+ }
119
+
120
+ const header = [
121
+ '// AUTO-GENERATED by @pyreon/zero iconsPlugin — do not edit.',
122
+ `// Add / remove .svg files in ${opts.importDir} and this regenerates.`,
123
+ '/// <reference types="vite/client" />',
124
+ "import { createNamedIcon } from '@pyreon/zero'",
125
+ ]
126
+ const imports = entries.map(
127
+ (e) => `import ${e.binding} from '${opts.importDir}/${e.file}${query}'`,
128
+ )
129
+ const registry = [
130
+ 'const REGISTRY = {',
131
+ ...entries.map((e) => ` ${JSON.stringify(e.key)}: ${e.binding},`),
132
+ '} as const',
133
+ ]
134
+ const tail = [
135
+ 'export type IconName = keyof typeof REGISTRY',
136
+ `export const Icon = createNamedIcon(REGISTRY${
137
+ opts.mode === 'image' ? ", { mode: 'image' }" : ''
138
+ })`,
139
+ '',
140
+ ]
141
+ return [...header, '', ...imports, '', ...registry, '', ...tail].join('\n')
142
+ }
143
+
144
+ /** One resolved set for the multi-set generator. */
145
+ export interface NamedSetInput {
146
+ /** Set key (`ui`) — becomes `<UiIcon>` + `type UiIconName`. */
147
+ key: string
148
+ files: string[]
149
+ mode: IconMode
150
+ /** Relative import dir from the generated file to this set's folder. */
151
+ importDir: string
152
+ }
153
+
154
+ /**
155
+ * Render the generated `.tsx` for the NAMED MULTI-SET form. One file, one
156
+ * `createNamedIcon` import, one strictly-typed component PER set with
157
+ * namespaced types (`UiIcon`/`UiIconName`, `BrandIcon`/`BrandIconName`) so
158
+ * sets never clash. Bindings are per-set-prefixed so two sets sharing a
159
+ * glyph filename don't collide.
160
+ */
161
+ export function generateNamedIconSetsSource(sets: NamedSetInput[]): string {
162
+ const header = [
163
+ '// AUTO-GENERATED by @pyreon/zero iconsPlugin — do not edit.',
164
+ '// Add / remove .svg files in the configured set folders and this regenerates.',
165
+ '/// <reference types="vite/client" />',
166
+ "import { createNamedIcon } from '@pyreon/zero'",
167
+ ]
168
+ const blocks: string[] = []
169
+ for (const set of sets) {
170
+ const component = componentNameFromSetKey(set.key)
171
+ const typeName = `${component}Name`
172
+ const registry = `${component}_REGISTRY`
173
+ const query = set.mode === 'image' ? '' : '?raw'
174
+ const seen = new Set<string>()
175
+ const entries: { key: string; binding: string; file: string }[] = []
176
+ for (const file of set.files) {
177
+ const k = iconNameFromFile(file)
178
+ // Per-set binding prefix → no cross-set collision even on shared names.
179
+ let binding = `${bindingFromName(set.key)}_${bindingFromName(k)}`
180
+ while (seen.has(binding)) binding = `${binding}_`
181
+ seen.add(binding)
182
+ entries.push({ key: k, binding, file })
183
+ }
184
+ const imports = entries.map(
185
+ (e) => `import ${e.binding} from '${set.importDir}/${e.file}${query}'`,
186
+ )
187
+ blocks.push(
188
+ [
189
+ `// ── set "${set.key}" → <${component} name="…" /> ──`,
190
+ ...imports,
191
+ `const ${registry} = {`,
192
+ ...entries.map((e) => ` ${JSON.stringify(e.key)}: ${e.binding},`),
193
+ '} as const',
194
+ `export type ${typeName} = keyof typeof ${registry}`,
195
+ `export const ${component} = createNamedIcon(${registry}${
196
+ set.mode === 'image' ? ", { mode: 'image' }" : ''
197
+ })`,
198
+ ].join('\n'),
199
+ )
200
+ }
201
+ return [...header, '', blocks.join('\n\n'), ''].join('\n')
202
+ }
203
+
204
+ function resolveOut(cfg: IconsPluginConfig, root: string): string {
205
+ if (cfg.out) return join(root, cfg.out)
206
+ if (cfg.dir) {
207
+ const dir = join(root, cfg.dir)
208
+ return join(dirname(dir), `${basename(dir)}.gen.tsx`)
209
+ }
210
+ // Multi-set form with no explicit `out`.
211
+ return join(root, 'src', 'icons.gen.tsx')
212
+ }
213
+
214
+ /**
215
+ * Vite plugin: scan `dir` for `*.svg`, write a strictly-typed
216
+ * `icons.gen.tsx`, regenerate on add / unlink in dev.
217
+ */
218
+ export function iconsPlugin(cfg: IconsPluginConfig): Plugin {
219
+ const hasDir = typeof cfg.dir === 'string'
220
+ const hasSets = !!cfg.sets && Object.keys(cfg.sets).length > 0
221
+ if (hasDir === hasSets) {
222
+ throw new Error(
223
+ '[Pyreon] iconsPlugin: provide EXACTLY ONE of `dir` (single set) or ' +
224
+ '`sets` (named multi-set). ' +
225
+ (hasDir
226
+ ? 'Both were given.'
227
+ : 'Neither was given (or `sets` is empty).'),
228
+ )
229
+ }
230
+ let root = process.cwd()
231
+ const mode: IconMode = cfg.mode ?? 'inline'
232
+
233
+ /** Relative `./…` import dir from the generated file to a scanned folder. */
234
+ function rel(out: string, scanned: string): string {
235
+ const r = relative(dirname(out), scanned).split('\\').join('/')
236
+ return r.startsWith('.') ? r : `./${r}`
237
+ }
238
+
239
+ async function regenerate(): Promise<void> {
240
+ const out = resolveOut(cfg, root)
241
+ let source: string
242
+ if (hasSets) {
243
+ const sets: NamedSetInput[] = Object.entries(cfg.sets ?? {}).map(
244
+ ([key, sc]) => {
245
+ const scanned = join(root, sc.dir)
246
+ return {
247
+ key,
248
+ files: scanIconDir(scanned),
249
+ mode: sc.mode ?? 'inline',
250
+ importDir: rel(out, scanned),
251
+ }
252
+ },
253
+ )
254
+ source = generateNamedIconSetsSource(sets)
255
+ } else {
256
+ const scanned = join(root, cfg.dir as string)
257
+ source = generateIconSetSource(scanIconDir(scanned), {
258
+ mode,
259
+ importDir: rel(out, scanned),
260
+ })
261
+ }
262
+ // Idempotent — never rewrite identical content (avoids an HMR loop).
263
+ const current = existsSync(out) ? await readFile(out, 'utf8') : null
264
+ if (current !== source) await writeFile(out, source, 'utf8')
265
+ }
266
+
267
+ const watchDirs = (): string[] =>
268
+ hasSets
269
+ ? Object.values(cfg.sets ?? {}).map((s) => join(root, s.dir))
270
+ : [join(root, cfg.dir as string)]
271
+
272
+ return {
273
+ name: 'pyreon:zero-icons',
274
+ async configResolved(resolved) {
275
+ root = resolved.root
276
+ await regenerate()
277
+ },
278
+ async buildStart() {
279
+ await regenerate()
280
+ },
281
+ configureServer(server) {
282
+ const dirs = watchDirs()
283
+ for (const d of dirs) server.watcher.add(d)
284
+ const onChange = (file: string): void => {
285
+ if (
286
+ file.toLowerCase().endsWith('.svg') &&
287
+ dirs.some((d) => file.startsWith(d))
288
+ ) {
289
+ void regenerate()
290
+ }
291
+ }
292
+ server.watcher.on('add', onChange)
293
+ server.watcher.on('unlink', onChange)
294
+ },
295
+ }
296
+ }