@pyreon/zero 0.24.4 → 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.
- package/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- package/src/vite-plugin.ts +0 -848
package/src/image-plugin.ts
DELETED
|
@@ -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
|
-
}
|