@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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/lib/cache.js +80 -0
  4. package/lib/cache.js.map +1 -0
  5. package/lib/client.js +58 -0
  6. package/lib/client.js.map +1 -0
  7. package/lib/config.js +35 -0
  8. package/lib/config.js.map +1 -0
  9. package/lib/font.js +251 -0
  10. package/lib/font.js.map +1 -0
  11. package/lib/fs-router-BkbIWqek.js +30 -0
  12. package/lib/fs-router-BkbIWqek.js.map +1 -0
  13. package/lib/fs-router-jfd1QGLB.js +261 -0
  14. package/lib/fs-router-jfd1QGLB.js.map +1 -0
  15. package/lib/image-plugin.js +289 -0
  16. package/lib/image-plugin.js.map +1 -0
  17. package/lib/image.js +113 -0
  18. package/lib/image.js.map +1 -0
  19. package/lib/index.js +1665 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/link.js +186 -0
  22. package/lib/link.js.map +1 -0
  23. package/lib/script.js +102 -0
  24. package/lib/script.js.map +1 -0
  25. package/lib/seo.js +136 -0
  26. package/lib/seo.js.map +1 -0
  27. package/lib/theme.js +165 -0
  28. package/lib/theme.js.map +1 -0
  29. package/lib/types/adapters/bun.d.ts +6 -0
  30. package/lib/types/adapters/bun.d.ts.map +1 -0
  31. package/lib/types/adapters/index.d.ts +10 -0
  32. package/lib/types/adapters/index.d.ts.map +1 -0
  33. package/lib/types/adapters/node.d.ts +6 -0
  34. package/lib/types/adapters/node.d.ts.map +1 -0
  35. package/lib/types/adapters/static.d.ts +7 -0
  36. package/lib/types/adapters/static.d.ts.map +1 -0
  37. package/lib/types/app.d.ts +24 -0
  38. package/lib/types/app.d.ts.map +1 -0
  39. package/lib/types/cache.d.ts +54 -0
  40. package/lib/types/cache.d.ts.map +1 -0
  41. package/lib/types/client.d.ts +19 -0
  42. package/lib/types/client.d.ts.map +1 -0
  43. package/lib/types/config.d.ts +18 -0
  44. package/lib/types/config.d.ts.map +1 -0
  45. package/lib/types/entry-server.d.ts +26 -0
  46. package/lib/types/entry-server.d.ts.map +1 -0
  47. package/lib/types/font.d.ts +119 -0
  48. package/lib/types/font.d.ts.map +1 -0
  49. package/lib/types/fs-router.d.ts +33 -0
  50. package/lib/types/fs-router.d.ts.map +1 -0
  51. package/lib/types/image-plugin.d.ts +79 -0
  52. package/lib/types/image-plugin.d.ts.map +1 -0
  53. package/lib/types/image.d.ts +50 -0
  54. package/lib/types/image.d.ts.map +1 -0
  55. package/lib/types/index.d.ts +27 -0
  56. package/lib/types/index.d.ts.map +1 -0
  57. package/lib/types/isr.d.ts +9 -0
  58. package/lib/types/isr.d.ts.map +1 -0
  59. package/lib/types/link.d.ts +116 -0
  60. package/lib/types/link.d.ts.map +1 -0
  61. package/lib/types/script.d.ts +34 -0
  62. package/lib/types/script.d.ts.map +1 -0
  63. package/lib/types/seo.d.ts +88 -0
  64. package/lib/types/seo.d.ts.map +1 -0
  65. package/lib/types/theme.d.ts +38 -0
  66. package/lib/types/theme.d.ts.map +1 -0
  67. package/lib/types/types.d.ts +104 -0
  68. package/lib/types/types.d.ts.map +1 -0
  69. package/lib/types/utils/use-intersection-observer.d.ts +10 -0
  70. package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
  71. package/lib/types/utils/with-headers.d.ts +6 -0
  72. package/lib/types/utils/with-headers.d.ts.map +1 -0
  73. package/lib/types/vite-plugin.d.ts +17 -0
  74. package/lib/types/vite-plugin.d.ts.map +1 -0
  75. package/package.json +100 -0
  76. package/src/adapters/bun.ts +65 -0
  77. package/src/adapters/index.ts +29 -0
  78. package/src/adapters/node.ts +113 -0
  79. package/src/adapters/static.ts +17 -0
  80. package/src/app.ts +62 -0
  81. package/src/cache.ts +149 -0
  82. package/src/client.ts +43 -0
  83. package/src/config.ts +36 -0
  84. package/src/entry-server.ts +51 -0
  85. package/src/font.ts +461 -0
  86. package/src/fs-router.ts +380 -0
  87. package/src/image-plugin.ts +452 -0
  88. package/src/image.tsx +167 -0
  89. package/src/index.ts +119 -0
  90. package/src/isr.ts +95 -0
  91. package/src/link.tsx +266 -0
  92. package/src/script.tsx +133 -0
  93. package/src/seo.ts +281 -0
  94. package/src/sharp.d.ts +20 -0
  95. package/src/theme.tsx +162 -0
  96. package/src/types.ts +130 -0
  97. package/src/utils/use-intersection-observer.ts +36 -0
  98. package/src/utils/with-headers.ts +16 -0
  99. 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
+ }