@pyreon/zero 0.1.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/LICENSE +21 -0
- package/README.md +53 -0
- package/lib/cache.js +80 -0
- package/lib/cache.js.map +1 -0
- package/lib/client.js +58 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +35 -0
- package/lib/config.js.map +1 -0
- package/lib/font.js +251 -0
- package/lib/font.js.map +1 -0
- package/lib/fs-router-BkbIWqek.js +30 -0
- package/lib/fs-router-BkbIWqek.js.map +1 -0
- package/lib/fs-router-jfd1QGLB.js +261 -0
- package/lib/fs-router-jfd1QGLB.js.map +1 -0
- package/lib/image-plugin.js +289 -0
- package/lib/image-plugin.js.map +1 -0
- package/lib/image.js +113 -0
- package/lib/image.js.map +1 -0
- package/lib/index.js +1665 -0
- package/lib/index.js.map +1 -0
- package/lib/link.js +186 -0
- package/lib/link.js.map +1 -0
- package/lib/script.js +102 -0
- package/lib/script.js.map +1 -0
- package/lib/seo.js +136 -0
- package/lib/seo.js.map +1 -0
- package/lib/theme.js +165 -0
- package/lib/theme.js.map +1 -0
- package/lib/types/adapters/bun.d.ts +6 -0
- package/lib/types/adapters/bun.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +10 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/node.d.ts +6 -0
- package/lib/types/adapters/node.d.ts.map +1 -0
- package/lib/types/adapters/static.d.ts +7 -0
- package/lib/types/adapters/static.d.ts.map +1 -0
- package/lib/types/app.d.ts +24 -0
- package/lib/types/app.d.ts.map +1 -0
- package/lib/types/cache.d.ts +54 -0
- package/lib/types/cache.d.ts.map +1 -0
- package/lib/types/client.d.ts +19 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/config.d.ts +18 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/entry-server.d.ts +26 -0
- package/lib/types/entry-server.d.ts.map +1 -0
- package/lib/types/font.d.ts +119 -0
- package/lib/types/font.d.ts.map +1 -0
- package/lib/types/fs-router.d.ts +33 -0
- package/lib/types/fs-router.d.ts.map +1 -0
- package/lib/types/image-plugin.d.ts +79 -0
- package/lib/types/image-plugin.d.ts.map +1 -0
- package/lib/types/image.d.ts +50 -0
- package/lib/types/image.d.ts.map +1 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/isr.d.ts +9 -0
- package/lib/types/isr.d.ts.map +1 -0
- package/lib/types/link.d.ts +116 -0
- package/lib/types/link.d.ts.map +1 -0
- package/lib/types/script.d.ts +34 -0
- package/lib/types/script.d.ts.map +1 -0
- package/lib/types/seo.d.ts +88 -0
- package/lib/types/seo.d.ts.map +1 -0
- package/lib/types/theme.d.ts +38 -0
- package/lib/types/theme.d.ts.map +1 -0
- package/lib/types/types.d.ts +104 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/utils/use-intersection-observer.d.ts +10 -0
- package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
- package/lib/types/utils/with-headers.d.ts +6 -0
- package/lib/types/utils/with-headers.d.ts.map +1 -0
- package/lib/types/vite-plugin.d.ts +17 -0
- package/lib/types/vite-plugin.d.ts.map +1 -0
- package/package.json +100 -0
- package/src/adapters/bun.ts +65 -0
- package/src/adapters/index.ts +29 -0
- package/src/adapters/node.ts +113 -0
- package/src/adapters/static.ts +17 -0
- package/src/app.ts +62 -0
- package/src/cache.ts +149 -0
- package/src/client.ts +43 -0
- package/src/config.ts +36 -0
- package/src/entry-server.ts +51 -0
- package/src/font.ts +461 -0
- package/src/fs-router.ts +380 -0
- package/src/image-plugin.ts +452 -0
- package/src/image.tsx +167 -0
- package/src/index.ts +119 -0
- package/src/isr.ts +95 -0
- package/src/link.tsx +266 -0
- package/src/script.tsx +133 -0
- package/src/seo.ts +281 -0
- package/src/sharp.d.ts +20 -0
- package/src/theme.tsx +162 -0
- package/src/types.ts +130 -0
- package/src/utils/use-intersection-observer.ts +36 -0
- package/src/utils/with-headers.ts +16 -0
- package/src/vite-plugin.ts +92 -0
|
@@ -0,0 +1,452 @@
|
|
|
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
|
+
// biome-ignore lint/suspicious/noConsole: intentional build-time warning
|
|
11
|
+
console.warn(
|
|
12
|
+
'\n[zero:image] 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
|
+
// // → { src, srcset, width, height, placeholder }
|
|
27
|
+
//
|
|
28
|
+
// Or use the component helper:
|
|
29
|
+
// import { Image } from "@pyreon/zero/image"
|
|
30
|
+
// <Image src="/hero.jpg" width={1920} height={1080} optimize />
|
|
31
|
+
|
|
32
|
+
export interface ImagePluginConfig {
|
|
33
|
+
/** Output directory for processed images. Default: "assets/img" */
|
|
34
|
+
outDir?: string
|
|
35
|
+
/** Default widths for responsive images. Default: [640, 1024, 1920] */
|
|
36
|
+
widths?: number[]
|
|
37
|
+
/** Output formats. Default: ["webp"] */
|
|
38
|
+
formats?: ImageFormat[]
|
|
39
|
+
/** Quality for lossy formats (1-100). Default: 80 */
|
|
40
|
+
quality?: number
|
|
41
|
+
/** Blur placeholder size in px. Default: 16 */
|
|
42
|
+
placeholderSize?: number
|
|
43
|
+
/** File patterns to process. Default: /\.(jpe?g|png|webp|avif)$/i */
|
|
44
|
+
include?: RegExp
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ImageFormat = 'webp' | 'avif' | 'jpeg' | 'png'
|
|
48
|
+
|
|
49
|
+
/** Per-format source set for <picture> <source> elements. */
|
|
50
|
+
export interface FormatSource {
|
|
51
|
+
/** MIME type. e.g. "image/webp", "image/avif" */
|
|
52
|
+
type: string
|
|
53
|
+
/** srcset string for this format. e.g. "/img-640.webp 640w, /img-1920.webp 1920w" */
|
|
54
|
+
srcset: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ProcessedImage {
|
|
58
|
+
/** Fallback source path (last format, largest width). */
|
|
59
|
+
src: string
|
|
60
|
+
/** Fallback srcset string (last format). */
|
|
61
|
+
srcset: string
|
|
62
|
+
/** Intrinsic width. */
|
|
63
|
+
width: number
|
|
64
|
+
/** Intrinsic height. */
|
|
65
|
+
height: number
|
|
66
|
+
/** Base64 blur placeholder data URI. */
|
|
67
|
+
placeholder: string
|
|
68
|
+
/** Per-format source sets for <picture> element. Ordered by priority (best format first). */
|
|
69
|
+
formats: FormatSource[]
|
|
70
|
+
/** Flat list of all sources. */
|
|
71
|
+
sources: Array<{ src: string; width: number; format: string }>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Zero image processing Vite plugin.
|
|
78
|
+
*
|
|
79
|
+
* Transforms image imports with query params into optimized responsive images:
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* // vite.config.ts
|
|
83
|
+
* import { imagePlugin } from "@pyreon/zero/image-plugin"
|
|
84
|
+
*
|
|
85
|
+
* export default {
|
|
86
|
+
* plugins: [
|
|
87
|
+
* pyreon(),
|
|
88
|
+
* zero(),
|
|
89
|
+
* imagePlugin({ widths: [480, 960, 1440], quality: 85 }),
|
|
90
|
+
* ],
|
|
91
|
+
* }
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* // In a component — import with ?optimize
|
|
95
|
+
* import hero from "./images/hero.jpg?optimize"
|
|
96
|
+
* // hero = { src, srcset, width, height, placeholder }
|
|
97
|
+
*
|
|
98
|
+
* <Image {...hero} alt="Hero" priority />
|
|
99
|
+
*/
|
|
100
|
+
export function imagePlugin(config: ImagePluginConfig = {}): Plugin {
|
|
101
|
+
const defaultWidths = config.widths ?? [640, 1024, 1920]
|
|
102
|
+
const defaultFormats = config.formats ?? ['webp']
|
|
103
|
+
const quality = config.quality ?? 80
|
|
104
|
+
const placeholderSize = config.placeholderSize ?? 16
|
|
105
|
+
const outSubDir = config.outDir ?? 'assets/img'
|
|
106
|
+
const include = config.include ?? IMAGE_EXT_RE
|
|
107
|
+
|
|
108
|
+
let root = ''
|
|
109
|
+
let outDir = ''
|
|
110
|
+
let isBuild = false
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
name: 'pyreon-zero-images',
|
|
114
|
+
enforce: 'pre',
|
|
115
|
+
|
|
116
|
+
configResolved(resolvedConfig) {
|
|
117
|
+
root = resolvedConfig.root
|
|
118
|
+
outDir = resolvedConfig.build.outDir
|
|
119
|
+
isBuild = resolvedConfig.command === 'build'
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async resolveId(id) {
|
|
123
|
+
// Handle ?optimize query on image imports
|
|
124
|
+
if (id.includes('?optimize') && include.test(id.split('?')[0]!)) {
|
|
125
|
+
return `\0virtual:zero-image:${id}`
|
|
126
|
+
}
|
|
127
|
+
return null
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async load(id) {
|
|
131
|
+
if (!id.startsWith('\0virtual:zero-image:')) return null
|
|
132
|
+
|
|
133
|
+
const rawPath =
|
|
134
|
+
id.replace('\0virtual:zero-image:', '').split('?')[0] ?? id
|
|
135
|
+
const absPath = rawPath.startsWith('/')
|
|
136
|
+
? join(root, 'public', rawPath)
|
|
137
|
+
: rawPath
|
|
138
|
+
|
|
139
|
+
if (!isBuild) {
|
|
140
|
+
const result = await loadDevImage(absPath, rawPath, placeholderSize)
|
|
141
|
+
return `export default ${JSON.stringify(result)}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const processed = await processImage(absPath, {
|
|
145
|
+
widths: defaultWidths,
|
|
146
|
+
formats: defaultFormats,
|
|
147
|
+
quality,
|
|
148
|
+
placeholderSize,
|
|
149
|
+
outSubDir,
|
|
150
|
+
outDir: join(root, outDir),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
await emitProcessedSources(processed, outSubDir, this)
|
|
154
|
+
rebuildFormatSrcsets(processed, absPath)
|
|
155
|
+
|
|
156
|
+
return `export default ${JSON.stringify(processed)}`
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function loadDevImage(
|
|
162
|
+
absPath: string,
|
|
163
|
+
rawPath: string,
|
|
164
|
+
placeholderSize: number,
|
|
165
|
+
): Promise<ProcessedImage> {
|
|
166
|
+
const metadata = await getImageMetadata(absPath)
|
|
167
|
+
const publicPath = rawPath.startsWith('/') ? rawPath : `/@fs/${absPath}`
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
src: publicPath,
|
|
171
|
+
srcset: '',
|
|
172
|
+
width: metadata.width,
|
|
173
|
+
height: metadata.height,
|
|
174
|
+
placeholder: await generateBlurPlaceholder(absPath, placeholderSize),
|
|
175
|
+
formats: [],
|
|
176
|
+
sources: [{ src: publicPath, width: metadata.width, format: 'original' }],
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function emitProcessedSources(
|
|
181
|
+
processed: ProcessedImage,
|
|
182
|
+
outSubDir: string,
|
|
183
|
+
ctx: {
|
|
184
|
+
emitFile: (f: {
|
|
185
|
+
type: 'asset'
|
|
186
|
+
fileName: string
|
|
187
|
+
source: Uint8Array
|
|
188
|
+
}) => void
|
|
189
|
+
},
|
|
190
|
+
) {
|
|
191
|
+
for (const source of processed.sources) {
|
|
192
|
+
const fileName = join(outSubDir, basename(source.src))
|
|
193
|
+
const content = await readFile(source.src)
|
|
194
|
+
ctx.emitFile({ type: 'asset', fileName, source: content })
|
|
195
|
+
source.src = `/${fileName}`
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function rebuildFormatSrcsets(processed: ProcessedImage, fallbackPath: string) {
|
|
200
|
+
const formatGroups = new Map<string, string[]>()
|
|
201
|
+
for (const s of processed.sources) {
|
|
202
|
+
let group = formatGroups.get(s.format)
|
|
203
|
+
if (!group) {
|
|
204
|
+
group = []
|
|
205
|
+
formatGroups.set(s.format, group)
|
|
206
|
+
}
|
|
207
|
+
group.push(`${s.src} ${s.width}w`)
|
|
208
|
+
}
|
|
209
|
+
processed.formats = [...formatGroups.entries()].map(([fmt, entries]) => ({
|
|
210
|
+
type: `image/${fmt}`,
|
|
211
|
+
srcset: entries.join(', '),
|
|
212
|
+
}))
|
|
213
|
+
|
|
214
|
+
const lastFormat = processed.formats.at(-1)
|
|
215
|
+
processed.srcset = lastFormat?.srcset ?? ''
|
|
216
|
+
processed.src = processed.sources.at(-1)?.src ?? fallbackPath
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Image processing utilities ─────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
interface ProcessOptions {
|
|
222
|
+
widths: number[]
|
|
223
|
+
formats: ImageFormat[]
|
|
224
|
+
quality: number
|
|
225
|
+
placeholderSize: number
|
|
226
|
+
outSubDir: string
|
|
227
|
+
outDir: string
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function processImage(
|
|
231
|
+
absPath: string,
|
|
232
|
+
opts: ProcessOptions,
|
|
233
|
+
): Promise<ProcessedImage> {
|
|
234
|
+
const metadata = await getImageMetadata(absPath)
|
|
235
|
+
const ext = extname(absPath)
|
|
236
|
+
const name = basename(absPath, ext)
|
|
237
|
+
const sources: Array<{ src: string; width: number; format: string }> = []
|
|
238
|
+
|
|
239
|
+
// Ensure output directory exists
|
|
240
|
+
const processedDir = join(opts.outDir, opts.outSubDir)
|
|
241
|
+
if (!existsSync(processedDir)) {
|
|
242
|
+
await mkdir(processedDir, { recursive: true })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Generate resized variants — iterate formats first so sources are grouped by format
|
|
246
|
+
for (const format of opts.formats) {
|
|
247
|
+
for (const targetWidth of opts.widths) {
|
|
248
|
+
// Don't upscale
|
|
249
|
+
const width = Math.min(targetWidth, metadata.width)
|
|
250
|
+
const outName = `${name}-${width}.${format}`
|
|
251
|
+
const outPath = join(processedDir, outName)
|
|
252
|
+
|
|
253
|
+
await resizeImage(absPath, outPath, width, format, opts.quality)
|
|
254
|
+
sources.push({ src: outPath, width, format })
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Build per-format source sets for <picture>
|
|
259
|
+
const formatGroups = new Map<string, Array<{ src: string; width: number }>>()
|
|
260
|
+
for (const s of sources) {
|
|
261
|
+
let group = formatGroups.get(s.format)
|
|
262
|
+
if (!group) {
|
|
263
|
+
group = []
|
|
264
|
+
formatGroups.set(s.format, group)
|
|
265
|
+
}
|
|
266
|
+
group.push({ src: s.src, width: s.width })
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const formats: FormatSource[] = [...formatGroups.entries()].map(
|
|
270
|
+
([fmt, group]) => ({
|
|
271
|
+
type: `image/${fmt === 'jpeg' ? 'jpeg' : fmt}`,
|
|
272
|
+
srcset: group.map((s) => `${s.src} ${s.width}w`).join(', '),
|
|
273
|
+
}),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
// Fallback: last format's srcset
|
|
277
|
+
const fallbackFormat = formats[formats.length - 1]
|
|
278
|
+
const fallbackSources = formatGroups.get([...formatGroups.keys()].pop()!)!
|
|
279
|
+
|
|
280
|
+
// Generate blur placeholder
|
|
281
|
+
const placeholder = await generateBlurPlaceholder(
|
|
282
|
+
absPath,
|
|
283
|
+
opts.placeholderSize,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,
|
|
288
|
+
srcset: fallbackFormat?.srcset ?? '',
|
|
289
|
+
width: metadata.width,
|
|
290
|
+
height: metadata.height,
|
|
291
|
+
placeholder,
|
|
292
|
+
formats,
|
|
293
|
+
sources,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
interface ImageMetadata {
|
|
298
|
+
width: number
|
|
299
|
+
height: number
|
|
300
|
+
format: string
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Read basic image metadata.
|
|
305
|
+
* Uses minimal binary header parsing — no external dependencies.
|
|
306
|
+
*/
|
|
307
|
+
async function getImageMetadata(absPath: string): Promise<ImageMetadata> {
|
|
308
|
+
const buffer = await readFile(absPath)
|
|
309
|
+
const ext = extname(absPath).toLowerCase()
|
|
310
|
+
|
|
311
|
+
if (ext === '.png') {
|
|
312
|
+
// PNG: width at bytes 16-19, height at 20-23 (big-endian)
|
|
313
|
+
const width = buffer.readUInt32BE(16)
|
|
314
|
+
const height = buffer.readUInt32BE(20)
|
|
315
|
+
return { width, height, format: 'png' }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (ext === '.jpg' || ext === '.jpeg') {
|
|
319
|
+
// JPEG: scan for SOF markers
|
|
320
|
+
const dimensions = parseJpegDimensions(buffer)
|
|
321
|
+
return { ...dimensions, format: 'jpeg' }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (ext === '.webp') {
|
|
325
|
+
// WebP: VP8 header
|
|
326
|
+
const dimensions = parseWebPDimensions(buffer)
|
|
327
|
+
return { ...dimensions, format: 'webp' }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Fallback
|
|
331
|
+
return { width: 0, height: 0, format: ext.slice(1) }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** @internal Exported for testing */
|
|
335
|
+
export function parseJpegDimensions(buffer: Buffer): {
|
|
336
|
+
width: number
|
|
337
|
+
height: number
|
|
338
|
+
} {
|
|
339
|
+
let offset = 2 // Skip SOI marker
|
|
340
|
+
while (offset < buffer.length) {
|
|
341
|
+
if (buffer[offset] !== 0xff) break
|
|
342
|
+
const marker = buffer[offset + 1]!
|
|
343
|
+
// SOF markers (0xC0-0xCF except 0xC4, 0xC8, 0xCC)
|
|
344
|
+
if (
|
|
345
|
+
marker >= 0xc0 &&
|
|
346
|
+
marker <= 0xcf &&
|
|
347
|
+
marker !== 0xc4 &&
|
|
348
|
+
marker !== 0xc8 &&
|
|
349
|
+
marker !== 0xcc
|
|
350
|
+
) {
|
|
351
|
+
const height = buffer.readUInt16BE(offset + 5)
|
|
352
|
+
const width = buffer.readUInt16BE(offset + 7)
|
|
353
|
+
return { width, height }
|
|
354
|
+
}
|
|
355
|
+
const length = buffer.readUInt16BE(offset + 2)
|
|
356
|
+
offset += 2 + length
|
|
357
|
+
}
|
|
358
|
+
return { width: 0, height: 0 }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** @internal Exported for testing */
|
|
362
|
+
export function parseWebPDimensions(buffer: Buffer): {
|
|
363
|
+
width: number
|
|
364
|
+
height: number
|
|
365
|
+
} {
|
|
366
|
+
// RIFF header: bytes 0-3 = "RIFF", 8-11 = "WEBP"
|
|
367
|
+
const chunk = buffer.toString('ascii', 12, 16)
|
|
368
|
+
if (chunk === 'VP8 ') {
|
|
369
|
+
// Lossy VP8
|
|
370
|
+
const width = buffer.readUInt16LE(26) & 0x3fff
|
|
371
|
+
const height = buffer.readUInt16LE(28) & 0x3fff
|
|
372
|
+
return { width, height }
|
|
373
|
+
}
|
|
374
|
+
if (chunk === 'VP8L') {
|
|
375
|
+
// Lossless VP8L
|
|
376
|
+
const bits = buffer.readUInt32LE(21)
|
|
377
|
+
const width = (bits & 0x3fff) + 1
|
|
378
|
+
const height = ((bits >> 14) & 0x3fff) + 1
|
|
379
|
+
return { width, height }
|
|
380
|
+
}
|
|
381
|
+
if (chunk === 'VP8X') {
|
|
382
|
+
// Extended VP8X
|
|
383
|
+
const width =
|
|
384
|
+
1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xffffff)
|
|
385
|
+
const height =
|
|
386
|
+
1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xffffff)
|
|
387
|
+
return { width, height }
|
|
388
|
+
}
|
|
389
|
+
return { width: 0, height: 0 }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Resize an image using native platform capabilities.
|
|
394
|
+
* Uses sharp if available, falls back to canvas API.
|
|
395
|
+
*/
|
|
396
|
+
async function resizeImage(
|
|
397
|
+
input: string,
|
|
398
|
+
output: string,
|
|
399
|
+
width: number,
|
|
400
|
+
format: ImageFormat,
|
|
401
|
+
quality: number,
|
|
402
|
+
): Promise<void> {
|
|
403
|
+
try {
|
|
404
|
+
// Try sharp (the standard Node.js image processing library)
|
|
405
|
+
const sharp = await import('sharp').then((m) => m.default ?? m)
|
|
406
|
+
let pipeline = sharp(input).resize(width)
|
|
407
|
+
|
|
408
|
+
switch (format) {
|
|
409
|
+
case 'webp':
|
|
410
|
+
pipeline = pipeline.webp({ quality })
|
|
411
|
+
break
|
|
412
|
+
case 'avif':
|
|
413
|
+
pipeline = pipeline.avif({ quality })
|
|
414
|
+
break
|
|
415
|
+
case 'jpeg':
|
|
416
|
+
pipeline = pipeline.jpeg({ quality, mozjpeg: true })
|
|
417
|
+
break
|
|
418
|
+
case 'png':
|
|
419
|
+
pipeline = pipeline.png({ compressionLevel: 9 })
|
|
420
|
+
break
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
await pipeline.toFile(output)
|
|
424
|
+
} catch {
|
|
425
|
+
// sharp not available — copy original as fallback
|
|
426
|
+
warnSharpMissing()
|
|
427
|
+
const content = await readFile(input)
|
|
428
|
+
await writeFile(output, content)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Generate a tiny blur placeholder as a base64 data URI.
|
|
434
|
+
*/
|
|
435
|
+
async function generateBlurPlaceholder(
|
|
436
|
+
input: string,
|
|
437
|
+
size: number,
|
|
438
|
+
): Promise<string> {
|
|
439
|
+
try {
|
|
440
|
+
const sharp = await import('sharp').then((m) => m.default ?? m)
|
|
441
|
+
const buffer = await sharp(input)
|
|
442
|
+
.resize(size, size, { fit: 'inside' })
|
|
443
|
+
.blur(2)
|
|
444
|
+
.webp({ quality: 20 })
|
|
445
|
+
.toBuffer()
|
|
446
|
+
|
|
447
|
+
return `data:image/webp;base64,${buffer.toString('base64')}`
|
|
448
|
+
} catch {
|
|
449
|
+
// sharp not available — return a transparent placeholder
|
|
450
|
+
return "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E"
|
|
451
|
+
}
|
|
452
|
+
}
|
package/src/image.tsx
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createRef } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import type { FormatSource } from './image-plugin'
|
|
4
|
+
import { useIntersectionObserver } from './utils/use-intersection-observer'
|
|
5
|
+
|
|
6
|
+
// ─── Image optimization component ───────────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// <Image> provides:
|
|
9
|
+
// - Lazy loading via IntersectionObserver (loads when near viewport)
|
|
10
|
+
// - Automatic width/height to prevent CLS (Cumulative Layout Shift)
|
|
11
|
+
// - Responsive srcset generation from width descriptors
|
|
12
|
+
// - Multi-format support via <picture> (WebP/AVIF with fallback)
|
|
13
|
+
// - Blur-up placeholder while loading
|
|
14
|
+
// - Priority loading for above-the-fold images
|
|
15
|
+
|
|
16
|
+
export interface ImageProps {
|
|
17
|
+
/** Image source URL. */
|
|
18
|
+
src: string
|
|
19
|
+
/** Alt text (required for accessibility). */
|
|
20
|
+
alt: string
|
|
21
|
+
/** Intrinsic width of the image. */
|
|
22
|
+
width: number
|
|
23
|
+
/** Intrinsic height of the image. */
|
|
24
|
+
height: number
|
|
25
|
+
/** Responsive sizes attribute. Default: "100vw" */
|
|
26
|
+
sizes?: string
|
|
27
|
+
/** Responsive srcset string or source array. */
|
|
28
|
+
srcset?: string | ImageSource[]
|
|
29
|
+
/** Per-format source sets for <picture>. Provided automatically by imagePlugin. */
|
|
30
|
+
formats?: FormatSource[]
|
|
31
|
+
/** Loading strategy. "lazy" uses IntersectionObserver, "eager" loads immediately. Default: "lazy" */
|
|
32
|
+
loading?: 'lazy' | 'eager'
|
|
33
|
+
/** Mark as priority (LCP image). Disables lazy loading, adds fetchpriority="high". */
|
|
34
|
+
priority?: boolean
|
|
35
|
+
/** Low-quality placeholder image URL or base64 data URI for blur-up effect. */
|
|
36
|
+
placeholder?: string
|
|
37
|
+
/** CSS class name. */
|
|
38
|
+
class?: string
|
|
39
|
+
/** Inline styles. */
|
|
40
|
+
style?: string
|
|
41
|
+
/** CSS object-fit. Default: "cover" */
|
|
42
|
+
fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
|
|
43
|
+
/** Decode async. Default: true */
|
|
44
|
+
decoding?: 'sync' | 'async' | 'auto'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ImageSource {
|
|
48
|
+
src: string
|
|
49
|
+
width: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Optimized image component with lazy loading, responsive images,
|
|
54
|
+
* multi-format <picture> support, and blur-up placeholders.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* // With imagePlugin — spread the import directly
|
|
58
|
+
* import hero from "./hero.jpg?optimize"
|
|
59
|
+
* <Image {...hero} alt="Hero" priority />
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* // Manual usage
|
|
63
|
+
* <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
|
|
64
|
+
*/
|
|
65
|
+
export function Image(props: ImageProps) {
|
|
66
|
+
const isEager = props.priority || props.loading === 'eager'
|
|
67
|
+
const loaded = signal(isEager)
|
|
68
|
+
const inView = signal(isEager)
|
|
69
|
+
const containerRef = createRef<HTMLElement>()
|
|
70
|
+
|
|
71
|
+
// Resolve srcset from string or array
|
|
72
|
+
const resolvedSrcset =
|
|
73
|
+
typeof props.srcset === 'string'
|
|
74
|
+
? props.srcset
|
|
75
|
+
: props.srcset?.map((s) => `${s.src} ${s.width}w`).join(', ')
|
|
76
|
+
|
|
77
|
+
const sizes = props.sizes ?? '100vw'
|
|
78
|
+
const fit = props.fit ?? 'cover'
|
|
79
|
+
const hasFormats = props.formats && props.formats.length > 0
|
|
80
|
+
const aspectRatio = `${props.width} / ${props.height}`
|
|
81
|
+
|
|
82
|
+
if (!isEager) {
|
|
83
|
+
useIntersectionObserver(
|
|
84
|
+
() => containerRef.current ?? undefined,
|
|
85
|
+
() => inView.set(true),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Static styles (don't depend on signals)
|
|
90
|
+
const containerStyle = [
|
|
91
|
+
'position: relative',
|
|
92
|
+
'overflow: hidden',
|
|
93
|
+
`aspect-ratio: ${aspectRatio}`,
|
|
94
|
+
`max-width: ${props.width}px`,
|
|
95
|
+
'width: 100%',
|
|
96
|
+
props.style,
|
|
97
|
+
]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join('; ')
|
|
100
|
+
|
|
101
|
+
const imgEl = (
|
|
102
|
+
<img
|
|
103
|
+
src={() => (inView() ? props.src : '')}
|
|
104
|
+
srcset={() =>
|
|
105
|
+
!hasFormats && inView() && resolvedSrcset ? resolvedSrcset : ''
|
|
106
|
+
}
|
|
107
|
+
sizes={resolvedSrcset ? sizes : undefined}
|
|
108
|
+
alt={props.alt}
|
|
109
|
+
width={props.width}
|
|
110
|
+
height={props.height}
|
|
111
|
+
loading={isEager ? 'eager' : 'lazy'}
|
|
112
|
+
decoding={props.decoding ?? 'async'}
|
|
113
|
+
fetchpriority={props.priority ? 'high' : undefined}
|
|
114
|
+
onload={() => loaded.set(true)}
|
|
115
|
+
style={() =>
|
|
116
|
+
[
|
|
117
|
+
'display: block',
|
|
118
|
+
'width: 100%',
|
|
119
|
+
'height: 100%',
|
|
120
|
+
`object-fit: ${fit}`,
|
|
121
|
+
'transition: opacity 0.3s ease',
|
|
122
|
+
props.placeholder && !loaded() ? 'opacity: 0' : 'opacity: 1',
|
|
123
|
+
].join('; ')
|
|
124
|
+
}
|
|
125
|
+
/>
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div ref={containerRef} class={props.class} style={containerStyle}>
|
|
130
|
+
{props.placeholder && (
|
|
131
|
+
<img
|
|
132
|
+
src={props.placeholder}
|
|
133
|
+
alt=""
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
loading="eager"
|
|
136
|
+
style={() =>
|
|
137
|
+
[
|
|
138
|
+
'position: absolute',
|
|
139
|
+
'inset: 0',
|
|
140
|
+
'width: 100%',
|
|
141
|
+
'height: 100%',
|
|
142
|
+
'object-fit: cover',
|
|
143
|
+
'filter: blur(20px)',
|
|
144
|
+
'transform: scale(1.1)',
|
|
145
|
+
'transition: opacity 0.4s ease',
|
|
146
|
+
loaded() ? 'opacity: 0; pointer-events: none' : 'opacity: 1',
|
|
147
|
+
].join('; ')
|
|
148
|
+
}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
{hasFormats ? (
|
|
152
|
+
<picture>
|
|
153
|
+
{props.formats?.map((fmt) => (
|
|
154
|
+
<source
|
|
155
|
+
type={fmt.type}
|
|
156
|
+
srcset={() => (inView() ? fmt.srcset : undefined)}
|
|
157
|
+
sizes={sizes}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
{imgEl}
|
|
161
|
+
</picture>
|
|
162
|
+
) : (
|
|
163
|
+
imgEl
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|