@pyreon/zero 0.12.13 → 0.12.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +41 -5
- package/lib/client.js.map +1 -1
- package/lib/env.js +6 -6
- package/lib/env.js.map +1 -1
- package/lib/favicon.js +2 -2
- package/lib/favicon.js.map +1 -1
- package/lib/font.js +2 -2
- package/lib/font.js.map +1 -1
- package/lib/i18n-routing.js.map +1 -1
- package/lib/image-plugin.js +1 -1
- package/lib/image-plugin.js.map +1 -1
- package/lib/index.js +39 -10
- package/lib/index.js.map +1 -1
- package/lib/link.js +12 -4
- package/lib/link.js.map +1 -1
- package/lib/meta.js.map +1 -1
- package/lib/og-image.js +2 -2
- package/lib/og-image.js.map +1 -1
- package/lib/script.js +1 -0
- package/lib/script.js.map +1 -1
- package/lib/server.js +132 -12
- package/lib/server.js.map +1 -1
- package/lib/theme.js +27 -7
- package/lib/theme.js.map +1 -1
- package/lib/types/client.d.ts +26 -0
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/config.d.ts +7 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/index.d.ts +13 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/link.d.ts.map +1 -1
- package/lib/types/server.d.ts +14 -0
- package/lib/types/server.d.ts.map +1 -1
- package/lib/types/theme.d.ts +6 -1
- package/lib/types/theme.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/adapters/index.ts +1 -1
- package/src/adapters/validate.ts +2 -2
- package/src/client.ts +84 -6
- package/src/env.ts +6 -6
- package/src/favicon.ts +3 -3
- package/src/font.ts +2 -2
- package/src/i18n-routing.ts +1 -1
- package/src/image-plugin.ts +1 -1
- package/src/isr.ts +34 -2
- package/src/link.tsx +21 -5
- package/src/og-image.ts +2 -2
- package/src/script.tsx +4 -0
- package/src/theme.tsx +33 -11
- package/src/types.ts +7 -0
- package/src/vite-plugin.ts +204 -2
package/lib/i18n-routing.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"i18n-routing.js","names":[],"sources":["../src/i18n-routing.ts"],"sourcesContent":["import { createContext } from '@pyreon/core'\nimport { signal } from '@pyreon/reactivity'\nimport type { Plugin } from 'vite'\n\n// ─── Localized routing ─────────────────────────────────────────────────────\n//\n// Adds locale-prefixed routes to Zero's file-system router:\n// - /about → /en/about, /de/about, /cs/about\n// - / → /en, /de, /cs (or default locale without prefix)\n// - Automatic locale detection from Accept-Language header\n// - Redirect to preferred locale\n// - hreflang link generation\n//\n// Usage:\n// import { i18nRouting } from \"@pyreon/zero\"\n// export default { plugins: [zero(), i18nRouting({ locales: [\"en\", \"de\"], defaultLocale: \"en\" })] }\n\nexport interface I18nRoutingConfig {\n /** Supported locales. e.g. [\"en\", \"de\", \"cs\"] */\n locales: string[]\n /** Default locale — served without prefix (/ instead of /en/). */\n defaultLocale: string\n /** Redirect root to detected locale. Default: true */\n detectLocale?: boolean\n /** Cookie name to persist locale preference. Default: \"locale\" */\n cookieName?: string\n /** URL strategy. Default: \"prefix-except-default\" */\n strategy?: 'prefix' | 'prefix-except-default'\n}\n\nexport interface LocaleContext {\n /** Current locale code. e.g. \"en\", \"de\" */\n locale: string\n /** All supported locales. */\n locales: string[]\n /** Default locale. */\n defaultLocale: string\n /** Build a localized path. e.g. localePath(\"/about\", \"de\") → \"/de/about\" */\n localePath: (path: string, locale?: string) => string\n /** Get hreflang alternates for the current path. */\n alternates: () => Array<{ locale: string; url: string }>\n}\n\n/**\n * Detect preferred locale from Accept-Language header.\n */\nexport function detectLocaleFromHeader(\n acceptLanguage: string | null | undefined,\n locales: string[],\n defaultLocale: string,\n): string {\n if (!acceptLanguage) return defaultLocale\n\n // Parse Accept-Language: en-US,en;q=0.9,de;q=0.8\n const preferred = acceptLanguage\n .split(',')\n .map((part) => {\n const [lang, q] = part.trim().split(';q=')\n return {\n lang: lang?.split('-')[0]?.toLowerCase() ?? '',\n quality: q ? Number.parseFloat(q) : 1,\n }\n })\n .sort((a, b) => b.quality - a.quality)\n\n for (const { lang } of preferred) {\n if (locales.includes(lang)) return lang\n }\n\n return defaultLocale\n}\n\n/**\n * Extract locale from a URL path.\n * Returns { locale, pathWithoutLocale }.\n */\nexport function extractLocaleFromPath(\n path: string,\n locales: string[],\n defaultLocale: string,\n): { locale: string; pathWithoutLocale: string } {\n const segments = path.split('/').filter(Boolean)\n const firstSegment = segments[0]?.toLowerCase()\n\n if (firstSegment && locales.includes(firstSegment)) {\n return {\n locale: firstSegment,\n pathWithoutLocale: '/' + segments.slice(1).join('/') || '/',\n }\n }\n\n return { locale: defaultLocale, pathWithoutLocale: path }\n}\n\n/**\n * Build a localized path.\n */\nexport function buildLocalePath(\n path: string,\n locale: string,\n defaultLocale: string,\n strategy: 'prefix' | 'prefix-except-default',\n): string {\n const clean = path === '/' ? '' : path\n if (strategy === 'prefix-except-default' && locale === defaultLocale) {\n return path\n }\n return `/${locale}${clean}`\n}\n\n/**\n * Create a LocaleContext for use in components and loaders.\n */\nexport function createLocaleContext(\n locale: string,\n path: string,\n config: I18nRoutingConfig,\n): LocaleContext {\n const strategy = config.strategy ?? 'prefix-except-default'\n\n return {\n locale,\n locales: config.locales,\n defaultLocale: config.defaultLocale,\n\n localePath(targetPath: string, targetLocale?: string) {\n return buildLocalePath(\n targetPath,\n targetLocale ?? locale,\n config.defaultLocale,\n strategy,\n )\n },\n\n alternates() {\n const { pathWithoutLocale } = extractLocaleFromPath(\n path,\n config.locales,\n config.defaultLocale,\n )\n return config.locales.map((loc) => ({\n locale: loc,\n url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy),\n }))\n },\n }\n}\n\n/**\n * I18n routing middleware for Zero's server.\n *\n * - Detects locale from URL prefix or Accept-Language header\n * - Redirects root to preferred locale (when detectLocale is true)\n * - Sets locale context for loaders and components\n *\n * @example\n * ```ts\n * // zero.config.ts\n * import { i18nRouting } from \"@pyreon/zero\"\n *\n * export default defineConfig({\n * plugins: [\n * i18nRouting({\n * locales: [\"en\", \"de\", \"cs\"],\n * defaultLocale: \"en\",\n * }),\n * ],\n * })\n * ```\n */\nexport function i18nRouting(config: I18nRoutingConfig): Plugin {\n const strategy = config.strategy ?? 'prefix-except-default'\n const detectEnabled = config.detectLocale !== false\n const cookieName = config.cookieName ?? 'locale'\n\n return {\n name: 'pyreon-zero-i18n-routing',\n\n // Route duplication is NOT handled here. The fs-router's `scanRouteFiles`\n // consumes the i18n config to duplicate routes per locale at build time.\n // This plugin only provides: (1) the server middleware for locale detection\n // and (2) the runtime hooks (useLocale, setLocale) for client-side use.\n configResolved() {},\n\n configureServer(server) {\n server.middlewares.use((req, res, next) => {\n const url = req.url ?? '/'\n\n // Skip static assets\n if (url.startsWith('/@') || url.startsWith('/__') || url.includes('.')) {\n return next()\n }\n\n const { locale } = extractLocaleFromPath(\n url,\n config.locales,\n config.defaultLocale,\n )\n\n // Redirect root to detected locale\n if (detectEnabled && url === '/') {\n const cookies = parseCookies(req.headers.cookie)\n const preferredFromCookie = cookies[cookieName]\n const preferredFromHeader = detectLocaleFromHeader(\n req.headers['accept-language'],\n config.locales,\n config.defaultLocale,\n )\n const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie)\n ? preferredFromCookie\n : preferredFromHeader\n\n if (strategy === 'prefix' || preferred !== config.defaultLocale) {\n res.writeHead(302, { Location: `/${preferred}/` })\n res.end()\n return\n }\n }\n\n // Attach locale context to request for loaders\n ;(req as any).__locale = locale\n ;(req as any).__localeContext = createLocaleContext(locale, url, config)\n\n // Update the module-level signal so useLocale() returns the correct value\n localeSignal.set(locale)\n\n next()\n })\n },\n }\n}\n\nfunction parseCookies(header: string | undefined): Record<string, string> {\n if (!header) return {}\n const result: Record<string, string> = {}\n for (const pair of header.split(';')) {\n const [key, value] = pair.trim().split('=')\n if (key && value) result[key] = decodeURIComponent(value)\n }\n return result\n}\n\n// ─── Reactive locale hook ───────────────────────────────────────────────────\n\n/** @internal Context for the current locale. */\nexport const LocaleCtx = createContext<string>('en')\n\n/** Current locale signal — set by the server middleware or client-side detection. */\nexport const localeSignal = signal('en')\n\n/**\n * Read the current locale reactively.\n *\n * Returns the locale signal value directly — reactive in both SSR and CSR.\n * The server middleware sets `localeSignal` per-request, and client-side\n * `setLocale()` updates it as well.\n *\n * @example\n * ```tsx\n * const locale = useLocale() // \"en\", \"de\", etc.\n * ```\n */\nexport function useLocale(): string {\n return localeSignal()\n}\n\n/**\n * Set the locale client-side and update the URL.\n *\n * @example\n * ```tsx\n * <button onClick={() => setLocale('de')}>Deutsch</button>\n * ```\n */\nexport function setLocale(\n locale: string,\n config: I18nRoutingConfig,\n): void {\n localeSignal.set(locale)\n\n // Persist to cookie\n if (typeof document !== 'undefined') {\n document.cookie = `${config.cookieName ?? 'locale'}=${locale}; path=/; max-age=31536000`\n }\n\n // Navigate to localized URL — use pushState to avoid full page reload\n if (typeof window !== 'undefined') {\n const strategy = config.strategy ?? 'prefix-except-default'\n const { pathWithoutLocale } = extractLocaleFromPath(\n window.location.pathname,\n config.locales,\n config.defaultLocale,\n )\n const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy)\n window.history.pushState(null, '', newPath)\n // Dispatch popstate so @pyreon/router picks up the URL change\n window.dispatchEvent(new PopStateEvent('popstate'))\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"i18n-routing.js","names":[],"sources":["../src/i18n-routing.ts"],"sourcesContent":["import { createContext } from '@pyreon/core'\nimport { signal } from '@pyreon/reactivity'\nimport type { Plugin } from 'vite'\n\n// ─── Localized routing ─────────────────────────────────────────────────────\n//\n// Adds locale-prefixed routes to Zero's file-system router:\n// - /about → /en/about, /de/about, /cs/about\n// - / → /en, /de, /cs (or default locale without prefix)\n// - Automatic locale detection from Accept-Language header\n// - Redirect to preferred locale\n// - hreflang link generation\n//\n// Usage:\n// import { i18nRouting } from \"@pyreon/zero\"\n// export default { plugins: [Pyreon], defaultLocale: \"en\" })] }\n\nexport interface I18nRoutingConfig {\n /** Supported locales. e.g. [\"en\", \"de\", \"cs\"] */\n locales: string[]\n /** Default locale — served without prefix (/ instead of /en/). */\n defaultLocale: string\n /** Redirect root to detected locale. Default: true */\n detectLocale?: boolean\n /** Cookie name to persist locale preference. Default: \"locale\" */\n cookieName?: string\n /** URL strategy. Default: \"prefix-except-default\" */\n strategy?: 'prefix' | 'prefix-except-default'\n}\n\nexport interface LocaleContext {\n /** Current locale code. e.g. \"en\", \"de\" */\n locale: string\n /** All supported locales. */\n locales: string[]\n /** Default locale. */\n defaultLocale: string\n /** Build a localized path. e.g. localePath(\"/about\", \"de\") → \"/de/about\" */\n localePath: (path: string, locale?: string) => string\n /** Get hreflang alternates for the current path. */\n alternates: () => Array<{ locale: string; url: string }>\n}\n\n/**\n * Detect preferred locale from Accept-Language header.\n */\nexport function detectLocaleFromHeader(\n acceptLanguage: string | null | undefined,\n locales: string[],\n defaultLocale: string,\n): string {\n if (!acceptLanguage) return defaultLocale\n\n // Parse Accept-Language: en-US,en;q=0.9,de;q=0.8\n const preferred = acceptLanguage\n .split(',')\n .map((part) => {\n const [lang, q] = part.trim().split(';q=')\n return {\n lang: lang?.split('-')[0]?.toLowerCase() ?? '',\n quality: q ? Number.parseFloat(q) : 1,\n }\n })\n .sort((a, b) => b.quality - a.quality)\n\n for (const { lang } of preferred) {\n if (locales.includes(lang)) return lang\n }\n\n return defaultLocale\n}\n\n/**\n * Extract locale from a URL path.\n * Returns { locale, pathWithoutLocale }.\n */\nexport function extractLocaleFromPath(\n path: string,\n locales: string[],\n defaultLocale: string,\n): { locale: string; pathWithoutLocale: string } {\n const segments = path.split('/').filter(Boolean)\n const firstSegment = segments[0]?.toLowerCase()\n\n if (firstSegment && locales.includes(firstSegment)) {\n return {\n locale: firstSegment,\n pathWithoutLocale: '/' + segments.slice(1).join('/') || '/',\n }\n }\n\n return { locale: defaultLocale, pathWithoutLocale: path }\n}\n\n/**\n * Build a localized path.\n */\nexport function buildLocalePath(\n path: string,\n locale: string,\n defaultLocale: string,\n strategy: 'prefix' | 'prefix-except-default',\n): string {\n const clean = path === '/' ? '' : path\n if (strategy === 'prefix-except-default' && locale === defaultLocale) {\n return path\n }\n return `/${locale}${clean}`\n}\n\n/**\n * Create a LocaleContext for use in components and loaders.\n */\nexport function createLocaleContext(\n locale: string,\n path: string,\n config: I18nRoutingConfig,\n): LocaleContext {\n const strategy = config.strategy ?? 'prefix-except-default'\n\n return {\n locale,\n locales: config.locales,\n defaultLocale: config.defaultLocale,\n\n localePath(targetPath: string, targetLocale?: string) {\n return buildLocalePath(\n targetPath,\n targetLocale ?? locale,\n config.defaultLocale,\n strategy,\n )\n },\n\n alternates() {\n const { pathWithoutLocale } = extractLocaleFromPath(\n path,\n config.locales,\n config.defaultLocale,\n )\n return config.locales.map((loc) => ({\n locale: loc,\n url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy),\n }))\n },\n }\n}\n\n/**\n * I18n routing middleware for Zero's server.\n *\n * - Detects locale from URL prefix or Accept-Language header\n * - Redirects root to preferred locale (when detectLocale is true)\n * - Sets locale context for loaders and components\n *\n * @example\n * ```ts\n * // zero.config.ts\n * import { i18nRouting } from \"@pyreon/zero\"\n *\n * export default defineConfig({\n * plugins: [\n * i18nRouting({\n * locales: [\"en\", \"de\", \"cs\"],\n * defaultLocale: \"en\",\n * }),\n * ],\n * })\n * ```\n */\nexport function i18nRouting(config: I18nRoutingConfig): Plugin {\n const strategy = config.strategy ?? 'prefix-except-default'\n const detectEnabled = config.detectLocale !== false\n const cookieName = config.cookieName ?? 'locale'\n\n return {\n name: 'pyreon-zero-i18n-routing',\n\n // Route duplication is NOT handled here. The fs-router's `scanRouteFiles`\n // consumes the i18n config to duplicate routes per locale at build time.\n // This plugin only provides: (1) the server middleware for locale detection\n // and (2) the runtime hooks (useLocale, setLocale) for client-side use.\n configResolved() {},\n\n configureServer(server) {\n server.middlewares.use((req, res, next) => {\n const url = req.url ?? '/'\n\n // Skip static assets\n if (url.startsWith('/@') || url.startsWith('/__') || url.includes('.')) {\n return next()\n }\n\n const { locale } = extractLocaleFromPath(\n url,\n config.locales,\n config.defaultLocale,\n )\n\n // Redirect root to detected locale\n if (detectEnabled && url === '/') {\n const cookies = parseCookies(req.headers.cookie)\n const preferredFromCookie = cookies[cookieName]\n const preferredFromHeader = detectLocaleFromHeader(\n req.headers['accept-language'],\n config.locales,\n config.defaultLocale,\n )\n const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie)\n ? preferredFromCookie\n : preferredFromHeader\n\n if (strategy === 'prefix' || preferred !== config.defaultLocale) {\n res.writeHead(302, { Location: `/${preferred}/` })\n res.end()\n return\n }\n }\n\n // Attach locale context to request for loaders\n ;(req as any).__locale = locale\n ;(req as any).__localeContext = createLocaleContext(locale, url, config)\n\n // Update the module-level signal so useLocale() returns the correct value\n localeSignal.set(locale)\n\n next()\n })\n },\n }\n}\n\nfunction parseCookies(header: string | undefined): Record<string, string> {\n if (!header) return {}\n const result: Record<string, string> = {}\n for (const pair of header.split(';')) {\n const [key, value] = pair.trim().split('=')\n if (key && value) result[key] = decodeURIComponent(value)\n }\n return result\n}\n\n// ─── Reactive locale hook ───────────────────────────────────────────────────\n\n/** @internal Context for the current locale. */\nexport const LocaleCtx = createContext<string>('en')\n\n/** Current locale signal — set by the server middleware or client-side detection. */\nexport const localeSignal = signal('en')\n\n/**\n * Read the current locale reactively.\n *\n * Returns the locale signal value directly — reactive in both SSR and CSR.\n * The server middleware sets `localeSignal` per-request, and client-side\n * `setLocale()` updates it as well.\n *\n * @example\n * ```tsx\n * const locale = useLocale() // \"en\", \"de\", etc.\n * ```\n */\nexport function useLocale(): string {\n return localeSignal()\n}\n\n/**\n * Set the locale client-side and update the URL.\n *\n * @example\n * ```tsx\n * <button onClick={() => setLocale('de')}>Deutsch</button>\n * ```\n */\nexport function setLocale(\n locale: string,\n config: I18nRoutingConfig,\n): void {\n localeSignal.set(locale)\n\n // Persist to cookie\n if (typeof document !== 'undefined') {\n document.cookie = `${config.cookieName ?? 'locale'}=${locale}; path=/; max-age=31536000`\n }\n\n // Navigate to localized URL — use pushState to avoid full page reload\n if (typeof window !== 'undefined') {\n const strategy = config.strategy ?? 'prefix-except-default'\n const { pathWithoutLocale } = extractLocaleFromPath(\n window.location.pathname,\n config.locales,\n config.defaultLocale,\n )\n const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy)\n window.history.pushState(null, '', newPath)\n // Dispatch popstate so @pyreon/router picks up the URL change\n window.dispatchEvent(new PopStateEvent('popstate'))\n }\n}\n"],"mappings":"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"}
|
package/lib/image-plugin.js
CHANGED
|
@@ -7,7 +7,7 @@ let sharpWarned = false;
|
|
|
7
7
|
function warnSharpMissing() {
|
|
8
8
|
if (sharpWarned) return;
|
|
9
9
|
sharpWarned = true;
|
|
10
|
-
console.warn("\n[
|
|
10
|
+
console.warn("\n[Pyreon] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n");
|
|
11
11
|
}
|
|
12
12
|
/** Built-in CDN providers. */
|
|
13
13
|
const cdnProviders = {
|
package/lib/image-plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"image-plugin.js","names":[],"sources":["../src/image-plugin.ts"],"sourcesContent":["import { existsSync } from 'node:fs'\nimport { mkdir, readFile, writeFile } from 'node:fs/promises'\nimport { basename, extname, join } from 'node:path'\nimport type { Plugin } from 'vite'\n\nlet sharpWarned = false\nfunction warnSharpMissing() {\n if (sharpWarned) return\n sharpWarned = true\n // oxlint-disable-next-line no-console\n console.warn(\n '\\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\\n',\n )\n}\n\n// ─── Image processing Vite plugin ──────────────────────────────────────────\n//\n// Processes images at build time:\n// - Generates multiple sizes for responsive srcset\n// - Converts to modern formats (WebP, AVIF)\n// - Creates tiny blur placeholders (base64 inline)\n// - Outputs optimized images to the build directory\n//\n// Usage in code:\n// import heroImg from \"./hero.jpg?optimize\"\n// // → { src, srcset, width, height, placeholder }\n//\n// Or use the component helper:\n// import { Image } from \"@pyreon/zero/image\"\n// <Image src=\"/hero.jpg\" width={1920} height={1080} optimize />\n\n/**\n * CDN provider — rewrites image URLs to CDN endpoints.\n * Return the rewritten URL, or null to use local processing.\n */\nexport type ImageCdnProvider = (src: string, opts: {\n width: number\n quality: number\n format: ImageFormat\n}) => string | null\n\n/** Built-in CDN providers. */\nexport const cdnProviders = {\n /** Cloudinary: `https://res.cloudinary.com/{cloud}/image/upload/...` */\n cloudinary: (cloudName: string): ImageCdnProvider => (src, { width, quality, format }) =>\n `https://res.cloudinary.com/${cloudName}/image/upload/w_${width},q_${quality},f_${format}/${src}`,\n\n /** Imgix: `https://{domain}.imgix.net/...?w=...&q=...&fm=...` */\n imgix: (domain: string): ImageCdnProvider => (src, { width, quality, format }) =>\n `https://${domain}.imgix.net/${src}?w=${width}&q=${quality}&fm=${format}&auto=format`,\n\n /** Vercel Image Optimization: `/_next/image?url=...&w=...&q=...` */\n vercel: (): ImageCdnProvider => (src, { width, quality }) =>\n `/_vercel/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`,\n\n /** Bunny CDN: `https://{pullZone}.b-cdn.net/...?width=...&quality=...` */\n bunny: (pullZone: string): ImageCdnProvider => (src, { width, quality }) =>\n `https://${pullZone}.b-cdn.net/${src}?width=${width}&quality=${quality}`,\n} as const\n\n/** Placeholder generation strategy. */\nexport type PlaceholderStrategy = 'blur' | 'dominant-color' | 'none'\n\n/** SVG processing options for ?component imports. */\nexport interface SvgOptions {\n /** Replace fill/stroke with currentColor. Default: true */\n currentColor?: boolean\n /** Default size (width/height). */\n defaultSize?: number\n}\n\nexport interface ImagePluginConfig {\n /** Output directory for processed images. Default: \"assets/img\" */\n outDir?: string\n /** Default widths for responsive images. Default: [640, 1024, 1920] */\n widths?: number[]\n /** Output formats. Default: [\"webp\"] */\n formats?: ImageFormat[]\n /** Quality for lossy formats (1-100). Default: 80 */\n quality?: number\n /** Blur placeholder size in px. Default: 16 */\n placeholderSize?: number\n /** Placeholder strategy. Default: \"blur\" */\n placeholder?: PlaceholderStrategy\n /** File patterns to process. Default: /\\.(jpe?g|png|webp|avif)$/i */\n include?: RegExp\n /**\n * CDN provider for URL rewriting. When set, images are NOT processed\n * locally — URLs are rewritten to the CDN endpoint.\n *\n * @example\n * ```ts\n * imagePlugin({ cdn: cdnProviders.cloudinary('my-cloud') })\n * imagePlugin({ cdn: cdnProviders.vercel() })\n * ```\n */\n cdn?: ImageCdnProvider\n /**\n * SVG processing options. Enables `?component` import for inline SVGs.\n *\n * @example\n * ```tsx\n * import Logo from './logo.svg?component'\n * <Logo width={24} class=\"text-primary\" />\n * ```\n */\n svg?: SvgOptions | boolean\n}\n\nexport type ImageFormat = 'webp' | 'avif' | 'jpeg' | 'png'\n\n/** Per-format source set for <picture> <source> elements. */\nexport interface FormatSource {\n /** MIME type. e.g. \"image/webp\", \"image/avif\" */\n type: string\n /** srcset string for this format. e.g. \"/img-640.webp 640w, /img-1920.webp 1920w\" */\n srcset: string\n}\n\nexport interface ProcessedImage {\n /** Fallback source path (last format, largest width). */\n src: string\n /** Fallback srcset string (last format). */\n srcset: string\n /** Intrinsic width. */\n width: number\n /** Intrinsic height. */\n height: number\n /** Base64 blur placeholder data URI. */\n placeholder: string\n /** Per-format source sets for <picture> element. Ordered by priority (best format first). */\n formats: FormatSource[]\n /** Flat list of all sources. */\n sources: Array<{ src: string; width: number; format: string }>\n}\n\nconst IMAGE_EXT_RE = /\\.(jpe?g|png|webp|avif)$/i\n\n/**\n * Zero image processing Vite plugin.\n *\n * Transforms image imports with query params into optimized responsive images:\n *\n * @example\n * // vite.config.ts\n * import { imagePlugin } from \"@pyreon/zero/image-plugin\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * imagePlugin({ widths: [480, 960, 1440], quality: 85 }),\n * ],\n * }\n *\n * @example\n * // In a component — import with ?optimize\n * import hero from \"./images/hero.jpg?optimize\"\n * // hero = { src, srcset, width, height, placeholder }\n *\n * <Image {...hero} alt=\"Hero\" priority />\n */\nexport function imagePlugin(config: ImagePluginConfig = {}): Plugin {\n const defaultWidths = config.widths ?? [640, 1024, 1920]\n const defaultFormats = config.formats ?? ['webp']\n const quality = config.quality ?? 80\n const placeholderSize = config.placeholderSize ?? 16\n const placeholderStrategy = config.placeholder ?? 'blur'\n const outSubDir = config.outDir ?? 'assets/img'\n const include = config.include ?? IMAGE_EXT_RE\n const cdn = config.cdn\n const svgOpts: SvgOptions | false = config.svg === true\n ? { currentColor: true }\n : config.svg === false || config.svg === undefined\n ? false\n : config.svg\n\n let root = ''\n let outDir = ''\n let isBuild = false\n\n return {\n name: 'pyreon-zero-images',\n enforce: 'pre',\n\n configResolved(resolvedConfig) {\n root = resolvedConfig.root\n outDir = resolvedConfig.build.outDir\n isBuild = resolvedConfig.command === 'build'\n },\n\n async resolveId(id) {\n // SVG as component: import Logo from './logo.svg?component'\n if (svgOpts && id.includes('?component') && id.split('?')[0]!.endsWith('.svg')) {\n return `\\0virtual:zero-svg:${id}`\n }\n // Handle ?optimize query on image imports\n if (id.includes('?optimize') && include.test(id.split('?')[0]!)) {\n return `\\0virtual:zero-image:${id}`\n }\n return null\n },\n\n async load(id) {\n // SVG component loading\n if (id.startsWith('\\0virtual:zero-svg:')) {\n const rawPath = id.replace('\\0virtual:zero-svg:', '').split('?')[0] ?? id\n const absPath = rawPath.startsWith('/') ? join(root, rawPath) : rawPath\n if (!existsSync(absPath)) return null\n\n let svg = await readFile(absPath, 'utf-8')\n\n // Replace fill/stroke with currentColor\n if (svgOpts && (svgOpts as SvgOptions).currentColor !== false) {\n svg = svg\n .replace(/fill=\"(?!none)[^\"]*\"/g, 'fill=\"currentColor\"')\n .replace(/stroke=\"(?!none)[^\"]*\"/g, 'stroke=\"currentColor\"')\n }\n\n // Add default size from config\n const defaultSize = svgOpts && (svgOpts as SvgOptions).defaultSize\n if (defaultSize && !svg.includes('width=')) {\n svg = svg.replace('<svg', `<svg width=\"${defaultSize}\" height=\"${defaultSize}\"`)\n }\n\n // Export as Pyreon component\n return `\nimport { h } from '@pyreon/core'\nconst _svg = ${JSON.stringify(svg)}\nexport default function SvgComponent(props) {\n const el = h('span', {\n ...props,\n dangerouslySetInnerHTML: { __html: _svg },\n style: [\n 'display:inline-flex;align-items:center;justify-content:center',\n props.width ? 'width:' + props.width + 'px' : '',\n props.height ? 'height:' + props.height + 'px' : '',\n props.style || '',\n ].filter(Boolean).join(';'),\n })\n return el\n}\n`\n }\n\n // Image optimization loading\n if (!id.startsWith('\\0virtual:zero-image:')) return null\n\n const rawPath = id.replace('\\0virtual:zero-image:', '').split('?')[0] ?? id\n const absPath = rawPath.startsWith('/') ? join(root, 'public', rawPath) : rawPath\n\n // CDN mode — rewrite URLs, no local processing\n if (cdn) {\n const metadata = await getImageMetadata(absPath)\n const sources = defaultWidths.map((w) => ({\n src: cdn(rawPath, { width: w, quality, format: defaultFormats[0]! }) ?? rawPath,\n width: w,\n format: defaultFormats[0]! as string,\n }))\n const srcset = sources.map((s) => `${s.src} ${s.width}w`).join(', ')\n const result: ProcessedImage = {\n src: sources[sources.length - 1]?.src ?? rawPath,\n srcset,\n width: metadata.width,\n height: metadata.height,\n placeholder: placeholderStrategy === 'none' ? ''\n : await generateBlurPlaceholder(absPath, placeholderSize),\n formats: defaultFormats.map((fmt) => ({\n type: `image/${fmt}`,\n srcset: defaultWidths\n .map((w) => `${cdn(rawPath, { width: w, quality, format: fmt }) ?? rawPath} ${w}w`)\n .join(', '),\n })),\n sources,\n }\n return `export default ${JSON.stringify(result)}`\n }\n\n if (!isBuild) {\n const result = await loadDevImage(absPath, rawPath, placeholderSize)\n return `export default ${JSON.stringify(result)}`\n }\n\n const processed = await processImage(absPath, {\n widths: defaultWidths,\n formats: defaultFormats,\n quality,\n placeholderSize,\n outSubDir,\n outDir: join(root, outDir),\n })\n\n await emitProcessedSources(processed, outSubDir, this)\n rebuildFormatSrcsets(processed, absPath)\n\n return `export default ${JSON.stringify(processed)}`\n },\n }\n}\n\nasync function loadDevImage(\n absPath: string,\n rawPath: string,\n placeholderSize: number,\n): Promise<ProcessedImage> {\n const metadata = await getImageMetadata(absPath)\n const publicPath = rawPath.startsWith('/') ? rawPath : `/@fs/${absPath}`\n\n return {\n src: publicPath,\n srcset: '',\n width: metadata.width,\n height: metadata.height,\n placeholder: await generateBlurPlaceholder(absPath, placeholderSize),\n formats: [],\n sources: [{ src: publicPath, width: metadata.width, format: 'original' }],\n }\n}\n\nasync function emitProcessedSources(\n processed: ProcessedImage,\n outSubDir: string,\n ctx: {\n emitFile: (f: { type: 'asset'; fileName: string; source: Uint8Array }) => void\n },\n) {\n for (const source of processed.sources) {\n const fileName = join(outSubDir, basename(source.src))\n const content = await readFile(source.src)\n ctx.emitFile({ type: 'asset', fileName, source: content })\n source.src = `/${fileName}`\n }\n}\n\nfunction rebuildFormatSrcsets(processed: ProcessedImage, fallbackPath: string) {\n const formatGroups = new Map<string, string[]>()\n for (const s of processed.sources) {\n let group = formatGroups.get(s.format)\n if (!group) {\n group = []\n formatGroups.set(s.format, group)\n }\n group.push(`${s.src} ${s.width}w`)\n }\n processed.formats = [...formatGroups.entries()].map(([fmt, entries]) => ({\n type: `image/${fmt}`,\n srcset: entries.join(', '),\n }))\n\n const lastFormat = processed.formats.at(-1)\n processed.srcset = lastFormat?.srcset ?? ''\n processed.src = processed.sources.at(-1)?.src ?? fallbackPath\n}\n\n// ─── Image processing utilities ─────────────────────────────────────────────\n\ninterface ProcessOptions {\n widths: number[]\n formats: ImageFormat[]\n quality: number\n placeholderSize: number\n outSubDir: string\n outDir: string\n}\n\nasync function processImage(absPath: string, opts: ProcessOptions): Promise<ProcessedImage> {\n const metadata = await getImageMetadata(absPath)\n const ext = extname(absPath)\n const name = basename(absPath, ext)\n const sources: Array<{ src: string; width: number; format: string }> = []\n\n // Ensure output directory exists\n const processedDir = join(opts.outDir, opts.outSubDir)\n if (!existsSync(processedDir)) {\n await mkdir(processedDir, { recursive: true })\n }\n\n // Generate resized variants — iterate formats first so sources are grouped by format\n for (const format of opts.formats) {\n for (const targetWidth of opts.widths) {\n // Don't upscale\n const width = Math.min(targetWidth, metadata.width)\n const outName = `${name}-${width}.${format}`\n const outPath = join(processedDir, outName)\n\n await resizeImage(absPath, outPath, width, format, opts.quality)\n sources.push({ src: outPath, width, format })\n }\n }\n\n // Build per-format source sets for <picture>\n const formatGroups = new Map<string, Array<{ src: string; width: number }>>()\n for (const s of sources) {\n let group = formatGroups.get(s.format)\n if (!group) {\n group = []\n formatGroups.set(s.format, group)\n }\n group.push({ src: s.src, width: s.width })\n }\n\n const formats: FormatSource[] = [...formatGroups.entries()].map(([fmt, group]) => ({\n type: `image/${fmt === 'jpeg' ? 'jpeg' : fmt}`,\n srcset: group.map((s) => `${s.src} ${s.width}w`).join(', '),\n }))\n\n // Fallback: last format's srcset\n const fallbackFormat = formats[formats.length - 1]\n const fallbackSources = formatGroups.get([...formatGroups.keys()].pop()!)!\n\n // Generate blur placeholder\n const placeholder = await generateBlurPlaceholder(absPath, opts.placeholderSize)\n\n return {\n src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,\n srcset: fallbackFormat?.srcset ?? '',\n width: metadata.width,\n height: metadata.height,\n placeholder,\n formats,\n sources,\n }\n}\n\ninterface ImageMetadata {\n width: number\n height: number\n format: string\n}\n\n/**\n * Read basic image metadata.\n * Uses minimal binary header parsing — no external dependencies.\n */\nasync function getImageMetadata(absPath: string): Promise<ImageMetadata> {\n const buffer = await readFile(absPath)\n const ext = extname(absPath).toLowerCase()\n\n if (ext === '.png') {\n // PNG: width at bytes 16-19, height at 20-23 (big-endian)\n const width = buffer.readUInt32BE(16)\n const height = buffer.readUInt32BE(20)\n return { width, height, format: 'png' }\n }\n\n if (ext === '.jpg' || ext === '.jpeg') {\n // JPEG: scan for SOF markers\n const dimensions = parseJpegDimensions(buffer)\n return { ...dimensions, format: 'jpeg' }\n }\n\n if (ext === '.webp') {\n // WebP: VP8 header\n const dimensions = parseWebPDimensions(buffer)\n return { ...dimensions, format: 'webp' }\n }\n\n // Fallback\n return { width: 0, height: 0, format: ext.slice(1) }\n}\n\n/** @internal Exported for testing */\nexport function parseJpegDimensions(buffer: Buffer): {\n width: number\n height: number\n} {\n let offset = 2 // Skip SOI marker\n while (offset < buffer.length) {\n if (buffer[offset] !== 0xff) break\n const marker = buffer[offset + 1]!\n // SOF markers (0xC0-0xCF except 0xC4, 0xC8, 0xCC)\n if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {\n const height = buffer.readUInt16BE(offset + 5)\n const width = buffer.readUInt16BE(offset + 7)\n return { width, height }\n }\n const length = buffer.readUInt16BE(offset + 2)\n offset += 2 + length\n }\n return { width: 0, height: 0 }\n}\n\n/** @internal Exported for testing */\nexport function parseWebPDimensions(buffer: Buffer): {\n width: number\n height: number\n} {\n // RIFF header: bytes 0-3 = \"RIFF\", 8-11 = \"WEBP\"\n const chunk = buffer.toString('ascii', 12, 16)\n if (chunk === 'VP8 ') {\n // Lossy VP8\n const width = buffer.readUInt16LE(26) & 0x3fff\n const height = buffer.readUInt16LE(28) & 0x3fff\n return { width, height }\n }\n if (chunk === 'VP8L') {\n // Lossless VP8L\n const bits = buffer.readUInt32LE(21)\n const width = (bits & 0x3fff) + 1\n const height = ((bits >> 14) & 0x3fff) + 1\n return { width, height }\n }\n if (chunk === 'VP8X') {\n // Extended VP8X\n const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xffffff)\n const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xffffff)\n return { width, height }\n }\n return { width: 0, height: 0 }\n}\n\n/**\n * Resize an image using native platform capabilities.\n * Uses sharp if available, falls back to canvas API.\n */\nasync function resizeImage(\n input: string,\n output: string,\n width: number,\n format: ImageFormat,\n quality: number,\n): Promise<void> {\n try {\n // Try sharp (the standard Node.js image processing library)\n const sharp = await import('sharp').then((m) => m.default ?? m)\n let pipeline = sharp(input).resize(width)\n\n switch (format) {\n case 'webp':\n pipeline = pipeline.webp({ quality })\n break\n case 'avif':\n pipeline = pipeline.avif({ quality })\n break\n case 'jpeg':\n pipeline = pipeline.jpeg({ quality, mozjpeg: true })\n break\n case 'png':\n pipeline = pipeline.png({ compressionLevel: 9 })\n break\n }\n\n await pipeline.toFile(output)\n } catch {\n // sharp not available — copy original as fallback\n warnSharpMissing()\n const content = await readFile(input)\n await writeFile(output, content)\n }\n}\n\n/**\n * Generate a tiny blur placeholder as a base64 data URI.\n */\nasync function generateBlurPlaceholder(input: string, size: number): Promise<string> {\n try {\n const sharp = await import('sharp').then((m) => m.default ?? m)\n const buffer = await sharp(input)\n .resize(size, size, { fit: 'inside' })\n .blur(2)\n .webp({ quality: 20 })\n .toBuffer()\n\n return `data:image/webp;base64,${buffer.toString('base64')}`\n } catch {\n // sharp not available — return a transparent placeholder\n return \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E\"\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"image-plugin.js","names":[],"sources":["../src/image-plugin.ts"],"sourcesContent":["import { existsSync } from 'node:fs'\nimport { mkdir, readFile, writeFile } from 'node:fs/promises'\nimport { basename, extname, join } from 'node:path'\nimport type { Plugin } from 'vite'\n\nlet sharpWarned = false\nfunction warnSharpMissing() {\n if (sharpWarned) return\n sharpWarned = true\n // oxlint-disable-next-line no-console\n console.warn(\n '\\n[Pyreon] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\\n',\n )\n}\n\n// ─── Image processing Vite plugin ──────────────────────────────────────────\n//\n// Processes images at build time:\n// - Generates multiple sizes for responsive srcset\n// - Converts to modern formats (WebP, AVIF)\n// - Creates tiny blur placeholders (base64 inline)\n// - Outputs optimized images to the build directory\n//\n// Usage in code:\n// import heroImg from \"./hero.jpg?optimize\"\n// // → { src, srcset, width, height, placeholder }\n//\n// Or use the component helper:\n// import { Image } from \"@pyreon/zero/image\"\n// <Image src=\"/hero.jpg\" width={1920} height={1080} optimize />\n\n/**\n * CDN provider — rewrites image URLs to CDN endpoints.\n * Return the rewritten URL, or null to use local processing.\n */\nexport type ImageCdnProvider = (src: string, opts: {\n width: number\n quality: number\n format: ImageFormat\n}) => string | null\n\n/** Built-in CDN providers. */\nexport const cdnProviders = {\n /** Cloudinary: `https://res.cloudinary.com/{cloud}/image/upload/...` */\n cloudinary: (cloudName: string): ImageCdnProvider => (src, { width, quality, format }) =>\n `https://res.cloudinary.com/${cloudName}/image/upload/w_${width},q_${quality},f_${format}/${src}`,\n\n /** Imgix: `https://{domain}.imgix.net/...?w=...&q=...&fm=...` */\n imgix: (domain: string): ImageCdnProvider => (src, { width, quality, format }) =>\n `https://${domain}.imgix.net/${src}?w=${width}&q=${quality}&fm=${format}&auto=format`,\n\n /** Vercel Image Optimization: `/_next/image?url=...&w=...&q=...` */\n vercel: (): ImageCdnProvider => (src, { width, quality }) =>\n `/_vercel/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`,\n\n /** Bunny CDN: `https://{pullZone}.b-cdn.net/...?width=...&quality=...` */\n bunny: (pullZone: string): ImageCdnProvider => (src, { width, quality }) =>\n `https://${pullZone}.b-cdn.net/${src}?width=${width}&quality=${quality}`,\n} as const\n\n/** Placeholder generation strategy. */\nexport type PlaceholderStrategy = 'blur' | 'dominant-color' | 'none'\n\n/** SVG processing options for ?component imports. */\nexport interface SvgOptions {\n /** Replace fill/stroke with currentColor. Default: true */\n currentColor?: boolean\n /** Default size (width/height). */\n defaultSize?: number\n}\n\nexport interface ImagePluginConfig {\n /** Output directory for processed images. Default: \"assets/img\" */\n outDir?: string\n /** Default widths for responsive images. Default: [640, 1024, 1920] */\n widths?: number[]\n /** Output formats. Default: [\"webp\"] */\n formats?: ImageFormat[]\n /** Quality for lossy formats (1-100). Default: 80 */\n quality?: number\n /** Blur placeholder size in px. Default: 16 */\n placeholderSize?: number\n /** Placeholder strategy. Default: \"blur\" */\n placeholder?: PlaceholderStrategy\n /** File patterns to process. Default: /\\.(jpe?g|png|webp|avif)$/i */\n include?: RegExp\n /**\n * CDN provider for URL rewriting. When set, images are NOT processed\n * locally — URLs are rewritten to the CDN endpoint.\n *\n * @example\n * ```ts\n * imagePlugin({ cdn: cdnProviders.cloudinary('my-cloud') })\n * imagePlugin({ cdn: cdnProviders.vercel() })\n * ```\n */\n cdn?: ImageCdnProvider\n /**\n * SVG processing options. Enables `?component` import for inline SVGs.\n *\n * @example\n * ```tsx\n * import Logo from './logo.svg?component'\n * <Logo width={24} class=\"text-primary\" />\n * ```\n */\n svg?: SvgOptions | boolean\n}\n\nexport type ImageFormat = 'webp' | 'avif' | 'jpeg' | 'png'\n\n/** Per-format source set for <picture> <source> elements. */\nexport interface FormatSource {\n /** MIME type. e.g. \"image/webp\", \"image/avif\" */\n type: string\n /** srcset string for this format. e.g. \"/img-640.webp 640w, /img-1920.webp 1920w\" */\n srcset: string\n}\n\nexport interface ProcessedImage {\n /** Fallback source path (last format, largest width). */\n src: string\n /** Fallback srcset string (last format). */\n srcset: string\n /** Intrinsic width. */\n width: number\n /** Intrinsic height. */\n height: number\n /** Base64 blur placeholder data URI. */\n placeholder: string\n /** Per-format source sets for <picture> element. Ordered by priority (best format first). */\n formats: FormatSource[]\n /** Flat list of all sources. */\n sources: Array<{ src: string; width: number; format: string }>\n}\n\nconst IMAGE_EXT_RE = /\\.(jpe?g|png|webp|avif)$/i\n\n/**\n * Zero image processing Vite plugin.\n *\n * Transforms image imports with query params into optimized responsive images:\n *\n * @example\n * // vite.config.ts\n * import { imagePlugin } from \"@pyreon/zero/image-plugin\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * imagePlugin({ widths: [480, 960, 1440], quality: 85 }),\n * ],\n * }\n *\n * @example\n * // In a component — import with ?optimize\n * import hero from \"./images/hero.jpg?optimize\"\n * // hero = { src, srcset, width, height, placeholder }\n *\n * <Image {...hero} alt=\"Hero\" priority />\n */\nexport function imagePlugin(config: ImagePluginConfig = {}): Plugin {\n const defaultWidths = config.widths ?? [640, 1024, 1920]\n const defaultFormats = config.formats ?? ['webp']\n const quality = config.quality ?? 80\n const placeholderSize = config.placeholderSize ?? 16\n const placeholderStrategy = config.placeholder ?? 'blur'\n const outSubDir = config.outDir ?? 'assets/img'\n const include = config.include ?? IMAGE_EXT_RE\n const cdn = config.cdn\n const svgOpts: SvgOptions | false = config.svg === true\n ? { currentColor: true }\n : config.svg === false || config.svg === undefined\n ? false\n : config.svg\n\n let root = ''\n let outDir = ''\n let isBuild = false\n\n return {\n name: 'pyreon-zero-images',\n enforce: 'pre',\n\n configResolved(resolvedConfig) {\n root = resolvedConfig.root\n outDir = resolvedConfig.build.outDir\n isBuild = resolvedConfig.command === 'build'\n },\n\n async resolveId(id) {\n // SVG as component: import Logo from './logo.svg?component'\n if (svgOpts && id.includes('?component') && id.split('?')[0]!.endsWith('.svg')) {\n return `\\0virtual:zero-svg:${id}`\n }\n // Handle ?optimize query on image imports\n if (id.includes('?optimize') && include.test(id.split('?')[0]!)) {\n return `\\0virtual:zero-image:${id}`\n }\n return null\n },\n\n async load(id) {\n // SVG component loading\n if (id.startsWith('\\0virtual:zero-svg:')) {\n const rawPath = id.replace('\\0virtual:zero-svg:', '').split('?')[0] ?? id\n const absPath = rawPath.startsWith('/') ? join(root, rawPath) : rawPath\n if (!existsSync(absPath)) return null\n\n let svg = await readFile(absPath, 'utf-8')\n\n // Replace fill/stroke with currentColor\n if (svgOpts && (svgOpts as SvgOptions).currentColor !== false) {\n svg = svg\n .replace(/fill=\"(?!none)[^\"]*\"/g, 'fill=\"currentColor\"')\n .replace(/stroke=\"(?!none)[^\"]*\"/g, 'stroke=\"currentColor\"')\n }\n\n // Add default size from config\n const defaultSize = svgOpts && (svgOpts as SvgOptions).defaultSize\n if (defaultSize && !svg.includes('width=')) {\n svg = svg.replace('<svg', `<svg width=\"${defaultSize}\" height=\"${defaultSize}\"`)\n }\n\n // Export as Pyreon component\n return `\nimport { h } from '@pyreon/core'\nconst _svg = ${JSON.stringify(svg)}\nexport default function SvgComponent(props) {\n const el = h('span', {\n ...props,\n dangerouslySetInnerHTML: { __html: _svg },\n style: [\n 'display:inline-flex;align-items:center;justify-content:center',\n props.width ? 'width:' + props.width + 'px' : '',\n props.height ? 'height:' + props.height + 'px' : '',\n props.style || '',\n ].filter(Boolean).join(';'),\n })\n return el\n}\n`\n }\n\n // Image optimization loading\n if (!id.startsWith('\\0virtual:zero-image:')) return null\n\n const rawPath = id.replace('\\0virtual:zero-image:', '').split('?')[0] ?? id\n const absPath = rawPath.startsWith('/') ? join(root, 'public', rawPath) : rawPath\n\n // CDN mode — rewrite URLs, no local processing\n if (cdn) {\n const metadata = await getImageMetadata(absPath)\n const sources = defaultWidths.map((w) => ({\n src: cdn(rawPath, { width: w, quality, format: defaultFormats[0]! }) ?? rawPath,\n width: w,\n format: defaultFormats[0]! as string,\n }))\n const srcset = sources.map((s) => `${s.src} ${s.width}w`).join(', ')\n const result: ProcessedImage = {\n src: sources[sources.length - 1]?.src ?? rawPath,\n srcset,\n width: metadata.width,\n height: metadata.height,\n placeholder: placeholderStrategy === 'none' ? ''\n : await generateBlurPlaceholder(absPath, placeholderSize),\n formats: defaultFormats.map((fmt) => ({\n type: `image/${fmt}`,\n srcset: defaultWidths\n .map((w) => `${cdn(rawPath, { width: w, quality, format: fmt }) ?? rawPath} ${w}w`)\n .join(', '),\n })),\n sources,\n }\n return `export default ${JSON.stringify(result)}`\n }\n\n if (!isBuild) {\n const result = await loadDevImage(absPath, rawPath, placeholderSize)\n return `export default ${JSON.stringify(result)}`\n }\n\n const processed = await processImage(absPath, {\n widths: defaultWidths,\n formats: defaultFormats,\n quality,\n placeholderSize,\n outSubDir,\n outDir: join(root, outDir),\n })\n\n await emitProcessedSources(processed, outSubDir, this)\n rebuildFormatSrcsets(processed, absPath)\n\n return `export default ${JSON.stringify(processed)}`\n },\n }\n}\n\nasync function loadDevImage(\n absPath: string,\n rawPath: string,\n placeholderSize: number,\n): Promise<ProcessedImage> {\n const metadata = await getImageMetadata(absPath)\n const publicPath = rawPath.startsWith('/') ? rawPath : `/@fs/${absPath}`\n\n return {\n src: publicPath,\n srcset: '',\n width: metadata.width,\n height: metadata.height,\n placeholder: await generateBlurPlaceholder(absPath, placeholderSize),\n formats: [],\n sources: [{ src: publicPath, width: metadata.width, format: 'original' }],\n }\n}\n\nasync function emitProcessedSources(\n processed: ProcessedImage,\n outSubDir: string,\n ctx: {\n emitFile: (f: { type: 'asset'; fileName: string; source: Uint8Array }) => void\n },\n) {\n for (const source of processed.sources) {\n const fileName = join(outSubDir, basename(source.src))\n const content = await readFile(source.src)\n ctx.emitFile({ type: 'asset', fileName, source: content })\n source.src = `/${fileName}`\n }\n}\n\nfunction rebuildFormatSrcsets(processed: ProcessedImage, fallbackPath: string) {\n const formatGroups = new Map<string, string[]>()\n for (const s of processed.sources) {\n let group = formatGroups.get(s.format)\n if (!group) {\n group = []\n formatGroups.set(s.format, group)\n }\n group.push(`${s.src} ${s.width}w`)\n }\n processed.formats = [...formatGroups.entries()].map(([fmt, entries]) => ({\n type: `image/${fmt}`,\n srcset: entries.join(', '),\n }))\n\n const lastFormat = processed.formats.at(-1)\n processed.srcset = lastFormat?.srcset ?? ''\n processed.src = processed.sources.at(-1)?.src ?? fallbackPath\n}\n\n// ─── Image processing utilities ─────────────────────────────────────────────\n\ninterface ProcessOptions {\n widths: number[]\n formats: ImageFormat[]\n quality: number\n placeholderSize: number\n outSubDir: string\n outDir: string\n}\n\nasync function processImage(absPath: string, opts: ProcessOptions): Promise<ProcessedImage> {\n const metadata = await getImageMetadata(absPath)\n const ext = extname(absPath)\n const name = basename(absPath, ext)\n const sources: Array<{ src: string; width: number; format: string }> = []\n\n // Ensure output directory exists\n const processedDir = join(opts.outDir, opts.outSubDir)\n if (!existsSync(processedDir)) {\n await mkdir(processedDir, { recursive: true })\n }\n\n // Generate resized variants — iterate formats first so sources are grouped by format\n for (const format of opts.formats) {\n for (const targetWidth of opts.widths) {\n // Don't upscale\n const width = Math.min(targetWidth, metadata.width)\n const outName = `${name}-${width}.${format}`\n const outPath = join(processedDir, outName)\n\n await resizeImage(absPath, outPath, width, format, opts.quality)\n sources.push({ src: outPath, width, format })\n }\n }\n\n // Build per-format source sets for <picture>\n const formatGroups = new Map<string, Array<{ src: string; width: number }>>()\n for (const s of sources) {\n let group = formatGroups.get(s.format)\n if (!group) {\n group = []\n formatGroups.set(s.format, group)\n }\n group.push({ src: s.src, width: s.width })\n }\n\n const formats: FormatSource[] = [...formatGroups.entries()].map(([fmt, group]) => ({\n type: `image/${fmt === 'jpeg' ? 'jpeg' : fmt}`,\n srcset: group.map((s) => `${s.src} ${s.width}w`).join(', '),\n }))\n\n // Fallback: last format's srcset\n const fallbackFormat = formats[formats.length - 1]\n const fallbackSources = formatGroups.get([...formatGroups.keys()].pop()!)!\n\n // Generate blur placeholder\n const placeholder = await generateBlurPlaceholder(absPath, opts.placeholderSize)\n\n return {\n src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,\n srcset: fallbackFormat?.srcset ?? '',\n width: metadata.width,\n height: metadata.height,\n placeholder,\n formats,\n sources,\n }\n}\n\ninterface ImageMetadata {\n width: number\n height: number\n format: string\n}\n\n/**\n * Read basic image metadata.\n * Uses minimal binary header parsing — no external dependencies.\n */\nasync function getImageMetadata(absPath: string): Promise<ImageMetadata> {\n const buffer = await readFile(absPath)\n const ext = extname(absPath).toLowerCase()\n\n if (ext === '.png') {\n // PNG: width at bytes 16-19, height at 20-23 (big-endian)\n const width = buffer.readUInt32BE(16)\n const height = buffer.readUInt32BE(20)\n return { width, height, format: 'png' }\n }\n\n if (ext === '.jpg' || ext === '.jpeg') {\n // JPEG: scan for SOF markers\n const dimensions = parseJpegDimensions(buffer)\n return { ...dimensions, format: 'jpeg' }\n }\n\n if (ext === '.webp') {\n // WebP: VP8 header\n const dimensions = parseWebPDimensions(buffer)\n return { ...dimensions, format: 'webp' }\n }\n\n // Fallback\n return { width: 0, height: 0, format: ext.slice(1) }\n}\n\n/** @internal Exported for testing */\nexport function parseJpegDimensions(buffer: Buffer): {\n width: number\n height: number\n} {\n let offset = 2 // Skip SOI marker\n while (offset < buffer.length) {\n if (buffer[offset] !== 0xff) break\n const marker = buffer[offset + 1]!\n // SOF markers (0xC0-0xCF except 0xC4, 0xC8, 0xCC)\n if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {\n const height = buffer.readUInt16BE(offset + 5)\n const width = buffer.readUInt16BE(offset + 7)\n return { width, height }\n }\n const length = buffer.readUInt16BE(offset + 2)\n offset += 2 + length\n }\n return { width: 0, height: 0 }\n}\n\n/** @internal Exported for testing */\nexport function parseWebPDimensions(buffer: Buffer): {\n width: number\n height: number\n} {\n // RIFF header: bytes 0-3 = \"RIFF\", 8-11 = \"WEBP\"\n const chunk = buffer.toString('ascii', 12, 16)\n if (chunk === 'VP8 ') {\n // Lossy VP8\n const width = buffer.readUInt16LE(26) & 0x3fff\n const height = buffer.readUInt16LE(28) & 0x3fff\n return { width, height }\n }\n if (chunk === 'VP8L') {\n // Lossless VP8L\n const bits = buffer.readUInt32LE(21)\n const width = (bits & 0x3fff) + 1\n const height = ((bits >> 14) & 0x3fff) + 1\n return { width, height }\n }\n if (chunk === 'VP8X') {\n // Extended VP8X\n const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xffffff)\n const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xffffff)\n return { width, height }\n }\n return { width: 0, height: 0 }\n}\n\n/**\n * Resize an image using native platform capabilities.\n * Uses sharp if available, falls back to canvas API.\n */\nasync function resizeImage(\n input: string,\n output: string,\n width: number,\n format: ImageFormat,\n quality: number,\n): Promise<void> {\n try {\n // Try sharp (the standard Node.js image processing library)\n const sharp = await import('sharp').then((m) => m.default ?? m)\n let pipeline = sharp(input).resize(width)\n\n switch (format) {\n case 'webp':\n pipeline = pipeline.webp({ quality })\n break\n case 'avif':\n pipeline = pipeline.avif({ quality })\n break\n case 'jpeg':\n pipeline = pipeline.jpeg({ quality, mozjpeg: true })\n break\n case 'png':\n pipeline = pipeline.png({ compressionLevel: 9 })\n break\n }\n\n await pipeline.toFile(output)\n } catch {\n // sharp not available — copy original as fallback\n warnSharpMissing()\n const content = await readFile(input)\n await writeFile(output, content)\n }\n}\n\n/**\n * Generate a tiny blur placeholder as a base64 data URI.\n */\nasync function generateBlurPlaceholder(input: string, size: number): Promise<string> {\n try {\n const sharp = await import('sharp').then((m) => m.default ?? m)\n const buffer = await sharp(input)\n .resize(size, size, { fit: 'inside' })\n .blur(2)\n .webp({ quality: 20 })\n .toBuffer()\n\n return `data:image/webp;base64,${buffer.toString('base64')}`\n } catch {\n // sharp not available — return a transparent placeholder\n return \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E\"\n }\n}\n"],"mappings":";;;;;AAKA,IAAI,cAAc;AAClB,SAAS,mBAAmB;AAC1B,KAAI,YAAa;AACjB,eAAc;AAEd,SAAQ,KACN,8GACD;;;AA8BH,MAAa,eAAe;CAE1B,aAAa,eAAyC,KAAK,EAAE,OAAO,SAAS,aAC3E,8BAA8B,UAAU,kBAAkB,MAAM,KAAK,QAAQ,KAAK,OAAO,GAAG;CAG9F,QAAQ,YAAsC,KAAK,EAAE,OAAO,SAAS,aACnE,WAAW,OAAO,aAAa,IAAI,KAAK,MAAM,KAAK,QAAQ,MAAM,OAAO;CAG1E,eAAiC,KAAK,EAAE,OAAO,cAC7C,sBAAsB,mBAAmB,IAAI,CAAC,KAAK,MAAM,KAAK;CAGhE,QAAQ,cAAwC,KAAK,EAAE,OAAO,cAC5D,WAAW,SAAS,aAAa,IAAI,SAAS,MAAM,WAAW;CAClE;AA8ED,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;AA0BrB,SAAgB,YAAY,SAA4B,EAAE,EAAU;CAClE,MAAM,gBAAgB,OAAO,UAAU;EAAC;EAAK;EAAM;EAAK;CACxD,MAAM,iBAAiB,OAAO,WAAW,CAAC,OAAO;CACjD,MAAM,UAAU,OAAO,WAAW;CAClC,MAAM,kBAAkB,OAAO,mBAAmB;CAClD,MAAM,sBAAsB,OAAO,eAAe;CAClD,MAAM,YAAY,OAAO,UAAU;CACnC,MAAM,UAAU,OAAO,WAAW;CAClC,MAAM,MAAM,OAAO;CACnB,MAAM,UAA8B,OAAO,QAAQ,OAC/C,EAAE,cAAc,MAAM,GACtB,OAAO,QAAQ,SAAS,OAAO,QAAQ,SACrC,QACA,OAAO;CAEb,IAAI,OAAO;CACX,IAAI,SAAS;CACb,IAAI,UAAU;AAEd,QAAO;EACL,MAAM;EACN,SAAS;EAET,eAAe,gBAAgB;AAC7B,UAAO,eAAe;AACtB,YAAS,eAAe,MAAM;AAC9B,aAAU,eAAe,YAAY;;EAGvC,MAAM,UAAU,IAAI;AAElB,OAAI,WAAW,GAAG,SAAS,aAAa,IAAI,GAAG,MAAM,IAAI,CAAC,GAAI,SAAS,OAAO,CAC5E,QAAO,sBAAsB;AAG/B,OAAI,GAAG,SAAS,YAAY,IAAI,QAAQ,KAAK,GAAG,MAAM,IAAI,CAAC,GAAI,CAC7D,QAAO,wBAAwB;AAEjC,UAAO;;EAGT,MAAM,KAAK,IAAI;AAEb,OAAI,GAAG,WAAW,sBAAsB,EAAE;IACxC,MAAM,UAAU,GAAG,QAAQ,uBAAuB,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM;IACvE,MAAM,UAAU,QAAQ,WAAW,IAAI,GAAG,KAAK,MAAM,QAAQ,GAAG;AAChE,QAAI,CAAC,WAAW,QAAQ,CAAE,QAAO;IAEjC,IAAI,MAAM,MAAM,SAAS,SAAS,QAAQ;AAG1C,QAAI,WAAY,QAAuB,iBAAiB,MACtD,OAAM,IACH,QAAQ,yBAAyB,wBAAsB,CACvD,QAAQ,2BAA2B,0BAAwB;IAIhE,MAAM,cAAc,WAAY,QAAuB;AACvD,QAAI,eAAe,CAAC,IAAI,SAAS,SAAS,CACxC,OAAM,IAAI,QAAQ,QAAQ,eAAe,YAAY,YAAY,YAAY,GAAG;AAIlF,WAAO;;eAEA,KAAK,UAAU,IAAI,CAAC;;;;;;;;;;;;;;;;AAkB7B,OAAI,CAAC,GAAG,WAAW,wBAAwB,CAAE,QAAO;GAEpD,MAAM,UAAU,GAAG,QAAQ,yBAAyB,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM;GACzE,MAAM,UAAU,QAAQ,WAAW,IAAI,GAAG,KAAK,MAAM,UAAU,QAAQ,GAAG;AAG1E,OAAI,KAAK;IACP,MAAM,WAAW,MAAM,iBAAiB,QAAQ;IAChD,MAAM,UAAU,cAAc,KAAK,OAAO;KACxC,KAAK,IAAI,SAAS;MAAE,OAAO;MAAG;MAAS,QAAQ,eAAe;MAAK,CAAC,IAAI;KACxE,OAAO;KACP,QAAQ,eAAe;KACxB,EAAE;IACH,MAAM,SAAS,QAAQ,KAAK,MAAM,GAAG,EAAE,IAAI,GAAG,EAAE,MAAM,GAAG,CAAC,KAAK,KAAK;IACpE,MAAM,SAAyB;KAC7B,KAAK,QAAQ,QAAQ,SAAS,IAAI,OAAO;KACzC;KACA,OAAO,SAAS;KAChB,QAAQ,SAAS;KACjB,aAAa,wBAAwB,SAAS,KAC1C,MAAM,wBAAwB,SAAS,gBAAgB;KAC3D,SAAS,eAAe,KAAK,SAAS;MACpC,MAAM,SAAS;MACf,QAAQ,cACL,KAAK,MAAM,GAAG,IAAI,SAAS;OAAE,OAAO;OAAG;OAAS,QAAQ;OAAK,CAAC,IAAI,QAAQ,GAAG,EAAE,GAAG,CAClF,KAAK,KAAK;MACd,EAAE;KACH;KACD;AACD,WAAO,kBAAkB,KAAK,UAAU,OAAO;;AAGjD,OAAI,CAAC,SAAS;IACZ,MAAM,SAAS,MAAM,aAAa,SAAS,SAAS,gBAAgB;AACpE,WAAO,kBAAkB,KAAK,UAAU,OAAO;;GAGjD,MAAM,YAAY,MAAM,aAAa,SAAS;IAC5C,QAAQ;IACR,SAAS;IACT;IACA;IACA;IACA,QAAQ,KAAK,MAAM,OAAO;IAC3B,CAAC;AAEF,SAAM,qBAAqB,WAAW,WAAW,KAAK;AACtD,wBAAqB,WAAW,QAAQ;AAExC,UAAO,kBAAkB,KAAK,UAAU,UAAU;;EAErD;;AAGH,eAAe,aACb,SACA,SACA,iBACyB;CACzB,MAAM,WAAW,MAAM,iBAAiB,QAAQ;CAChD,MAAM,aAAa,QAAQ,WAAW,IAAI,GAAG,UAAU,QAAQ;AAE/D,QAAO;EACL,KAAK;EACL,QAAQ;EACR,OAAO,SAAS;EAChB,QAAQ,SAAS;EACjB,aAAa,MAAM,wBAAwB,SAAS,gBAAgB;EACpE,SAAS,EAAE;EACX,SAAS,CAAC;GAAE,KAAK;GAAY,OAAO,SAAS;GAAO,QAAQ;GAAY,CAAC;EAC1E;;AAGH,eAAe,qBACb,WACA,WACA,KAGA;AACA,MAAK,MAAM,UAAU,UAAU,SAAS;EACtC,MAAM,WAAW,KAAK,WAAW,SAAS,OAAO,IAAI,CAAC;EACtD,MAAM,UAAU,MAAM,SAAS,OAAO,IAAI;AAC1C,MAAI,SAAS;GAAE,MAAM;GAAS;GAAU,QAAQ;GAAS,CAAC;AAC1D,SAAO,MAAM,IAAI;;;AAIrB,SAAS,qBAAqB,WAA2B,cAAsB;CAC7E,MAAM,+BAAe,IAAI,KAAuB;AAChD,MAAK,MAAM,KAAK,UAAU,SAAS;EACjC,IAAI,QAAQ,aAAa,IAAI,EAAE,OAAO;AACtC,MAAI,CAAC,OAAO;AACV,WAAQ,EAAE;AACV,gBAAa,IAAI,EAAE,QAAQ,MAAM;;AAEnC,QAAM,KAAK,GAAG,EAAE,IAAI,GAAG,EAAE,MAAM,GAAG;;AAEpC,WAAU,UAAU,CAAC,GAAG,aAAa,SAAS,CAAC,CAAC,KAAK,CAAC,KAAK,cAAc;EACvE,MAAM,SAAS;EACf,QAAQ,QAAQ,KAAK,KAAK;EAC3B,EAAE;AAGH,WAAU,SADS,UAAU,QAAQ,GAAG,GAAG,EACZ,UAAU;AACzC,WAAU,MAAM,UAAU,QAAQ,GAAG,GAAG,EAAE,OAAO;;AAcnD,eAAe,aAAa,SAAiB,MAA+C;CAC1F,MAAM,WAAW,MAAM,iBAAiB,QAAQ;CAEhD,MAAM,OAAO,SAAS,SADV,QAAQ,QAAQ,CACO;CACnC,MAAM,UAAiE,EAAE;CAGzE,MAAM,eAAe,KAAK,KAAK,QAAQ,KAAK,UAAU;AACtD,KAAI,CAAC,WAAW,aAAa,CAC3B,OAAM,MAAM,cAAc,EAAE,WAAW,MAAM,CAAC;AAIhD,MAAK,MAAM,UAAU,KAAK,QACxB,MAAK,MAAM,eAAe,KAAK,QAAQ;EAErC,MAAM,QAAQ,KAAK,IAAI,aAAa,SAAS,MAAM;EAEnD,MAAM,UAAU,KAAK,cADL,GAAG,KAAK,GAAG,MAAM,GAAG,SACO;AAE3C,QAAM,YAAY,SAAS,SAAS,OAAO,QAAQ,KAAK,QAAQ;AAChE,UAAQ,KAAK;GAAE,KAAK;GAAS;GAAO;GAAQ,CAAC;;CAKjD,MAAM,+BAAe,IAAI,KAAoD;AAC7E,MAAK,MAAM,KAAK,SAAS;EACvB,IAAI,QAAQ,aAAa,IAAI,EAAE,OAAO;AACtC,MAAI,CAAC,OAAO;AACV,WAAQ,EAAE;AACV,gBAAa,IAAI,EAAE,QAAQ,MAAM;;AAEnC,QAAM,KAAK;GAAE,KAAK,EAAE;GAAK,OAAO,EAAE;GAAO,CAAC;;CAG5C,MAAM,UAA0B,CAAC,GAAG,aAAa,SAAS,CAAC,CAAC,KAAK,CAAC,KAAK,YAAY;EACjF,MAAM,SAAS,QAAQ,SAAS,SAAS;EACzC,QAAQ,MAAM,KAAK,MAAM,GAAG,EAAE,IAAI,GAAG,EAAE,MAAM,GAAG,CAAC,KAAK,KAAK;EAC5D,EAAE;CAGH,MAAM,iBAAiB,QAAQ,QAAQ,SAAS;CAChD,MAAM,kBAAkB,aAAa,IAAI,CAAC,GAAG,aAAa,MAAM,CAAC,CAAC,KAAK,CAAE;CAGzE,MAAM,cAAc,MAAM,wBAAwB,SAAS,KAAK,gBAAgB;AAEhF,QAAO;EACL,KAAK,gBAAgB,gBAAgB,SAAS,IAAI,OAAO;EACzD,QAAQ,gBAAgB,UAAU;EAClC,OAAO,SAAS;EAChB,QAAQ,SAAS;EACjB;EACA;EACA;EACD;;;;;;AAaH,eAAe,iBAAiB,SAAyC;CACvE,MAAM,SAAS,MAAM,SAAS,QAAQ;CACtC,MAAM,MAAM,QAAQ,QAAQ,CAAC,aAAa;AAE1C,KAAI,QAAQ,OAIV,QAAO;EAAE,OAFK,OAAO,aAAa,GAAG;EAErB,QADD,OAAO,aAAa,GAAG;EACd,QAAQ;EAAO;AAGzC,KAAI,QAAQ,UAAU,QAAQ,QAG5B,QAAO;EAAE,GADU,oBAAoB,OAAO;EACtB,QAAQ;EAAQ;AAG1C,KAAI,QAAQ,QAGV,QAAO;EAAE,GADU,oBAAoB,OAAO;EACtB,QAAQ;EAAQ;AAI1C,QAAO;EAAE,OAAO;EAAG,QAAQ;EAAG,QAAQ,IAAI,MAAM,EAAE;EAAE;;;AAItD,SAAgB,oBAAoB,QAGlC;CACA,IAAI,SAAS;AACb,QAAO,SAAS,OAAO,QAAQ;AAC7B,MAAI,OAAO,YAAY,IAAM;EAC7B,MAAM,SAAS,OAAO,SAAS;AAE/B,MAAI,UAAU,OAAQ,UAAU,OAAQ,WAAW,OAAQ,WAAW,OAAQ,WAAW,KAAM;GAC7F,MAAM,SAAS,OAAO,aAAa,SAAS,EAAE;AAE9C,UAAO;IAAE,OADK,OAAO,aAAa,SAAS,EAAE;IAC7B;IAAQ;;EAE1B,MAAM,SAAS,OAAO,aAAa,SAAS,EAAE;AAC9C,YAAU,IAAI;;AAEhB,QAAO;EAAE,OAAO;EAAG,QAAQ;EAAG;;;AAIhC,SAAgB,oBAAoB,QAGlC;CAEA,MAAM,QAAQ,OAAO,SAAS,SAAS,IAAI,GAAG;AAC9C,KAAI,UAAU,OAIZ,QAAO;EAAE,OAFK,OAAO,aAAa,GAAG,GAAG;EAExB,QADD,OAAO,aAAa,GAAG,GAAG;EACjB;AAE1B,KAAI,UAAU,QAAQ;EAEpB,MAAM,OAAO,OAAO,aAAa,GAAG;AAGpC,SAAO;GAAE,QAFM,OAAO,SAAU;GAEhB,SADC,QAAQ,KAAM,SAAU;GACjB;;AAE1B,KAAI,UAAU,OAIZ,QAAO;EAAE,OAFK,MAAM,OAAO,MAAQ,OAAO,OAAQ,IAAM,OAAO,OAAQ,MAAO;EAE9D,QADD,MAAM,OAAO,MAAQ,OAAO,OAAQ,IAAM,OAAO,OAAQ,MAAO;EACvD;AAE1B,QAAO;EAAE,OAAO;EAAG,QAAQ;EAAG;;;;;;AAOhC,eAAe,YACb,OACA,QACA,OACA,QACA,SACe;AACf,KAAI;EAGF,IAAI,YADU,MAAM,OAAO,SAAS,MAAM,MAAM,EAAE,WAAW,EAAE,EAC1C,MAAM,CAAC,OAAO,MAAM;AAEzC,UAAQ,QAAR;GACE,KAAK;AACH,eAAW,SAAS,KAAK,EAAE,SAAS,CAAC;AACrC;GACF,KAAK;AACH,eAAW,SAAS,KAAK,EAAE,SAAS,CAAC;AACrC;GACF,KAAK;AACH,eAAW,SAAS,KAAK;KAAE;KAAS,SAAS;KAAM,CAAC;AACpD;GACF,KAAK;AACH,eAAW,SAAS,IAAI,EAAE,kBAAkB,GAAG,CAAC;AAChD;;AAGJ,QAAM,SAAS,OAAO,OAAO;SACvB;AAEN,oBAAkB;AAElB,QAAM,UAAU,QADA,MAAM,SAAS,MAAM,CACL;;;;;;AAOpC,eAAe,wBAAwB,OAAe,MAA+B;AACnF,KAAI;AAQF,SAAO,2BANQ,OADD,MAAM,OAAO,SAAS,MAAM,MAAM,EAAE,WAAW,EAAE,EACpC,MAAM,CAC9B,OAAO,MAAM,MAAM,EAAE,KAAK,UAAU,CAAC,CACrC,KAAK,EAAE,CACP,KAAK,EAAE,SAAS,IAAI,CAAC,CACrB,UAAU,EAE2B,SAAS,SAAS;SACpD;AAEN,SAAO"}
|
package/lib/index.js
CHANGED
|
@@ -173,25 +173,33 @@ function Image(props) {
|
|
|
173
173
|
//#endregion
|
|
174
174
|
//#region src/link.tsx
|
|
175
175
|
const MAX_PREFETCH_CACHE = 200;
|
|
176
|
-
const prefetched = /* @__PURE__ */ new
|
|
176
|
+
const prefetched = /* @__PURE__ */ new Map();
|
|
177
177
|
function doPrefetch(href) {
|
|
178
|
+
if (typeof document === "undefined") return;
|
|
178
179
|
if (prefetched.has(href)) return;
|
|
179
180
|
if (prefetched.size >= MAX_PREFETCH_CACHE) {
|
|
180
|
-
const
|
|
181
|
-
if (
|
|
181
|
+
const firstEntry = prefetched.entries().next().value;
|
|
182
|
+
if (firstEntry) {
|
|
183
|
+
const [oldestHref, oldestLinks] = firstEntry;
|
|
184
|
+
for (const link of oldestLinks) link.remove();
|
|
185
|
+
prefetched.delete(oldestHref);
|
|
186
|
+
}
|
|
182
187
|
}
|
|
183
|
-
|
|
188
|
+
const injected = [];
|
|
184
189
|
const docLink = document.createElement("link");
|
|
185
190
|
docLink.rel = "prefetch";
|
|
186
191
|
docLink.href = href;
|
|
187
192
|
docLink.as = "document";
|
|
188
193
|
document.head.appendChild(docLink);
|
|
194
|
+
injected.push(docLink);
|
|
189
195
|
try {
|
|
190
196
|
const chunkHint = document.createElement("link");
|
|
191
197
|
chunkHint.rel = "modulepreload";
|
|
192
198
|
chunkHint.href = href;
|
|
193
199
|
document.head.appendChild(chunkHint);
|
|
200
|
+
injected.push(chunkHint);
|
|
194
201
|
} catch {}
|
|
202
|
+
prefetched.set(href, injected);
|
|
195
203
|
}
|
|
196
204
|
/**
|
|
197
205
|
* Prefetch a route's JS chunk by injecting `<link rel="prefetch">` into the
|
|
@@ -363,6 +371,7 @@ const Link = createLink((props) => /* @__PURE__ */ jsx("a", {
|
|
|
363
371
|
*/
|
|
364
372
|
function Script(props) {
|
|
365
373
|
function loadScript() {
|
|
374
|
+
if (typeof document === "undefined") return;
|
|
366
375
|
if (props.id && document.getElementById(props.id)) return;
|
|
367
376
|
const script = document.createElement("script");
|
|
368
377
|
if (props.src) script.src = props.src;
|
|
@@ -767,6 +776,18 @@ function buildMetaTags(props) {
|
|
|
767
776
|
const STORAGE_KEY = "zero-theme";
|
|
768
777
|
/** Reactive theme signal. */
|
|
769
778
|
const theme = signal("system");
|
|
779
|
+
/**
|
|
780
|
+
* Reactive signal tracking the OS color-scheme preference. Updated by the
|
|
781
|
+
* `matchMedia('(prefers-color-scheme: dark)').change` event registered in
|
|
782
|
+
* `initTheme`. Components reading `resolvedTheme()` subscribe to BOTH
|
|
783
|
+
* `theme` and this signal, so a user toggling dark mode at the OS level
|
|
784
|
+
* re-renders everything reactively — not just the `<html data-theme>`
|
|
785
|
+
* attribute.
|
|
786
|
+
*
|
|
787
|
+
* SSR default is `_ssrDefault` (mutable via `setSSRThemeDefault`) so the
|
|
788
|
+
* server-rendered theme can differ from the client's OS preference.
|
|
789
|
+
*/
|
|
790
|
+
const _osPrefersDark = signal(false);
|
|
770
791
|
/** SSR fallback when system preference can't be detected. Default: 'light'. */
|
|
771
792
|
let _ssrDefault = "light";
|
|
772
793
|
/**
|
|
@@ -776,12 +797,17 @@ let _ssrDefault = "light";
|
|
|
776
797
|
function setSSRThemeDefault(value) {
|
|
777
798
|
_ssrDefault = value;
|
|
778
799
|
}
|
|
779
|
-
/**
|
|
800
|
+
/**
|
|
801
|
+
* Reactive read of the resolved theme. Subscribes to `theme` (explicit
|
|
802
|
+
* user choice) and — when `theme === 'system'` — to `_osPrefersDark`
|
|
803
|
+
* (OS color-scheme preference). Components using `resolvedTheme()`
|
|
804
|
+
* inside JSX / effects / computeds re-render when either changes.
|
|
805
|
+
*/
|
|
780
806
|
function resolvedTheme() {
|
|
781
807
|
const t = theme();
|
|
782
808
|
if (t === "system") {
|
|
783
809
|
if (typeof window === "undefined") return _ssrDefault;
|
|
784
|
-
return
|
|
810
|
+
return _osPrefersDark() ? "dark" : "light";
|
|
785
811
|
}
|
|
786
812
|
return t;
|
|
787
813
|
}
|
|
@@ -811,18 +837,21 @@ function initTheme() {
|
|
|
811
837
|
} catch {}
|
|
812
838
|
document.documentElement.dataset.theme = resolvedTheme();
|
|
813
839
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
814
|
-
|
|
815
|
-
|
|
840
|
+
_osPrefersDark.set(mq.matches);
|
|
841
|
+
function onChange(e) {
|
|
842
|
+
_osPrefersDark.set(e.matches);
|
|
816
843
|
}
|
|
817
844
|
mq.addEventListener("change", onChange);
|
|
818
|
-
onUnmount(() => mq.removeEventListener("change", onChange));
|
|
819
845
|
const dispose = effect(() => {
|
|
820
846
|
const mode = resolvedTheme();
|
|
821
847
|
document.documentElement.dataset.theme = mode;
|
|
822
848
|
const faviconLinks = document.querySelectorAll("[data-favicon-theme]");
|
|
823
849
|
for (const link of faviconLinks) link.media = link.dataset.faviconTheme === mode ? "" : "not all";
|
|
824
850
|
});
|
|
825
|
-
|
|
851
|
+
return () => {
|
|
852
|
+
mq.removeEventListener("change", onChange);
|
|
853
|
+
dispose?.dispose();
|
|
854
|
+
};
|
|
826
855
|
});
|
|
827
856
|
}
|
|
828
857
|
/**
|