@pyreon/zero 0.24.5 → 0.24.6

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.
Files changed (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. package/src/vite-plugin.ts +0 -848
@@ -1,751 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
- import { basename, extname, join } from 'node:path'
4
- import type { Plugin } from 'vite'
5
-
6
- let sharpWarned = false
7
- function warnSharpMissing() {
8
- if (sharpWarned) return
9
- sharpWarned = true
10
- // oxlint-disable-next-line no-console
11
- console.warn(
12
- '\n[Pyreon] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n',
13
- )
14
- }
15
-
16
- // ─── Image processing Vite plugin ──────────────────────────────────────────
17
- //
18
- // Processes images at build time:
19
- // - Generates multiple sizes for responsive srcset
20
- // - Converts to modern formats (WebP, AVIF)
21
- // - Creates tiny blur placeholders (base64 inline)
22
- // - Outputs optimized images to the build directory
23
- //
24
- // Usage in code:
25
- // import heroImg from "./hero.jpg?optimize"
26
- // // → ProcessedImage { src, srcset, width, height, placeholder }
27
- //
28
- // Type the `?optimize` / `?component` / `?raw` imports out of the box —
29
- // add ONE line to a tsconfig-covered `.d.ts` (e.g. `src/env.d.ts`):
30
- // /// <reference types="@pyreon/zero/image-types" />
31
- // (ships the ambient `declare module "*?optimize"` etc. — reuses this
32
- // module's own `ProcessedImage`, so it never drifts.)
33
- //
34
- // Or use the component helper:
35
- // import { Image } from "@pyreon/zero/image"
36
- // <Image src="/hero.jpg" width={1920} height={1080} optimize />
37
-
38
- /**
39
- * CDN provider — rewrites image URLs to CDN endpoints.
40
- * Return the rewritten URL, or null to use local processing.
41
- */
42
- export type ImageCdnProvider = (src: string, opts: {
43
- width: number
44
- quality: number
45
- format: ImageFormat
46
- }) => string | null
47
-
48
- /** Built-in CDN providers. */
49
- export const cdnProviders = {
50
- /** Cloudinary: `https://res.cloudinary.com/{cloud}/image/upload/...` */
51
- cloudinary: (cloudName: string): ImageCdnProvider => (src, { width, quality, format }) =>
52
- `https://res.cloudinary.com/${cloudName}/image/upload/w_${width},q_${quality},f_${format}/${src}`,
53
-
54
- /** Imgix: `https://{domain}.imgix.net/...?w=...&q=...&fm=...` */
55
- imgix: (domain: string): ImageCdnProvider => (src, { width, quality, format }) =>
56
- `https://${domain}.imgix.net/${src}?w=${width}&q=${quality}&fm=${format}&auto=format`,
57
-
58
- /** Vercel Image Optimization: `/_next/image?url=...&w=...&q=...` */
59
- vercel: (): ImageCdnProvider => (src, { width, quality }) =>
60
- `/_vercel/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`,
61
-
62
- /** Bunny CDN: `https://{pullZone}.b-cdn.net/...?width=...&quality=...` */
63
- bunny: (pullZone: string): ImageCdnProvider => (src, { width, quality }) =>
64
- `https://${pullZone}.b-cdn.net/${src}?width=${width}&quality=${quality}`,
65
- } as const
66
-
67
- /**
68
- * Placeholder generation strategy.
69
- *
70
- * - `'blur'` — tiny downscaled + blurred WebP data URI (a few hundred bytes).
71
- * The richest preview; faithfully previews the image's content.
72
- * - `'color'` — the image's dominant colour as a ~200-byte flat SVG data
73
- * URI. Constant size regardless of source complexity (a blurred WebP
74
- * grows with image content; this doesn't), zero decode, instant paint,
75
- * zero layout shift. For real photos it's far smaller than `'blur'`; for
76
- * trivial/solid sources `'blur'` can be the smaller of the two. Best when
77
- * you want a clean solid backdrop rather than a blurry preview.
78
- * - `'none'` — no placeholder (`placeholder: ''`). Skips all placeholder work.
79
- *
80
- * `'dominant-color'` is a deprecated alias of `'color'` — it was typed from
81
- * the plugin's inception but never implemented (the build + dev paths always
82
- * fell through to blur). It now resolves to `'color'`; prefer the shorter
83
- * name in new code.
84
- */
85
- export type PlaceholderStrategy = 'blur' | 'color' | 'dominant-color' | 'none'
86
-
87
- /** Quality per output format (1-100), or a single number applied to all. */
88
- export type ImageQuality = number | Partial<Record<ImageFormat, number>>
89
-
90
- /**
91
- * Normalize the public {@link PlaceholderStrategy} to an internal kind.
92
- * @internal Exported for testing.
93
- */
94
- export function normalizePlaceholder(s: PlaceholderStrategy): 'blur' | 'color' | 'none' {
95
- return s === 'dominant-color' ? 'color' : s
96
- }
97
-
98
- /** SVG processing options for ?component imports. */
99
- export interface SvgOptions {
100
- /** Replace fill/stroke with currentColor. Default: true */
101
- currentColor?: boolean
102
- /** Default size (width/height). */
103
- defaultSize?: number
104
- }
105
-
106
- export interface ImagePluginConfig {
107
- /** Output directory for processed images. Default: "assets/img" */
108
- outDir?: string
109
- /** Default widths for responsive images. Default: [640, 1024, 1920] */
110
- widths?: number[]
111
- /** Output formats. Default: ["webp"] */
112
- formats?: ImageFormat[]
113
- /**
114
- * Quality for lossy formats (1-100). Default: 80.
115
- *
116
- * Accepts a single number applied to every format, OR a per-format map so
117
- * you can tune each codec independently — AVIF tolerates a much lower
118
- * number than WebP/JPEG for the same perceived quality:
119
- *
120
- * ```ts
121
- * imagePlugin({ formats: ['avif', 'webp'], quality: { avif: 55, webp: 75 } })
122
- * ```
123
- *
124
- * Formats omitted from the map fall back to 80.
125
- */
126
- quality?: ImageQuality
127
- /** Blur placeholder size in px (only used by the `'blur'` strategy). Default: 16 */
128
- placeholderSize?: number
129
- /** Placeholder strategy. Default: `"blur"`. See {@link PlaceholderStrategy}. */
130
- placeholder?: PlaceholderStrategy
131
- /** File patterns to process. Default: /\.(jpe?g|png|webp|avif)$/i */
132
- include?: RegExp
133
- /**
134
- * CDN provider for URL rewriting. When set, images are NOT processed
135
- * locally — URLs are rewritten to the CDN endpoint.
136
- *
137
- * @example
138
- * ```ts
139
- * imagePlugin({ cdn: cdnProviders.cloudinary('my-cloud') })
140
- * imagePlugin({ cdn: cdnProviders.vercel() })
141
- * ```
142
- */
143
- cdn?: ImageCdnProvider
144
- /**
145
- * SVG processing options. Enables `?component` import for inline SVGs.
146
- *
147
- * @example
148
- * ```tsx
149
- * import Logo from './logo.svg?component'
150
- * <Logo width={24} class="text-primary" />
151
- * ```
152
- */
153
- svg?: SvgOptions | boolean
154
- }
155
-
156
- export type ImageFormat = 'webp' | 'avif' | 'jpeg' | 'png'
157
-
158
- /** Per-format source set for <picture> <source> elements. */
159
- export interface FormatSource {
160
- /** MIME type. e.g. "image/webp", "image/avif" */
161
- type: string
162
- /** srcset string for this format. e.g. "/img-640.webp 640w, /img-1920.webp 1920w" */
163
- srcset: string
164
- }
165
-
166
- export interface ProcessedImage {
167
- /** Fallback source path (last format, largest width). */
168
- src: string
169
- /** Fallback srcset string (last format). */
170
- srcset: string
171
- /** Intrinsic width. */
172
- width: number
173
- /** Intrinsic height. */
174
- height: number
175
- /** Base64 blur placeholder data URI. */
176
- placeholder: string
177
- /** Per-format source sets for <picture> element. Ordered by priority (best format first). */
178
- formats: FormatSource[]
179
- /** Flat list of all sources. */
180
- sources: Array<{ src: string; width: number; format: string }>
181
- }
182
-
183
- const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i
184
-
185
- /**
186
- * Zero image processing Vite plugin.
187
- *
188
- * Transforms image imports with query params into optimized responsive images:
189
- *
190
- * @example
191
- * // vite.config.ts
192
- * import { imagePlugin } from "@pyreon/zero/image-plugin"
193
- *
194
- * export default {
195
- * plugins: [
196
- * pyreon(),
197
- * zero(),
198
- * imagePlugin({ widths: [480, 960, 1440], quality: 85 }),
199
- * ],
200
- * }
201
- *
202
- * @example
203
- * // In a component — import with ?optimize
204
- * import hero from "./images/hero.jpg?optimize"
205
- * // hero = { src, srcset, width, height, placeholder }
206
- *
207
- * <Image {...hero} alt="Hero" priority />
208
- */
209
- export function imagePlugin(config: ImagePluginConfig = {}): Plugin {
210
- const defaultWidths = config.widths ?? [640, 1024, 1920]
211
- const defaultFormats = config.formats ?? ['webp']
212
- const qualityFor = resolveQuality(config.quality)
213
- const placeholderSize = config.placeholderSize ?? 16
214
- const placeholderStrategy = normalizePlaceholder(config.placeholder ?? 'blur')
215
- const outSubDir = config.outDir ?? 'assets/img'
216
- const include = config.include ?? IMAGE_EXT_RE
217
- const cdn = config.cdn
218
- const svgOpts: SvgOptions | false = config.svg === true
219
- ? { currentColor: true }
220
- : config.svg === false || config.svg === undefined
221
- ? false
222
- : config.svg
223
-
224
- let root = ''
225
- let outDir = ''
226
- let isBuild = false
227
-
228
- return {
229
- name: 'pyreon-zero-images',
230
- enforce: 'pre',
231
-
232
- configResolved(resolvedConfig) {
233
- root = resolvedConfig.root
234
- outDir = resolvedConfig.build.outDir
235
- isBuild = resolvedConfig.command === 'build'
236
- },
237
-
238
- async resolveId(id, importer) {
239
- const isSvgComponent =
240
- svgOpts && id.includes('?component') && id.split('?')[0]!.endsWith('.svg')
241
- const isOptimize =
242
- id.includes('?optimize') && include.test(id.split('?')[0]!)
243
- if (!isSvgComponent && !isOptimize) return null
244
-
245
- // Resolve the bare specifier to an ABSOLUTE fs path the way Vite
246
- // resolves `?url` — importer-relative + alias-aware. The old code
247
- // embedded the raw unresolved id, so `load()` had to guess: a
248
- // relative `./img.png?optimize` resolved against cwd (≠ the
249
- // importer's dir → ENOENT), and an aliased `~/x.png?optimize`
250
- // arrived already-absolute and got `join(root,'public',…)`-doubled.
251
- // `this.resolve` (skipSelf so we don't recurse into our own
252
- // resolveId) handles relative + aliases + extensions. A public-dir
253
- // web path (`/foo.png?optimize`) doesn't resolve to a module →
254
- // null → keep the original id so load()'s public/ fallback applies.
255
- const qIdx = id.indexOf('?')
256
- const bare = qIdx === -1 ? id : id.slice(0, qIdx)
257
- const query = qIdx === -1 ? '' : id.slice(qIdx)
258
- const resolved = await this.resolve(bare, importer, { skipSelf: true })
259
- const carried = resolved ? `${resolved.id}${query}` : id
260
-
261
- if (isSvgComponent) return `\0virtual:zero-svg:${carried}`
262
- return `\0virtual:zero-image:${carried}`
263
- },
264
-
265
- async load(id) {
266
- // SVG component loading
267
- if (id.startsWith('\0virtual:zero-svg:')) {
268
- const rawPath = id.replace('\0virtual:zero-svg:', '').split('?')[0] ?? id
269
- // resolveId now carries an absolute fs path for relative/aliased
270
- // imports → trust it if it exists. Only a public-dir web path
271
- // (`/logo.svg`, unresolved) falls back to root-join.
272
- const absPath = existsSync(rawPath)
273
- ? rawPath
274
- : rawPath.startsWith('/')
275
- ? join(root, rawPath)
276
- : rawPath
277
- if (!existsSync(absPath)) return null
278
-
279
- let svg = await readFile(absPath, 'utf-8')
280
-
281
- // Replace fill/stroke with currentColor
282
- if (svgOpts && (svgOpts as SvgOptions).currentColor !== false) {
283
- svg = svg
284
- .replace(/fill="(?!none)[^"]*"/g, 'fill="currentColor"')
285
- .replace(/stroke="(?!none)[^"]*"/g, 'stroke="currentColor"')
286
- }
287
-
288
- // Add default size from config
289
- const defaultSize = svgOpts && (svgOpts as SvgOptions).defaultSize
290
- if (defaultSize && !svg.includes('width=')) {
291
- svg = svg.replace('<svg', `<svg width="${defaultSize}" height="${defaultSize}"`)
292
- }
293
-
294
- // Export as Pyreon component
295
- return `
296
- import { h } from '@pyreon/core'
297
- const _svg = ${JSON.stringify(svg)}
298
- export default function SvgComponent(props) {
299
- const el = h('span', {
300
- ...props,
301
- dangerouslySetInnerHTML: { __html: _svg },
302
- style: [
303
- 'display:inline-flex;align-items:center;justify-content:center',
304
- props.width ? 'width:' + props.width + 'px' : '',
305
- props.height ? 'height:' + props.height + 'px' : '',
306
- props.style || '',
307
- ].filter(Boolean).join(';'),
308
- })
309
- return el
310
- }
311
- `
312
- }
313
-
314
- // Image optimization loading
315
- if (!id.startsWith('\0virtual:zero-image:')) return null
316
-
317
- const rawPath = id.replace('\0virtual:zero-image:', '').split('?')[0] ?? id
318
- // resolveId now carries an absolute fs path for relative/aliased
319
- // imports (the `./img.png?optimize` and `~/img.png?optimize` cases
320
- // that used to ENOENT / double-`public`). Trust an existing
321
- // absolute path; only an unresolved public-dir web path
322
- // (`/foo.png?optimize`) falls back to `<root>/public/…`.
323
- const absPath = existsSync(rawPath)
324
- ? rawPath
325
- : rawPath.startsWith('/')
326
- ? join(root, 'public', rawPath)
327
- : rawPath
328
-
329
- // CDN mode — rewrite URLs, no local processing
330
- if (cdn) {
331
- const metadata = await getImageMetadata(absPath)
332
- const sources = defaultWidths.map((w) => ({
333
- src:
334
- cdn(rawPath, {
335
- width: w,
336
- quality: qualityFor(defaultFormats[0]!),
337
- format: defaultFormats[0]!,
338
- }) ?? rawPath,
339
- width: w,
340
- format: defaultFormats[0]! as string,
341
- }))
342
- const srcset = sources.map((s) => `${s.src} ${s.width}w`).join(', ')
343
- const result: ProcessedImage = {
344
- src: sources[sources.length - 1]?.src ?? rawPath,
345
- srcset,
346
- width: metadata.width,
347
- height: metadata.height,
348
- placeholder: await generatePlaceholder(absPath, placeholderStrategy, placeholderSize),
349
- formats: defaultFormats.map((fmt) => ({
350
- type: `image/${fmt}`,
351
- srcset: defaultWidths
352
- .map(
353
- (w) =>
354
- `${cdn(rawPath, { width: w, quality: qualityFor(fmt), format: fmt }) ?? rawPath} ${w}w`,
355
- )
356
- .join(', '),
357
- })),
358
- sources,
359
- }
360
- return `export default ${JSON.stringify(result)}`
361
- }
362
-
363
- if (!isBuild) {
364
- const result = await loadDevImage(
365
- absPath,
366
- rawPath,
367
- placeholderStrategy,
368
- placeholderSize,
369
- )
370
- return `export default ${JSON.stringify(result)}`
371
- }
372
-
373
- const processed = await processImage(absPath, {
374
- widths: defaultWidths,
375
- formats: defaultFormats,
376
- qualityFor,
377
- placeholderStrategy,
378
- placeholderSize,
379
- outSubDir,
380
- outDir: join(root, outDir),
381
- })
382
-
383
- await emitProcessedSources(processed, outSubDir, this)
384
- rebuildFormatSrcsets(processed, absPath)
385
-
386
- return `export default ${JSON.stringify(processed)}`
387
- },
388
- }
389
- }
390
-
391
- async function loadDevImage(
392
- absPath: string,
393
- rawPath: string,
394
- strategy: 'blur' | 'color' | 'none',
395
- placeholderSize: number,
396
- ): Promise<ProcessedImage> {
397
- const metadata = await getImageMetadata(absPath)
398
- // `rawPath` is a public-dir web path (e.g. `/logo.png`, served from
399
- // `public/` at the web root) ONLY when it does NOT resolve to a real
400
- // file on disk — the same discriminator the `absPath` derivation uses
401
- // above. `resolveId` now hands absolute fs paths for relative/aliased
402
- // imports (`/Users/…/img.png`); those ARE real files and must be
403
- // served through Vite's `/@fs/` prefix, not as a literal `/Users/…`
404
- // URL (which 404s in dev — build mode was unaffected).
405
- const isPublicWebPath = rawPath.startsWith('/') && !existsSync(rawPath)
406
- const publicPath = isPublicWebPath ? rawPath : `/@fs/${absPath}`
407
-
408
- return {
409
- src: publicPath,
410
- srcset: '',
411
- width: metadata.width,
412
- height: metadata.height,
413
- placeholder: await generatePlaceholder(absPath, strategy, placeholderSize),
414
- formats: [],
415
- sources: [{ src: publicPath, width: metadata.width, format: 'original' }],
416
- }
417
- }
418
-
419
- async function emitProcessedSources(
420
- processed: ProcessedImage,
421
- outSubDir: string,
422
- ctx: {
423
- emitFile: (f: { type: 'asset'; fileName: string; source: Uint8Array }) => void
424
- },
425
- ) {
426
- for (const source of processed.sources) {
427
- const fileName = join(outSubDir, basename(source.src))
428
- const content = await readFile(source.src)
429
- ctx.emitFile({ type: 'asset', fileName, source: content })
430
- source.src = `/${fileName}`
431
- }
432
- }
433
-
434
- function rebuildFormatSrcsets(processed: ProcessedImage, fallbackPath: string) {
435
- const formatGroups = new Map<string, string[]>()
436
- for (const s of processed.sources) {
437
- let group = formatGroups.get(s.format)
438
- if (!group) {
439
- group = []
440
- formatGroups.set(s.format, group)
441
- }
442
- group.push(`${s.src} ${s.width}w`)
443
- }
444
- processed.formats = [...formatGroups.entries()].map(([fmt, entries]) => ({
445
- type: `image/${fmt}`,
446
- srcset: entries.join(', '),
447
- }))
448
-
449
- const lastFormat = processed.formats.at(-1)
450
- processed.srcset = lastFormat?.srcset ?? ''
451
- processed.src = processed.sources.at(-1)?.src ?? fallbackPath
452
- }
453
-
454
- // ─── Image processing utilities ─────────────────────────────────────────────
455
-
456
- interface ProcessOptions {
457
- widths: number[]
458
- formats: ImageFormat[]
459
- qualityFor: (format: ImageFormat) => number
460
- placeholderStrategy: 'blur' | 'color' | 'none'
461
- placeholderSize: number
462
- outSubDir: string
463
- outDir: string
464
- }
465
-
466
- async function processImage(absPath: string, opts: ProcessOptions): Promise<ProcessedImage> {
467
- const metadata = await getImageMetadata(absPath)
468
- const ext = extname(absPath)
469
- const name = basename(absPath, ext)
470
- const sources: Array<{ src: string; width: number; format: string }> = []
471
-
472
- // Ensure output directory exists
473
- const processedDir = join(opts.outDir, opts.outSubDir)
474
- if (!existsSync(processedDir)) {
475
- await mkdir(processedDir, { recursive: true })
476
- }
477
-
478
- // Generate resized variants — iterate formats first so sources are grouped by format
479
- for (const format of opts.formats) {
480
- for (const targetWidth of opts.widths) {
481
- // Don't upscale
482
- const width = Math.min(targetWidth, metadata.width)
483
- const outName = `${name}-${width}.${format}`
484
- const outPath = join(processedDir, outName)
485
-
486
- await resizeImage(absPath, outPath, width, format, opts.qualityFor(format))
487
- sources.push({ src: outPath, width, format })
488
- }
489
- }
490
-
491
- // Build per-format source sets for <picture>
492
- const formatGroups = new Map<string, Array<{ src: string; width: number }>>()
493
- for (const s of sources) {
494
- let group = formatGroups.get(s.format)
495
- if (!group) {
496
- group = []
497
- formatGroups.set(s.format, group)
498
- }
499
- group.push({ src: s.src, width: s.width })
500
- }
501
-
502
- const formats: FormatSource[] = [...formatGroups.entries()].map(([fmt, group]) => ({
503
- type: `image/${fmt === 'jpeg' ? 'jpeg' : fmt}`,
504
- srcset: group.map((s) => `${s.src} ${s.width}w`).join(', '),
505
- }))
506
-
507
- // Fallback: last format's srcset
508
- const fallbackFormat = formats[formats.length - 1]
509
- const fallbackSources = formatGroups.get([...formatGroups.keys()].pop()!)!
510
-
511
- // Generate the placeholder per the configured strategy. Pre-fix this
512
- // hard-coded `generateBlurPlaceholder`, so `placeholder: 'none'` was
513
- // ignored in build mode and `'dominant-color'` never resolved anywhere.
514
- const placeholder = await generatePlaceholder(
515
- absPath,
516
- opts.placeholderStrategy,
517
- opts.placeholderSize,
518
- )
519
-
520
- return {
521
- src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,
522
- srcset: fallbackFormat?.srcset ?? '',
523
- width: metadata.width,
524
- height: metadata.height,
525
- placeholder,
526
- formats,
527
- sources,
528
- }
529
- }
530
-
531
- interface ImageMetadata {
532
- width: number
533
- height: number
534
- format: string
535
- }
536
-
537
- /**
538
- * Read basic image metadata.
539
- * Uses minimal binary header parsing — no external dependencies.
540
- */
541
- async function getImageMetadata(absPath: string): Promise<ImageMetadata> {
542
- const buffer = await readFile(absPath)
543
- const ext = extname(absPath).toLowerCase()
544
-
545
- if (ext === '.png') {
546
- // PNG: width at bytes 16-19, height at 20-23 (big-endian)
547
- const width = buffer.readUInt32BE(16)
548
- const height = buffer.readUInt32BE(20)
549
- return { width, height, format: 'png' }
550
- }
551
-
552
- if (ext === '.jpg' || ext === '.jpeg') {
553
- // JPEG: scan for SOF markers
554
- const dimensions = parseJpegDimensions(buffer)
555
- return { ...dimensions, format: 'jpeg' }
556
- }
557
-
558
- if (ext === '.webp') {
559
- // WebP: VP8 header
560
- const dimensions = parseWebPDimensions(buffer)
561
- return { ...dimensions, format: 'webp' }
562
- }
563
-
564
- // Fallback
565
- return { width: 0, height: 0, format: ext.slice(1) }
566
- }
567
-
568
- /** @internal Exported for testing */
569
- export function parseJpegDimensions(buffer: Buffer): {
570
- width: number
571
- height: number
572
- } {
573
- let offset = 2 // Skip SOI marker
574
- while (offset < buffer.length) {
575
- if (buffer[offset] !== 0xff) break
576
- const marker = buffer[offset + 1]!
577
- // SOF markers (0xC0-0xCF except 0xC4, 0xC8, 0xCC)
578
- if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
579
- const height = buffer.readUInt16BE(offset + 5)
580
- const width = buffer.readUInt16BE(offset + 7)
581
- return { width, height }
582
- }
583
- const length = buffer.readUInt16BE(offset + 2)
584
- offset += 2 + length
585
- }
586
- return { width: 0, height: 0 }
587
- }
588
-
589
- /** @internal Exported for testing */
590
- export function parseWebPDimensions(buffer: Buffer): {
591
- width: number
592
- height: number
593
- } {
594
- // RIFF header: bytes 0-3 = "RIFF", 8-11 = "WEBP"
595
- const chunk = buffer.toString('ascii', 12, 16)
596
- if (chunk === 'VP8 ') {
597
- // Lossy VP8
598
- const width = buffer.readUInt16LE(26) & 0x3fff
599
- const height = buffer.readUInt16LE(28) & 0x3fff
600
- return { width, height }
601
- }
602
- if (chunk === 'VP8L') {
603
- // Lossless VP8L
604
- const bits = buffer.readUInt32LE(21)
605
- const width = (bits & 0x3fff) + 1
606
- const height = ((bits >> 14) & 0x3fff) + 1
607
- return { width, height }
608
- }
609
- if (chunk === 'VP8X') {
610
- // Extended VP8X
611
- const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xffffff)
612
- const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xffffff)
613
- return { width, height }
614
- }
615
- return { width: 0, height: 0 }
616
- }
617
-
618
- /**
619
- * Resize an image using native platform capabilities.
620
- * Uses sharp if available, falls back to canvas API.
621
- */
622
- async function resizeImage(
623
- input: string,
624
- output: string,
625
- width: number,
626
- format: ImageFormat,
627
- quality: number,
628
- ): Promise<void> {
629
- try {
630
- // Try sharp (the standard Node.js image processing library)
631
- const sharp = await import('sharp').then((m) => m.default ?? m)
632
- let pipeline = sharp(input).resize(width)
633
-
634
- switch (format) {
635
- case 'webp':
636
- pipeline = pipeline.webp({ quality })
637
- break
638
- case 'avif':
639
- pipeline = pipeline.avif({ quality })
640
- break
641
- case 'jpeg':
642
- pipeline = pipeline.jpeg({ quality, mozjpeg: true })
643
- break
644
- case 'png':
645
- pipeline = pipeline.png({ compressionLevel: 9 })
646
- break
647
- }
648
-
649
- await pipeline.toFile(output)
650
- } catch {
651
- // sharp not available — copy original as fallback
652
- warnSharpMissing()
653
- const content = await readFile(input)
654
- await writeFile(output, content)
655
- }
656
- }
657
-
658
- /**
659
- * Generate a tiny blur placeholder as a base64 data URI.
660
- */
661
- async function generateBlurPlaceholder(input: string, size: number): Promise<string> {
662
- try {
663
- const sharp = await import('sharp').then((m) => m.default ?? m)
664
- const buffer = await sharp(input)
665
- .resize(size, size, { fit: 'inside' })
666
- .blur(2)
667
- .webp({ quality: 20 })
668
- .toBuffer()
669
-
670
- return `data:image/webp;base64,${buffer.toString('base64')}`
671
- } catch {
672
- // sharp not available — return a transparent placeholder
673
- return TRANSPARENT_PLACEHOLDER
674
- }
675
- }
676
-
677
- /** 1×1 transparent SVG — the no-sharp fallback for every strategy. */
678
- const TRANSPARENT_PLACEHOLDER =
679
- "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E"
680
-
681
- const DEFAULT_QUALITY = 80
682
-
683
- /**
684
- * Resolve the public {@link ImageQuality} config into a per-format lookup.
685
- *
686
- * - `undefined` → every format gets {@link DEFAULT_QUALITY}.
687
- * - `number` → that number for every format (backward-compatible).
688
- * - `Partial<Record<ImageFormat, number>>` → per-format; formats omitted
689
- * from the map fall back to {@link DEFAULT_QUALITY}.
690
- *
691
- * @internal Exported for testing.
692
- */
693
- export function resolveQuality(
694
- q: ImageQuality | undefined,
695
- ): (format: ImageFormat) => number {
696
- if (q === undefined) return () => DEFAULT_QUALITY
697
- if (typeof q === 'number') return () => q
698
- return (format) => q[format] ?? DEFAULT_QUALITY
699
- }
700
-
701
- /**
702
- * Dispatch placeholder generation by strategy. Single source of truth used
703
- * by every code path (CDN / dev / build) — pre-fix each path open-coded
704
- * `generateBlurPlaceholder`, so `'none'` was honoured only in the CDN path
705
- * and `'dominant-color'` (typed since the plugin's inception) was never
706
- * implemented anywhere — the exact typed-but-unimplemented bug class the
707
- * `audit-types` gate exists to catch.
708
- *
709
- * @internal Exported for testing.
710
- */
711
- export async function generatePlaceholder(
712
- input: string,
713
- strategy: 'blur' | 'color' | 'none',
714
- size: number,
715
- ): Promise<string> {
716
- if (strategy === 'none') return ''
717
- if (strategy === 'color') return generateColorPlaceholder(input)
718
- return generateBlurPlaceholder(input, size)
719
- }
720
-
721
- /**
722
- * Generate a dominant-colour placeholder: a ~200-byte flat-fill SVG data URI.
723
- *
724
- * Uses sharp's `.stats()` `dominant` swatch — a histogram-binned colour,
725
- * not a naive average (averaging a photo trends muddy grey). Note the
726
- * swatch is approximate by design: a pure-red source resolves to ~#f80808,
727
- * not #ff0000. The SVG is a constant ~200 bytes regardless of source
728
- * complexity and needs zero image decode, at the cost of showing a solid
729
- * colour instead of a blurry preview of the content.
730
- */
731
- async function generateColorPlaceholder(input: string): Promise<string> {
732
- try {
733
- const sharp = await import('sharp').then((m) => m.default ?? m)
734
- const { dominant } = await sharp(input).stats()
735
- const hex =
736
- '#' +
737
- [dominant.r, dominant.g, dominant.b]
738
- .map((c) => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0'))
739
- .join('')
740
- // Inline SVG with the colour as a single rect — URL-encoded so it needs
741
- // no base64 inflation. preserveAspectRatio + viewBox let it scale to any
742
- // container the way an <img> placeholder is expected to.
743
- const svg =
744
- `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1' preserveAspectRatio='none'>` +
745
- `<rect width='1' height='1' fill='${hex}'/></svg>`
746
- return `data:image/svg+xml,${encodeURIComponent(svg)}`
747
- } catch {
748
- // sharp not available — transparent fallback (same as the blur path).
749
- return TRANSPARENT_PLACEHOLDER
750
- }
751
- }