@pyreon/zero 0.18.0 → 0.19.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.
@@ -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
+ }
@@ -58,8 +58,36 @@ export const cdnProviders = {
58
58
  `https://${pullZone}.b-cdn.net/${src}?width=${width}&quality=${quality}`,
59
59
  } as const
60
60
 
61
- /** Placeholder generation strategy. */
62
- export type PlaceholderStrategy = 'blur' | 'dominant-color' | 'none'
61
+ /**
62
+ * Placeholder generation strategy.
63
+ *
64
+ * - `'blur'` — tiny downscaled + blurred WebP data URI (a few hundred bytes).
65
+ * The richest preview; faithfully previews the image's content.
66
+ * - `'color'` — the image's dominant colour as a ~200-byte flat SVG data
67
+ * URI. Constant size regardless of source complexity (a blurred WebP
68
+ * grows with image content; this doesn't), zero decode, instant paint,
69
+ * zero layout shift. For real photos it's far smaller than `'blur'`; for
70
+ * trivial/solid sources `'blur'` can be the smaller of the two. Best when
71
+ * you want a clean solid backdrop rather than a blurry preview.
72
+ * - `'none'` — no placeholder (`placeholder: ''`). Skips all placeholder work.
73
+ *
74
+ * `'dominant-color'` is a deprecated alias of `'color'` — it was typed from
75
+ * the plugin's inception but never implemented (the build + dev paths always
76
+ * fell through to blur). It now resolves to `'color'`; prefer the shorter
77
+ * name in new code.
78
+ */
79
+ export type PlaceholderStrategy = 'blur' | 'color' | 'dominant-color' | 'none'
80
+
81
+ /** Quality per output format (1-100), or a single number applied to all. */
82
+ export type ImageQuality = number | Partial<Record<ImageFormat, number>>
83
+
84
+ /**
85
+ * Normalize the public {@link PlaceholderStrategy} to an internal kind.
86
+ * @internal Exported for testing.
87
+ */
88
+ export function normalizePlaceholder(s: PlaceholderStrategy): 'blur' | 'color' | 'none' {
89
+ return s === 'dominant-color' ? 'color' : s
90
+ }
63
91
 
64
92
  /** SVG processing options for ?component imports. */
