@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/image-plugin.ts
CHANGED
|
@@ -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
|
-
/**
|
|
62
|
-
|
|
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
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
412
|
-
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|