@pyreon/zero 0.16.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.
- package/lib/{api-routes-Ci0kVmM4.js → api-routes-CQiOi3q5.js} +5 -3
- package/lib/api-routes.js +4 -2
- package/lib/{fs-router-MewHc5SB.js → fs-router-BVY4lTH_.js} +4 -3
- package/lib/image-plugin.js +77 -13
- package/lib/index.js +96 -3
- package/lib/rate-limit.js +5 -0
- package/lib/seo.js +11 -6
- package/lib/server.js +229 -25
- package/lib/testing.js +4 -2
- package/lib/types/config.d.ts +41 -0
- package/lib/types/image-plugin.d.ts +65 -7
- package/lib/types/index.d.ts +120 -2
- package/lib/types/server.d.ts +119 -1
- package/lib/{vite-plugin-xjWZwudX.js → vite-plugin-8TXXFqdP.js} +51 -14
- package/package.json +10 -10
- package/src/api-routes.ts +12 -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 +157 -20
- 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 +41 -0
- package/src/vite-plugin.ts +63 -11
|
@@ -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
|
+
}
|
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
|
|
@@ -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:
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
412
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|