@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.
@@ -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
@@ -189,23 +229,45 @@ export function imagePlugin(config: ImagePluginConfig = {}): Plugin {
189
229
  isBuild = resolvedConfig.command === 'build'
190
230
  },
191
231
 
192
- async resolveId(id) {
193
- // SVG as component: import Logo from './logo.svg?component'
194
- if (svgOpts && id.includes('?component') && id.split('?')[0]!.endsWith('.svg')) {
195
- return `\0virtual:zero-svg:${id}`
196
- }
197
- // Handle ?optimize query on image imports
198
- if (id.includes('?optimize') && include.test(id.split('?')[0]!)) {
199
- return `\0virtual:zero-image:${id}`
200
- }
201
- return null
232
+ async resolveId(id, importer) {
233
+ const isSvgComponent =
234
+ svgOpts && id.includes('?component') && id.split('?')[0]!.endsWith('.svg')
235
+ const isOptimize =
236
+ id.includes('?optimize') && include.test(id.split('?')[0]!)
237
+ if (!isSvgComponent && !isOptimize) return null
238
+
239
+ // Resolve the bare specifier to an ABSOLUTE fs path the way Vite
240
+ // resolves `?url` — importer-relative + alias-aware. The old code
241
+ // embedded the raw unresolved id, so `load()` had to guess: a
242
+ // relative `./img.png?optimize` resolved against cwd (≠ the
243
+ // importer's dir → ENOENT), and an aliased `~/x.png?optimize`
244
+ // arrived already-absolute and got `join(root,'public',…)`-doubled.
245
+ // `this.resolve` (skipSelf so we don't recurse into our own
246
+ // resolveId) handles relative + aliases + extensions. A public-dir
247
+ // web path (`/foo.png?optimize`) doesn't resolve to a module →
248
+ // null → keep the original id so load()'s public/ fallback applies.
249
+ const qIdx = id.indexOf('?')
250
+ const bare = qIdx === -1 ? id : id.slice(0, qIdx)
251
+ const query = qIdx === -1 ? '' : id.slice(qIdx)
252
+ const resolved = await this.resolve(bare, importer, { skipSelf: true })
253
+ const carried = resolved ? `${resolved.id}${query}` : id
254
+
255
+ if (isSvgComponent) return `\0virtual:zero-svg:${carried}`
256
+ return `\0virtual:zero-image:${carried}`
202
257
  },
203
258
 
204
259
  async load(id) {
205
260
  // SVG component loading
206
261
  if (id.startsWith('\0virtual:zero-svg:')) {
207
262
  const rawPath = id.replace('\0virtual:zero-svg:', '').split('?')[0] ?? id
208
- const absPath = rawPath.startsWith('/') ? join(root, rawPath) : rawPath
263
+ // resolveId now carries an absolute fs path for relative/aliased
264
+ // imports → trust it if it exists. Only a public-dir web path
265
+ // (`/logo.svg`, unresolved) falls back to root-join.
266
+ const absPath = existsSync(rawPath)
267
+ ? rawPath
268
+ : rawPath.startsWith('/')
269
+ ? join(root, rawPath)
270
+ : rawPath
209
271
  if (!existsSync(absPath)) return null
210
272
 
211
273
  let svg = await readFile(absPath, 'utf-8')
@@ -247,13 +309,27 @@ export default function SvgComponent(props) {
247
309
  if (!id.startsWith('\0virtual:zero-image:')) return null
248
310
 
249
311
  const rawPath = id.replace('\0virtual:zero-image:', '').split('?')[0] ?? id
250
- const absPath = rawPath.startsWith('/') ? join(root, 'public', rawPath) : rawPath
312
+ // resolveId now carries an absolute fs path for relative/aliased
313
+ // imports (the `./img.png?optimize` and `~/img.png?optimize` cases
314
+ // that used to ENOENT / double-`public`). Trust an existing
315
+ // absolute path; only an unresolved public-dir web path
316
+ // (`/foo.png?optimize`) falls back to `<root>/public/…`.
317
+ const absPath = existsSync(rawPath)
318
+ ? rawPath
319
+ : rawPath.startsWith('/')
320
+ ? join(root, 'public', rawPath)
321
+ : rawPath
251
322
 
252
323
  // CDN mode — rewrite URLs, no local processing
253
324
  if (cdn) {
254
325
  const metadata = await getImageMetadata(absPath)
255
326
  const sources = defaultWidths.map((w) => ({
256
- src: cdn(rawPath, { width: w, quality, format: defaultFormats[0]! }) ?? rawPath,
327
+ src:
328
+ cdn(rawPath, {
329
+ width: w,
330
+ quality: qualityFor(defaultFormats[0]!),
331
+ format: defaultFormats[0]!,
332
+ }) ?? rawPath,
257
333
  width: w,
258
334
  format: defaultFormats[0]! as string,
259
335
  }))
@@ -263,12 +339,14 @@ export default function SvgComponent(props) {
263
339
  srcset,
264
340
  width: metadata.width,
265
341
  height: metadata.height,
266
- placeholder: placeholderStrategy === 'none' ? ''
267
- : await generateBlurPlaceholder(absPath, placeholderSize),
342
+ placeholder: await generatePlaceholder(absPath, placeholderStrategy, placeholderSize),
268
343
  formats: defaultFormats.map((fmt) => ({
269
344
  type: `image/${fmt}`,
270
345
  srcset: defaultWidths
271
- .map((w) => `${cdn(rawPath, { width: w, quality, format: fmt }) ?? rawPath} ${w}w`)
346
+ .map(
347
+ (w) =>
348
+ `${cdn(rawPath, { width: w, quality: qualityFor(fmt), format: fmt }) ?? rawPath} ${w}w`,
349
+ )
272
350
  .join(', '),
273
351
  })),
274
352
  sources,
@@ -277,14 +355,20 @@ export default function SvgComponent(props) {
277
355
  }
278
356
 
279
357
  if (!isBuild) {
280
- const result = await loadDevImage(absPath, rawPath, placeholderSize)
358
+ const result = await loadDevImage(
359
+ absPath,
360
+ rawPath,
361
+ placeholderStrategy,
362
+ placeholderSize,
363
+ )
281
364
  return `export default ${JSON.stringify(result)}`
282
365
  }
283
366
 
284
367
  const processed = await processImage(absPath, {
285
368
  widths: defaultWidths,
286
369
  formats: defaultFormats,
287
- quality,
370
+ qualityFor,
371
+ placeholderStrategy,
288
372
  placeholderSize,
289
373
  outSubDir,
290
374
  outDir: join(root, outDir),
@@ -301,6 +385,7 @@ export default function SvgComponent(props) {
301
385
  async function loadDevImage(
302
386
  absPath: string,
303
387
  rawPath: string,
388
+ strategy: 'blur' | 'color' | 'none',
304
389
  placeholderSize: number,
305
390
  ): Promise<ProcessedImage> {
306
391
  const metadata = await getImageMetadata(absPath)
@@ -311,7 +396,7 @@ async function loadDevImage(
311
396
  srcset: '',
312
397
  width: metadata.width,
313
398
  height: metadata.height,
314
- placeholder: await generateBlurPlaceholder(absPath, placeholderSize),
399
+ placeholder: await generatePlaceholder(absPath, strategy, placeholderSize),
315
400
  formats: [],
316
401
  sources: [{ src: publicPath, width: metadata.width, format: 'original' }],
317
402
  }
@@ -357,7 +442,8 @@ function rebuildFormatSrcsets(processed: ProcessedImage, fallbackPath: string) {
357
442
  interface ProcessOptions {
358
443
  widths: number[]
359
444
  formats: ImageFormat[]
360
- quality: number
445
+ qualityFor: (format: ImageFormat) => number
446
+ placeholderStrategy: 'blur' | 'color' | 'none'
361
447
  placeholderSize: number
362
448
  outSubDir: string
363
449
  outDir: string
@@ -383,7 +469,7 @@ async function processImage(absPath: string, opts: ProcessOptions): Promise<Proc
383
469
  const outName = `${name}-${width}.${format}`
384
470
  const outPath = join(processedDir, outName)
385
471
 
386
- await resizeImage(absPath, outPath, width, format, opts.quality)
472
+ await resizeImage(absPath, outPath, width, format, opts.qualityFor(format))
387
473
  sources.push({ src: outPath, width, format })
388
474
  }
389
475
  }
@@ -408,8 +494,14 @@ async function processImage(absPath: string, opts: ProcessOptions): Promise<Proc
408
494
  const fallbackFormat = formats[formats.length - 1]
409
495
  const fallbackSources = formatGroups.get([...formatGroups.keys()].pop()!)!
410
496
 
411
- // Generate blur placeholder
412
- const placeholder = await generateBlurPlaceholder(absPath, opts.placeholderSize)
497
+ // Generate the placeholder per the configured strategy. Pre-fix this
498
+ // hard-coded `generateBlurPlaceholder`, so `placeholder: 'none'` was
499
+ // ignored in build mode and `'dominant-color'` never resolved anywhere.
500
+ const placeholder = await generatePlaceholder(
501
+ absPath,
502
+ opts.placeholderStrategy,
503
+ opts.placeholderSize,
504
+ )
413
505
 
414
506
  return {
415
507
  src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,
@@ -564,6 +656,82 @@ async function generateBlurPlaceholder(input: string, size: number): Promise<str
564
656
  return `data:image/webp;base64,${buffer.toString('base64')}`
565
657
  } catch {
566
658
  // 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"
659
+ return TRANSPARENT_PLACEHOLDER
660
+ }
661
+ }
662
+
663
+ /** 1×1 transparent SVG — the no-sharp fallback for every strategy. */
664
+ const TRANSPARENT_PLACEHOLDER =
665
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E"
666
+
667
+ const DEFAULT_QUALITY = 80
668
+
669
+ /**
670
+ * Resolve the public {@link ImageQuality} config into a per-format lookup.
671
+ *
672
+ * - `undefined` → every format gets {@link DEFAULT_QUALITY}.
673
+ * - `number` → that number for every format (backward-compatible).
674
+ * - `Partial<Record<ImageFormat, number>>` → per-format; formats omitted
675
+ * from the map fall back to {@link DEFAULT_QUALITY}.
676
+ *
677
+ * @internal Exported for testing.
678
+ */
679
+ export function resolveQuality(
680
+ q: ImageQuality | undefined,
681
+ ): (format: ImageFormat) => number {
682
+ if (q === undefined) return () => DEFAULT_QUALITY
683
+ if (typeof q === 'number') return () => q
684
+ return (format) => q[format] ?? DEFAULT_QUALITY
685
+ }
686
+
687
+ /**
688
+ * Dispatch placeholder generation by strategy. Single source of truth used
689
+ * by every code path (CDN / dev / build) — pre-fix each path open-coded
690
+ * `generateBlurPlaceholder`, so `'none'` was honoured only in the CDN path
691
+ * and `'dominant-color'` (typed since the plugin's inception) was never
692
+ * implemented anywhere — the exact typed-but-unimplemented bug class the
693
+ * `audit-types` gate exists to catch.
694
+ *
695
+ * @internal Exported for testing.
696
+ */
697
+ export async function generatePlaceholder(
698
+ input: string,
699
+ strategy: 'blur' | 'color' | 'none',
700
+ size: number,
701
+ ): Promise<string> {
702
+ if (strategy === 'none') return ''
703
+ if (strategy === 'color') return generateColorPlaceholder(input)
704
+ return generateBlurPlaceholder(input, size)
705
+ }
706
+
707
+ /**
708
+ * Generate a dominant-colour placeholder: a ~200-byte flat-fill SVG data URI.
709
+ *
710
+ * Uses sharp's `.stats()` `dominant` swatch — a histogram-binned colour,
711
+ * not a naive average (averaging a photo trends muddy grey). Note the
712
+ * swatch is approximate by design: a pure-red source resolves to ~#f80808,
713
+ * not #ff0000. The SVG is a constant ~200 bytes regardless of source
714
+ * complexity and needs zero image decode, at the cost of showing a solid
715
+ * colour instead of a blurry preview of the content.
716
+ */
717
+ async function generateColorPlaceholder(input: string): Promise<string> {
718
+ try {
719
+ const sharp = await import('sharp').then((m) => m.default ?? m)
720
+ const { dominant } = await sharp(input).stats()
721
+ const hex =
722
+ '#' +
723
+ [dominant.r, dominant.g, dominant.b]
724
+ .map((c) => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0'))
725
+ .join('')
726
+ // Inline SVG with the colour as a single rect — URL-encoded so it needs
727
+ // no base64 inflation. preserveAspectRatio + viewBox let it scale to any
728
+ // container the way an <img> placeholder is expected to.
729
+ const svg =
730
+ `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1' preserveAspectRatio='none'>` +
731
+ `<rect width='1' height='1' fill='${hex}'/></svg>`
732
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`
733
+ } catch {
734
+ // sharp not available — transparent fallback (same as the blur path).
735
+ return TRANSPARENT_PLACEHOLDER
568
736
  }
569
737
  }
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, {
package/src/manifest.ts CHANGED
@@ -475,6 +475,105 @@ const ButtonLink = createLink((props) => (
475
475
  seeAlso: ['Link', 'useLink'],
476
476
  },
477
477
 
478
+ {
479
+ name: 'Icon',
480
+ kind: 'component',
481
+ signature: '<Icon as={ImportedSvgComponent} | svg={rawSvgMarkupString} {...hostProps} />',
482
+ summary:
483
+ "Renders a FULL loaded SVG — it does NOT synthesize its own `<svg>` around hand-authored `<path>` children. You load an svg (it already contains the `<svg>` root) and Icon makes it container-sizable + theme-aware. Two source props: `as` — an imported SVG *component* (`import X from './x.svg?component'`), rendered DIRECTLY with no host wrapper (recommended; it's a real `<svg>` so container-fill is reliable); `svg` — the raw `<svg>…</svg>` *markup string* (`import x from './x.svg?raw'`), inlined via a single `<span>` host (a markup string needs a parent to mount — this one host is unavoidable for the string form). Defaults (`fill=\"currentColor\"`, `display:block;width:100%;height:100%`) are overridable — consumer props spread through and win. No fixed size → fills its container; `fill=\"currentColor\"` themes via CSS `color`. Intentionally no `useIcon` hook (an icon has no composable behaviour); two layers: `createIcon` (one component per loaded glyph) + `Icon` (one-off).",
484
+ example: `import { Icon } from '@pyreon/zero'
485
+ import Check from './check.svg?component'
486
+ import checkRaw from './check.svg?raw'
487
+
488
+ // Component form — rendered directly, no wrapper, reliable fill:
489
+ <span style="width:2rem"><Icon as={Check} /></span>
490
+
491
+ // Raw-markup form — inlined inside one <span> host:
492
+ <span style="width:2rem"><Icon svg={checkRaw} /></span>`,
493
+ mistakes: [
494
+ "Expecting `<Icon>` to synthesize an `<svg>` from `<path>` children — it does NOT. Pass a loaded svg via `as` (imported `?component`) or `svg` (imported `?raw` string). Children are not the API",
495
+ "Expecting `<Icon>` to size itself — it has NO intrinsic size; it fills its container. Wrap + size it (`<span style=\"width:1.5rem\">`) or use a sized flex/grid cell",
496
+ "Hardcoding `fill=\"#000\"` — breaks theming. Leave the `currentColor` default; drive colour with CSS `color` so dark mode + hover work for free. Only the `as` form forwards `fill` to the real svg — the `svg`-string form's markup is opaque, so colour it via `currentColor` inside the asset",
497
+ "Expecting svg-only props (`viewBox`, `fill`) to apply in the `svg`-string form — they can't reach the opaque inlined markup; only host attrs (`class`, `style`, `aria-*`, events) forward. Use the `as` form when you need to drive svg attributes",
498
+ "Reaching for a `useIcon` hook — there isn't one, by design. Use `createIcon` or inline `<Icon>`; an icon has no behaviour worth a hook layer",
499
+ "Preferring `svg` (raw string) for the wrapper-free guarantee — it's the opposite: `svg` ALWAYS adds a `<span>` host (unavoidable for string inlining); `as` is the zero-wrapper form",
500
+ ],
501
+ seeAlso: ['createIcon', 'IconProps', 'Image'],
502
+ },
503
+ {
504
+ name: 'createIcon',
505
+ kind: 'function',
506
+ signature: 'function createIcon(source: string | SvgComponent): (props: SvgAttributes) => VNodeChild',
507
+ summary:
508
+ "Builds a reusable icon component from a LOADED svg — a raw `<svg>…</svg>` markup string (`?raw`) OR an imported SVG component (`?component`). The result is still just `<Icon>` (string → `svg` prop, component → `as` prop), so it's container-sizable + theme-aware with every prop passed through. A generated icon set is `createIcon`-per-glyph with zero per-icon boilerplate. Mirrors the `createLink`/`createImage` factory layer, minus a hook (icons have no composable behaviour).",
509
+ example: `import { createIcon } from '@pyreon/zero'
510
+ import StarSvg from './star.svg?component'
511
+ import checkRaw from './check.svg?raw'
512
+
513
+ export const Star = createIcon(StarSvg) // component → rendered directly
514
+ export const Check = createIcon(checkRaw) // raw string → inlined via <span>
515
+
516
+ // Sized + themed entirely by the consumer:
517
+ <span style="width:48px"><Check class="text-green-600" aria-label="done" /></span>`,
518
+ mistakes: [
519
+ "Calling `createIcon` inside a component body — define icon components at module scope (like `createLink`/`createImage`). Re-creating the component every render defeats identity-based reconciliation",
520
+ "Passing hand-built `<path>` JSX as `source` — `source` is a full loaded svg: a `?raw` markup string OR a `?component` import. It does NOT take individual shapes; the loaded asset already contains its own `<svg>` root",
521
+ "Assuming the `?raw` form has no wrapper — the string form ALWAYS adds one `<span>` host (unavoidable for inlining markup). Use the `?component` form for the zero-wrapper, attribute-forwarding path",
522
+ ],
523
+ seeAlso: ['Icon', 'IconProps', 'createNamedIcon', 'iconsPlugin'],
524
+ },
525
+ {
526
+ name: 'iconsPlugin',
527
+ kind: 'function',
528
+ signature: "iconsPlugin({ dir | sets, out?, mode?: 'inline' | 'image' }): Plugin",
529
+ summary:
530
+ "Vite plugin (from `@pyreon/zero/server`): point it at a folder of `*.svg` files and it writes a strictly-typed generated `icons.gen.tsx` exporting `<Icon name=\"…\" />`. Add an svg → the `name` union widens; remove one → an invalid `name` fails typecheck. The generated file calls `createNamedIcon(REGISTRY)`, so `keyof typeof REGISTRY` IS the type surface (autocomplete + real go-to-definition, zero per-app wiring — same one-touch shape as fs-router / islands auto-registry). Regenerates on add/unlink in dev (idempotent write — never rewrites identical content). **Named multi-set form** (`sets: { ui: { dir }, brand: { dir, mode } }`, mutually exclusive with `dir`): one generated file exports a strictly-typed component PER set with NAMESPACED types so they never clash — `ui` → `<UiIcon name=\"…\" />` + `type UiIconName`, `brand` → `<BrandIcon name=\"…\" />` + `type BrandIconName`; per-set binding prefixes mean two sets sharing a glyph filename don't collide. Two render modes per the colorful-vs-system split (settable per-set): `mode: 'inline'` (default — system icons; each svg inlined as raw `?raw` markup, `currentColor`-themeable, recolor via CSS `color`) and `mode: 'image'` (colorful / brand icons; each svg emitted as a static asset, rendered `<img>`, NO mutation, original colors preserved). Default `out` is `icons.gen.tsx` next to `dir` for the single-set form (`src/icons` → `src/icons.gen.tsx`) or `src/icons.gen.tsx` for the multi-set form — recommend gitignoring it (build artifact). It writes a real file (NOT a virtual module) deliberately: the published `@pyreon/zero` package can't `import` a plugin virtual module — Rolldown resolves static imports before plugin `resolveId` (the same constraint that makes islands need `hydrateIslandsAuto(registry)` with an explicit import).",
531
+ example: `// vite.config.ts — single set:
532
+ import { iconsPlugin } from '@pyreon/zero/server'
533
+ iconsPlugin({ dir: './src/icons' })
534
+ // app: import { Icon } from './icons.gen'; <Icon name="check-circle" />
535
+
536
+ // Named multi-set — per-set typed components, no IconName clash:
537
+ iconsPlugin({ sets: {
538
+ ui: { dir: './src/icons/ui' },
539
+ brand: { dir: './src/icons/brand', mode: 'image' },
540
+ }})
541
+ // app: import { UiIcon, BrandIcon } from './icons.gen'
542
+ // <UiIcon name="arrow-left" /> <BrandIcon name="logo-mark" />`,
543
+ mistakes: [
544
+ "Passing BOTH `dir` and `sets` (or neither) — exactly one is required; the plugin throws `[Pyreon] iconsPlugin: provide EXACTLY ONE of dir or sets` at config time",
545
+ "Using `mode: 'inline'` (default) for multicolor / brand SVGs — inline mode is for monochrome system icons you recolor via `currentColor`. A multicolor logo's hardcoded fills survive but you lose nothing by using `mode: 'image'`, which is the correct choice for no-mutation colorful assets",
546
+ "Using `mode: 'image'` for icons you need to recolor — `<img>` can't be themed via CSS `color`; the svg is opaque. Recolorable system icons need `mode: 'inline'`",
547
+ "Editing the generated `icons.gen.tsx` by hand — it's regenerated on every add/unlink. Add/remove `.svg` files in the set folder(s) instead; commit the gitignore entry, not the file",
548
+ "Expecting a virtual `import 'virtual:zero/icons'` — there isn't one (Rolldown import-ordering constraint). The plugin writes a REAL file you import by path; that's what gives go-to-definition + zero wiring",
549
+ "Pointing a set `dir` at a folder that doesn't exist yet — `scanIconDir` returns empty and the generated `*IconName` is `never` (every `name` fails typecheck). Create the folder + drop at least one `.svg` first",
550
+ "Forgetting `vite/client` types — the generated file's `?raw` imports rely on Vite's ambient `*.svg?raw` module declaration; the generated file emits `/// <reference types=\"vite/client\" />` but the consuming tsconfig must still resolve `vite/client`",
551
+ ],
552
+ seeAlso: ['createNamedIcon', 'Icon', 'IconProps'],
553
+ },
554
+ {
555
+ name: 'createNamedIcon',
556
+ kind: 'function',
557
+ signature:
558
+ "function createNamedIcon<R extends Record<string, string>>(registry: R, options?: { mode?: 'inline' | 'image' }): (props: { name: keyof R & string } & …) => VNodeChild",
559
+ summary:
560
+ "Runtime half of `iconsPlugin` — builds a strictly-typed `<Icon name=\"…\" />` from a name→source registry. `keyof R` makes `name` a precise string union (the generated file passes a literal registry so the union infers there → autocomplete + go-to-definition). `mode: 'inline'` (default) treats each `source` as raw `<svg>` markup rendered via `Icon` (`currentColor`-themeable system icons); `mode: 'image'` treats each `source` as an asset URL rendered `<img>` with NO mutation (colorful / brand icons). Either way it stays container-filling + props-transparent. Not normally hand-called — `iconsPlugin` emits the generated file that calls it; call it directly only for a hand-maintained set.",
561
+ example: `// icons.gen.tsx (auto-generated by iconsPlugin):
562
+ import { createNamedIcon } from '@pyreon/zero'
563
+ export const Icon = createNamedIcon({ 'check-circle': '<svg…>…</svg>' })
564
+
565
+ // image mode (hand-maintained colorful set):
566
+ import logo from './logo.svg' // Vite → URL
567
+ export const Brand = createNamedIcon({ logo }, { mode: 'image' })
568
+ <Brand name="logo" alt="Company" />`,
569
+ mistakes: [
570
+ "Passing a `Record<string, string>` typed loosely (e.g. `: Record<string, string>`) — that widens `keyof R` to `string` and you lose the typed `name`. Pass the object literal directly (or `as const`) so the keys infer",
571
+ "Using `mode: 'image'` then expecting `fill` / svg props to apply — the `<img>` is opaque; only host attrs (`class`, `style`, `alt`, events) forward. Use `mode: 'inline'` for svg-attribute control",
572
+ "Omitting `alt` in `mode: 'image'` — it defaults to `\"\"` (decorative). Pass a real `alt` for meaningful icons; screen readers skip empty-alt images",
573
+ "Calling `createNamedIcon` inside a component body — define the set once at module scope (the generated file does). Re-creating it per render defeats identity-based reconciliation",
574
+ ],
575
+ seeAlso: ['iconsPlugin', 'Icon', 'IconProps'],
576
+ },
478
577
  {
479
578
  name: 'Image',
480
579
  kind: 'component',
package/src/rate-limit.ts CHANGED
@@ -62,6 +62,22 @@ export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
62
62
  for (const [key, entry] of store) {
63
63
  if (entry.resetAt <= now) store.delete(key)
64
64
  }
65
+ // HARD cap. The expired-sweep above only removes entries whose
66
+ // window has elapsed. An attacker flooding unique keys WITHIN one
67
+ // window (spoofed `X-Forwarded-For` / proxy header — `defaultKeyFn`
68
+ // trusts request headers) produces only fresh entries, so the sweep
69
+ // frees nothing and `store.set` grows the Map without bound — an
70
+ // unauthenticated memory-exhaustion DoS. `MAX_STORE_SIZE` was a
71
+ // declared constant used ONLY as a sweep trigger, never enforced.
72
+ // Map preserves insertion order, so evicting from the front drops
73
+ // the oldest trackers first (acceptable: an evicted attacker key
74
+ // simply gets a fresh window — no bypass of legitimate limits since
75
+ // a real client re-inserts and is immediately re-tracked).
76
+ while (store.size > MAX_STORE_SIZE) {
77
+ const oldest = store.keys().next().value
78
+ if (oldest === undefined) break
79
+ store.delete(oldest)
80
+ }
65
81
  }
66
82
 
67
83
  return (ctx: MiddlewareContext) => {
package/src/seo.ts CHANGED
@@ -135,10 +135,25 @@ export function generateSitemap(
135
135
  .filter((p): p is string => p !== null)
136
136
  .filter((p) => !exclude.some((e) => p.startsWith(e)))
137
137
 
138
- const allPaths: SitemapEntry[] = [
139
- ...paths.map((p) => ({ path: p, changefreq, priority })),
140
- ...(config.additionalPaths ?? []),
141
- ]
138
+ // Dedup by path (first-wins, order-preserving). The same static route
139
+ // routinely appears in BOTH the file-system route scan AND
140
+ // `additionalPaths` (e.g. SSG-emitted paths merged in via
141
+ // `seoPlugin`), which previously produced a DUPLICATE `<url>` entry —
142
+ // the i18n branch of `clusterPathsByLocale` dedups via `byUnPrefixed`,
143
+ // but the non-i18n branch is a raw 1:1 `entries.map(...)`, so without
144
+ // this the duplicate reached the emitted sitemap. Dedup here covers
145
+ // both branches at the single source. The route-scan entry wins so its
146
+ // configured `changefreq`/`priority` is kept over a bare dup.
147
+ const allPaths: SitemapEntry[] = (() => {
148
+ const byPath = new Map<string, SitemapEntry>()
149
+ for (const e of [
150
+ ...paths.map((p) => ({ path: p, changefreq, priority })),
151
+ ...(config.additionalPaths ?? []),
152
+ ]) {
153
+ if (!byPath.has(e.path)) byPath.set(e.path, e)
154
+ }
155
+ return [...byPath.values()]
156
+ })()
142
157
 
143
158
  // PR K: when i18n is set, cluster URLs by their un-prefixed (default-
144
159
  // locale) form so each `<url>` entry can carry the hreflang siblings
package/src/server.ts CHANGED
@@ -70,6 +70,8 @@ export { compose, getContext } from "./middleware";
70
70
  export { zeroPlugin as default, getZeroPluginConfig } from "./vite-plugin";
71
71
  export type { FaviconPluginConfig, FaviconLocaleConfig } from "./favicon";
72
72
  export { faviconPlugin, faviconLinks } from "./favicon";
73
+ export type { IconsPluginConfig, IconSetConfig, NamedSetInput } from "./icons-plugin";
74
+ export { iconsPlugin, iconNameFromFile, scanIconDir, generateIconSetSource, generateNamedIconSetsSource, componentNameFromSetKey } from "./icons-plugin";
73
75
  export type { SeoPluginConfig, SitemapConfig, RobotsConfig } from "./seo";
74
76
  export { seoPlugin, generateSitemap, generateRobots, jsonLd, seoMiddleware } from "./seo";
75
77
  export type { OgImagePluginConfig, OgImageTemplate, OgImageLayer } from "./og-image";
package/src/sharp.d.ts CHANGED
@@ -9,6 +9,12 @@ declare module 'sharp' {
9
9
  toFile(path: string): Promise<void>
10
10
  toBuffer(): Promise<Buffer>
11
11
  metadata(): Promise<{ width?: number; height?: number; format?: string }>
12
+ /**
13
+ * Image statistics. `dominant` is the histogram-mode RGB swatch —
14
+ * the basis of the `'color'` / `'dominant-color'` placeholder
15
+ * strategy (a flat-fill SVG, not a muddy channel average).
16
+ */
17
+ stats(): Promise<{ dominant: { r: number; g: number; b: number } }>
12
18
  }
13
19
 
14
20
  function sharp(input: string | Buffer): SharpInstance