@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/lib/{api-routes-Ci0kVmM4.js → api-routes-CMsLztoj.js} +5 -3
- package/lib/api-routes.js +4 -2
- package/lib/favicon.js +57 -3
- package/lib/{fs-router-MewHc5SB.js → fs-router-Bacdhsq-.js} +4 -3
- package/lib/image-plugin.js +90 -19
- package/lib/index.js +96 -3
- package/lib/rate-limit.js +5 -0
- package/lib/seo.js +11 -6
- package/lib/server.js +2888 -147
- package/lib/testing.js +4 -2
- package/lib/types/config.d.ts +9 -0
- package/lib/types/favicon.d.ts +17 -1
- package/lib/types/i18n-routing.d.ts +2 -4
- package/lib/types/image-plugin.d.ts +65 -7
- package/lib/types/index.d.ts +91 -7
- package/lib/types/link.d.ts +2 -4
- package/lib/types/server.d.ts +89 -5
- package/lib/types/theme.d.ts +1 -2
- package/package.json +10 -10
- package/src/api-routes.ts +12 -2
- package/src/favicon.ts +84 -2
- package/src/fs-router.ts +7 -1
- package/src/icon.tsx +182 -0
- package/src/icons-plugin.ts +296 -0
- package/src/image-plugin.ts +200 -32
- package/src/index.ts +2 -0
- package/src/isr.ts +54 -10
- package/src/manifest.ts +99 -0
- package/src/rate-limit.ts +16 -0
- package/src/seo.ts +19 -4
- package/src/server.ts +2 -0
- package/src/sharp.d.ts +6 -0
- package/src/ssg-plugin.ts +47 -8
- package/src/types.ts +9 -0
- package/lib/vite-plugin-y0NmCLJA.js +0 -2476
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|