65
93
  export interface SvgOptions {
@@ -76,11 +104,23 @@ export interface ImagePluginConfig {
76
104
  widths?: number[]
77
105
  /** Output formats. Default: ["webp"] */
78
106
  formats?: ImageFormat[]
79
- /** Quality for lossy formats (1-100). Default: 80 */
80
- quality?: number
81
- /** Blur placeholder size in px. Default: 16 */
107
+ /**
108
+ * Quality for lossy formats (1-100). Default: 80.
109
+ *
110
+ * Accepts a single number applied to every format, OR a per-format map so
111
+ * you can tune each codec independently — AVIF tolerates a much lower
112
+ * number than WebP/JPEG for the same perceived quality:
113
+ *
114
+ * ```ts
115
+ * imagePlugin({ formats: ['avif', 'webp'], quality: { avif: 55, webp: 75 } })
116
+ * ```
117
+ *
118
+ * Formats omitted from the map fall back to 80.
119
+ */
120
+ quality?: ImageQuality
121
+ /** Blur placeholder size in px (only used by the `'blur'` strategy). Default: 16 */
82
122
  placeholderSize?: number
83
- /** Placeholder strategy. Default: "blur" */
123
+ /** Placeholder strategy. Default: `"blur"`. See {@link PlaceholderStrategy}. */
84
124
  placeholder?: PlaceholderStrategy
85
125
  /** File patterns to process. Default: /\.(jpe?g|png|webp|avif)$/i */
86
126
  include?: RegExp
@@ -163,9 +203,9 @@ const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i
163
203
  export function imagePlugin(config: ImagePluginConfig = {}): Plugin {
164
204
  const defaultWidths = config.widths ?? [640, 1024, 1920]
165
205
  const defaultFormats = config.formats ?? ['webp']
166
- const quality = config.quality ?? 80
206
+ const qualityFor = resolveQuality(config.quality)
167
207
  const placeholderSize = config.placeholderSize ?? 16
168
- const placeholderStrategy = config.placeholder ?? 'blur'
208
+ const placeholderStrategy = normalizePlaceholder(config.placeholder ?? 'blur')
169
209
  const outSubDir = config.outDir ?? 'assets/img'
170
210
  const include = config.include ?? IMAGE_EXT_RE
171
211
  const cdn = config.cdn
@@ -253,7 +293,12 @@ export default function SvgComponent(props) {
253
293
  if (cdn) {
254
294
  const metadata = await getImageMetadata(absPath)
255
295
  const sources = defaultWidths.map((w) => ({
256
- src: cdn(rawPath, { width: w, quality, format: defaultFormats[0]! }) ?? rawPath,
296
+ src:
297
+ cdn(rawPath, {
298
+ width: w,
299
+ quality: qualityFor(defaultFormats[0]!),
300
+ format: defaultFormats[0]!,
301
+ }) ?? rawPath,
257
302
  width: w,
258
303
  format: defaultFormats[0]! as string,
259
304
  }))
@@ -263,12 +308,14 @@ export default function SvgComponent(props) {
263
308
  srcset,
264
309
  width: metadata.width,
265
310
  height: metadata.height,
266
- placeholder: placeholderStrategy === 'none' ? ''
267
- : await generateBlurPlaceholder(absPath, placeholderSize),
311
+ placeholder: await generatePlaceholder(absPath, placeholderStrategy, placeholderSize),
268
312
  formats: defaultFormats.map((fmt) => ({
269
313
  type: `image/${fmt}`,
270
314
  srcset: defaultWidths
271
- .map((w) => `${cdn(rawPath, { width: w, quality, format: fmt }) ?? rawPath} ${w}w`)
315
+ .map(
316
+ (w) =>
317
+ `${cdn(rawPath, { width: w, quality: qualityFor(fmt), format: fmt }) ?? rawPath} ${w}w`,
318
+ )
272
319
  .join(', '),
273
320
  })),
274
321
  sources,
@@ -277,14 +324,20 @@ export default function SvgComponent(props) {
277
324
  }
278
325
 
279
326
  if (!isBuild) {
280
- const result = await loadDevImage(absPath, rawPath, placeholderSize)
327
+ const result = await loadDevImage(
328
+ absPath,
329
+ rawPath,
330
+ placeholderStrategy,
331
+ placeholderSize,
332
+ )
281
333
  return `export default ${JSON.stringify(result)}`
282
334
  }
283
335
 
284
336
  const processed = await processImage(absPath, {
285
337
  widths: defaultWidths,
286
338
  formats: defaultFormats,
287
- quality,
339
+ qualityFor,
340
+ placeholderStrategy,
288
341
  placeholderSize,
289
342
  outSubDir,
290
343
  outDir: join(root, outDir),
@@ -301,6 +354,7 @@ export default function SvgComponent(props) {
301
354
  async function loadDevImage(
302
355
  absPath: string,
303
356
  rawPath: string,
357
+ strategy: 'blur' | 'color' | 'none',
304
358
  placeholderSize: number,
305
359
  ): Promise<ProcessedImage> {
306
360
  const metadata = await getImageMetadata(absPath)
@@ -311,7 +365,7 @@ async function loadDevImage(
311
365
  srcset: '',
312
366
  width: metadata.width,
313
367
  height: metadata.height,
314
- placeholder: await generateBlurPlaceholder(absPath, placeholderSize),
368
+ placeholder: await generatePlaceholder(absPath, strategy, placeholderSize),
315
369
  formats: [],
316
370
  sources: [{ src: publicPath, width: metadata.width, format: 'original' }],
317
371
  }
@@ -357,7 +411,8 @@ function rebuildFormatSrcsets(processed: ProcessedImage, fallbackPath: string) {
357
411
  interface ProcessOptions {
358
412
  widths: number[]
359
413
  formats: ImageFormat[]
360
- quality: number
414
+ qualityFor: (format: ImageFormat) => number
415
+ placeholderStrategy: 'blur' | 'color' | 'none'
361
416
  placeholderSize: number
362
417
  outSubDir: string
363
418
  outDir: string
@@ -383,7 +438,7 @@ async function processImage(absPath: string, opts: ProcessOptions): Promise<Proc
383
438
  const outName = `${name}-${width}.${format}`
384
439
  const outPath = join(processedDir, outName)
385
440
 
386
- await resizeImage(absPath, outPath, width, format, opts.quality)
441
+ await resizeImage(absPath, outPath, width, format, opts.qualityFor(format))
387
442
  sources.push({ src: outPath, width, format })
388
443
  }
389
444
  }
@@ -408,8 +463,14 @@ async function processImage(absPath: string, opts: ProcessOptions): Promise<Proc
408
463
  const fallbackFormat = formats[formats.length - 1]
409
464
  const fallbackSources = formatGroups.get([...formatGroups.keys()].pop()!)!
410
465
 
411
- // Generate blur placeholder
412
- const placeholder = await generateBlurPlaceholder(absPath, opts.placeholderSize)
466
+ // Generate the placeholder per the configured strategy. Pre-fix this
467
+ // hard-coded `generateBlurPlaceholder`, so `placeholder: 'none'` was
468
+ // ignored in build mode and `'dominant-color'` never resolved anywhere.
469
+ const placeholder = await generatePlaceholder(
470
+ absPath,
471
+ opts.placeholderStrategy,
472
+ opts.placeholderSize,
473
+ )
413
474
 
414
475
  return {
415
476
  src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,
@@ -564,6 +625,82 @@ async function generateBlurPlaceholder(input: string, size: number): Promise<str
564
625
  return `data:image/webp;base64,${buffer.toString('base64')}`
565
626
  } catch {
566
627
  // sharp not available — return a transparent placeholder
567
- return "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E"
628
+ return TRANSPARENT_PLACEHOLDER
629
+ }
630
+ }
631
+
632
+ /** 1×1 transparent SVG — the no-sharp fallback for every strategy. */
633
+ const TRANSPARENT_PLACEHOLDER =
634
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E"
635
+
636
+ const DEFAULT_QUALITY = 80
637
+
638
+ /**
639
+ * Resolve the public {@link ImageQuality} config into a per-format lookup.
640
+ *
641
+ * - `undefined` → every format gets {@link DEFAULT_QUALITY}.
642
+ * - `number` → that number for every format (backward-compatible).
643
+ * - `Partial<Record<ImageFormat, number>>` → per-format; formats omitted
644
+ * from the map fall back to {@link DEFAULT_QUALITY}.
645
+ *
646
+ * @internal Exported for testing.
647
+ */
648
+ export function resolveQuality(
649
+ q: ImageQuality | undefined,
650
+ ): (format: ImageFormat) => number {
651
+ if (q === undefined) return () => DEFAULT_QUALITY
652
+ if (typeof q === 'number') return () => q
653
+ return (format) => q[format] ?? DEFAULT_QUALITY
654
+ }
655
+
656
+ /**
657
+ * Dispatch placeholder generation by strategy. Single source of truth used
658
+ * by every code path (CDN / dev / build) — pre-fix each path open-coded
659
+ * `generateBlurPlaceholder`, so `'none'` was honoured only in the CDN path
660
+ * and `'dominant-color'` (typed since the plugin's inception) was never
661
+ * implemented anywhere — the exact typed-but-unimplemented bug class the
662
+ * `audit-types` gate exists to catch.
663
+ *
664
+ * @internal Exported for testing.
665
+ */
666
+ export async function generatePlaceholder(
667
+ input: string,
668
+ strategy: 'blur' | 'color' | 'none',
669
+ size: number,
670
+ ): Promise<string> {
671
+ if (strategy === 'none') return ''
672
+ if (strategy === 'color') return generateColorPlaceholder(input)
673
+ return generateBlurPlaceholder(input, size)
674
+ }
675
+
676
+ /**
677
+ * Generate a dominant-colour placeholder: a ~200-byte flat-fill SVG data URI.
678
+ *
679
+ * Uses sharp's `.stats()` `dominant` swatch — a histogram-binned colour,
680
+ * not a naive average (averaging a photo trends muddy grey). Note the
681
+ * swatch is approximate by design: a pure-red source resolves to ~#f80808,
682
+ * not #ff0000. The SVG is a constant ~200 bytes regardless of source
683
+ * complexity and needs zero image decode, at the cost of showing a solid
684
+ * colour instead of a blurry preview of the content.
685
+ */
686
+ async function generateColorPlaceholder(input: string): Promise<string> {
687
+ try {
688
+ const sharp = await import('sharp').then((m) => m.default ?? m)
689
+ const { dominant } = await sharp(input).stats()
690
+ const hex =
691
+ '#' +
692
+ [dominant.r, dominant.g, dominant.b]
693
+ .map((c) => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0'))
694
+ .join('')
695
+ // Inline SVG with the colour as a single rect — URL-encoded so it needs
696
+ // no base64 inflation. preserveAspectRatio + viewBox let it scale to any
697
+ // container the way an <img> placeholder is expected to.
698
+ const svg =
699
+ `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1' preserveAspectRatio='none'>` +
700
+ `<rect width='1' height='1' fill='${hex}'/></svg>`
701
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`
702
+ } catch {
703
+ // sharp not available — transparent fallback (same as the blur path).
704
+ return TRANSPARENT_PLACEHOLDER
568
705
  }
569
706
  }
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@
13
13
 
14
14
  // ─── Components (browser-safe) ──────────────────────────────────────────────
15
15
 
16
+ export type { IconMode, IconProps, NamedIconProps, SvgComponent } from "./icon";
17
+ export { createIcon, createNamedIcon, Icon } from "./icon";
16
18
  export type { ImageProps, ImageRenderProps, ImageSource, UseImageReturn } from "./image";
17
19
  export { createImage, Image, useImage } from "./image";
18
20
  export type { LinkProps, LinkRenderProps, UseLinkReturn } from "./link";
package/src/isr.ts CHANGED
@@ -28,6 +28,25 @@ export function createISRHandler(
28
28
  const cache = new Map<string, CacheEntry>()
29
29
  const revalidating = new Set<string>()
30
30
  const revalidateMs = config.revalidate * 1000
31
+ // Bounded background-revalidation timeout. Without it, a handler that
32
+ // hangs forever leaves its key permanently in `revalidating` (the
33
+ // `finally` that clears it never runs), so EVERY later request for
34
+ // that key short-circuits the `revalidating.has(key)` guard and the
35
+ // entry stays stale for the rest of the process lifetime — it can
36
+ // never recover. 30s default matches the Suspense streaming timeout;
37
+ // overridable via ISRConfig.revalidateTimeoutMs.
38
+ const REVALIDATE_TIMEOUT_MS = Math.max(1, config.revalidateTimeoutMs ?? 30_000)
39
+
40
+ // Only 2xx, cookie-free responses may be cached. Caching a transient
41
+ // 5xx/3xx/404 and replaying it as a 200 for the whole revalidate
42
+ // window is a self-inflicted outage / cache-poisoning bug. Caching a
43
+ // `Set-Cookie` response and replaying it to every visitor leaks one
44
+ // user's session/CSRF cookie cross-user — not covered by the
45
+ // documented "ISR-without-cacheKey is for non-personalized pages"
46
+ // caveat (that caveat is about key variance, not header stripping).
47
+ function isCacheable(res: Response): boolean {
48
+ return res.status >= 200 && res.status < 300 && !res.headers.has('set-cookie')
49
+ }
31
50
  const maxEntries = Math.max(1, config.maxEntries ?? 1000)
32
51
  // M1.1 — cache-key derivation. Default keys by pathname only (the
33
52
  // pre-M1 behaviour). User-supplied `cacheKey` opts in to varying
@@ -76,16 +95,29 @@ export function createISRHandler(
76
95
  method: 'GET',
77
96
  headers: originalReq.headers,
78
97
  })
79
- const res = await handler(req)
80
- const html = await res.text()
81
- const headers: Record<string, string> = {}
82
- res.headers.forEach((v, k) => {
83
- headers[k] = v
84
- })
85
-
86
- set(key, { html, headers, timestamp: Date.now() })
98
+ // Bound the revalidation so a hung handler can't pin `key` in
99
+ // `revalidating` forever (which would freeze the entry stale).
100
+ const res = await Promise.race([
101
+ handler(req),
102
+ new Promise<never>((_, reject) =>
103
+ setTimeout(
104
+ () => reject(new Error('[Pyreon ISR] revalidation timeout')),
105
+ REVALIDATE_TIMEOUT_MS,
106
+ ),
107
+ ),
108
+ ])
109
+ // Never overwrite a good stale entry with a bad re-render
110
+ // (5xx/3xx) or poison it with a Set-Cookie response.
111
+ if (isCacheable(res)) {
112
+ const html = await res.text()
113
+ const headers: Record<string, string> = {}
114
+ res.headers.forEach((v, k) => {
115
+ headers[k] = v
116
+ })
117
+ set(key, { html, headers, timestamp: Date.now() })
118
+ }
87
119
  } catch {
88
- // Revalidation failed — stale cache entry remains valid
120
+ // Revalidation failed / timed out — stale cache entry remains valid
89
121
  } finally {
90
122
  revalidating.delete(key)
91
123
  }
@@ -123,7 +155,11 @@ export function createISRHandler(
123
155
  })
124
156
  }
125
157
 
126
- // Cache miss — render, cache, and return
158
+ // Cache miss — render. Only cache (and only normalize to a 200
159
+ // text/html response) when the render is actually cacheable; a
160
+ // transient error / redirect / Set-Cookie response is passed
161
+ // through verbatim with its ORIGINAL status + headers and is NOT
162
+ // stored, so it can't be replayed as a 200 to later visitors.
127
163
  const res = await handler(req)
128
164
  const html = await res.text()
129
165
  const headers: Record<string, string> = {}
@@ -131,6 +167,14 @@ export function createISRHandler(
131
167
  headers[k] = v
132
168
  })
133
169
 
170
+ if (!isCacheable(res)) {
171
+ return new Response(html, {
172
+ status: res.status,
173
+ statusText: res.statusText,
174
+ headers: { ...headers, 'x-isr-cache': 'BYPASS' },
175
+ })
176
+ }
177
+
134
178
  set(key, { html, headers, timestamp: Date.now() })
135
179
 
136
180
  return new Response(html, {