@pyreon/zero 0.11.9 → 0.12.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 (82) hide show
  1. package/lib/font.js +20 -7
  2. package/lib/font.js.map +1 -1
  3. package/lib/image-plugin.js.map +1 -1
  4. package/lib/index.js +21 -7
  5. package/lib/index.js.map +1 -1
  6. package/lib/link.js +1 -0
  7. package/lib/link.js.map +1 -1
  8. package/lib/types/actions.d.ts +57 -0
  9. package/lib/types/actions.d.ts.map +1 -0
  10. package/lib/types/adapters/bun.d.ts +6 -0
  11. package/lib/types/adapters/bun.d.ts.map +1 -0
  12. package/lib/types/adapters/index.d.ts +10 -0
  13. package/lib/types/adapters/index.d.ts.map +1 -0
  14. package/lib/types/adapters/node.d.ts +6 -0
  15. package/lib/types/adapters/node.d.ts.map +1 -0
  16. package/lib/types/adapters/static.d.ts +7 -0
  17. package/lib/types/adapters/static.d.ts.map +1 -0
  18. package/lib/types/api-routes.d.ts +66 -0
  19. package/lib/types/api-routes.d.ts.map +1 -0
  20. package/lib/types/app.d.ts +24 -0
  21. package/lib/types/app.d.ts.map +1 -0
  22. package/lib/types/cache.d.ts +54 -0
  23. package/lib/types/cache.d.ts.map +1 -0
  24. package/lib/types/client.d.ts +19 -0
  25. package/lib/types/client.d.ts.map +1 -0
  26. package/lib/types/compression.d.ts +33 -0
  27. package/lib/types/compression.d.ts.map +1 -0
  28. package/lib/types/config.d.ts +18 -0
  29. package/lib/types/config.d.ts.map +1 -0
  30. package/lib/types/cors.d.ts +32 -0
  31. package/lib/types/cors.d.ts.map +1 -0
  32. package/lib/types/entry-server.d.ts +37 -0
  33. package/lib/types/entry-server.d.ts.map +1 -0
  34. package/lib/types/error-overlay.d.ts +6 -0
  35. package/lib/types/error-overlay.d.ts.map +1 -0
  36. package/lib/types/favicon.d.ts +43 -0
  37. package/lib/types/favicon.d.ts.map +1 -0
  38. package/lib/types/font.d.ts +119 -0
  39. package/lib/types/font.d.ts.map +1 -0
  40. package/lib/types/fs-router.d.ts +47 -0
  41. package/lib/types/fs-router.d.ts.map +1 -0
  42. package/lib/types/i18n-routing.d.ts +98 -0
  43. package/lib/types/i18n-routing.d.ts.map +1 -0
  44. package/lib/types/image-plugin.d.ts +79 -0
  45. package/lib/types/image-plugin.d.ts.map +1 -0
  46. package/lib/types/image.d.ts +51 -0
  47. package/lib/types/image.d.ts.map +1 -0
  48. package/lib/types/index.d.ts +46 -0
  49. package/lib/types/index.d.ts.map +1 -0
  50. package/lib/types/isr.d.ts +9 -0
  51. package/lib/types/isr.d.ts.map +1 -0
  52. package/lib/types/link.d.ts +127 -0
  53. package/lib/types/link.d.ts.map +1 -0
  54. package/lib/types/meta.d.ts +91 -0
  55. package/lib/types/meta.d.ts.map +1 -0
  56. package/lib/types/middleware.d.ts +35 -0
  57. package/lib/types/middleware.d.ts.map +1 -0
  58. package/lib/types/not-found.d.ts +7 -0
  59. package/lib/types/not-found.d.ts.map +1 -0
  60. package/lib/types/rate-limit.d.ts +34 -0
  61. package/lib/types/rate-limit.d.ts.map +1 -0
  62. package/lib/types/script.d.ts +35 -0
  63. package/lib/types/script.d.ts.map +1 -0
  64. package/lib/types/seo.d.ts +88 -0
  65. package/lib/types/seo.d.ts.map +1 -0
  66. package/lib/types/testing.d.ts +85 -0
  67. package/lib/types/testing.d.ts.map +1 -0
  68. package/lib/types/theme.d.ts +39 -0
  69. package/lib/types/theme.d.ts.map +1 -0
  70. package/lib/types/types.d.ts +111 -0
  71. package/lib/types/types.d.ts.map +1 -0
  72. package/lib/types/utils/use-intersection-observer.d.ts +10 -0
  73. package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
  74. package/lib/types/utils/with-headers.d.ts +6 -0
  75. package/lib/types/utils/with-headers.d.ts.map +1 -0
  76. package/lib/types/vite-plugin.d.ts +17 -0
  77. package/lib/types/vite-plugin.d.ts.map +1 -0
  78. package/package.json +10 -10
  79. package/src/favicon.ts +2 -2
  80. package/src/font.ts +32 -8
  81. package/src/image-plugin.ts +1 -1
  82. package/src/link.tsx +7 -0
package/lib/font.js CHANGED
@@ -38,13 +38,26 @@ function parseGoogleFamily(input) {
38
38
  variable: true,
39
39
  weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])]
40
40
  };
41
- const weightMatch = spec.match(/wght@([\d;]+)/);
42
- if (weightMatch && weightMatch[1]) return {
43
- family,
44
- italic,
45
- variable: false,
46
- weights: weightMatch[1].split(";").map(Number)
47
- };
41
+ const afterAt = spec.split("@")[1];
42
+ if (afterAt) {
43
+ const entries = afterAt.split(";").filter(Boolean);
44
+ const weights = /* @__PURE__ */ new Set();
45
+ for (const entry of entries) if (entry.includes(",")) {
46
+ const parts = entry.split(",");
47
+ const weight = Number(parts[parts.length - 1]);
48
+ if (weight > 0) weights.add(weight);
49
+ if (parts[0] === "1") italic = true;
50
+ } else if (entry.includes("..")) {} else {
51
+ const weight = Number(entry);
52
+ if (weight > 0) weights.add(weight);
53
+ }
54
+ if (weights.size > 0) return {
55
+ family,
56
+ italic,
57
+ variable: false,
58
+ weights: [...weights].sort((a, b) => a - b)
59
+ };
60
+ }
48
61
  }
49
62
  return {
50
63
  family,
package/lib/font.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"font.js","names":[],"sources":["../src/font.ts"],"sourcesContent":["import type { Plugin } from 'vite'\n\n// ─── Font optimization ──────────────────────────────────────────────────────\n//\n// Zero provides automatic font optimization:\n// - Downloads and self-hosts Google Fonts at build time (privacy + performance)\n// - Falls back to CDN link in dev mode (for fast dev startup)\n// - Injects preconnect/preload hints into the HTML\n// - Sets font-display: swap to prevent FOIT (Flash of Invisible Text)\n// - Generates optimized @font-face declarations\n// - Size-adjusted fallback fonts to reduce CLS\n\nexport interface FontConfig {\n /**\n * Google Fonts families.\n *\n * Accepts both string shorthand and structured objects:\n * - String: \"Inter:wght@400;500;700\" or \"Inter:wght@100..900\"\n * - Object: { family: \"Inter\", weights: [400, 500, 700] }\n * - Variable: { family: \"Inter\", variable: true, weightRange: [100, 900] }\n */\n google?: GoogleFontInput[]\n /** Local font files. */\n local?: LocalFont[]\n /** Default font-display strategy. Default: \"swap\" */\n display?: FontDisplay\n /** Preload critical fonts. Default: true */\n preload?: boolean\n /** Self-host Google Fonts at build time. Default: true */\n selfHost?: boolean\n /** Fallback font metrics for reducing CLS. */\n fallbacks?: Record<string, FallbackMetrics>\n}\n\n/** Static Google Font config. */\nexport interface GoogleFontStatic {\n family: string\n weights: number[]\n italic?: boolean\n variable?: false\n}\n\n/** Variable Google Font config. */\nexport interface GoogleFontVariable {\n family: string\n /** Weight range as [min, max] tuple. e.g. [100, 900] */\n weightRange: [number, number]\n italic?: boolean\n variable: true\n}\n\n/** Google font input: structured object or string shorthand. */\nexport type GoogleFontInput = GoogleFontStatic | GoogleFontVariable | string\n\nexport interface LocalFont {\n family: string\n src: string\n /** Single weight (400) or variable range (\"100 900\"). */\n weight?: number | `${number} ${number}`\n style?: 'normal' | 'italic'\n display?: FontDisplay\n}\n\nexport type FontDisplay = 'auto' | 'block' | 'swap' | 'fallback' | 'optional'\n\n/** Metrics for generating size-adjusted fallback fonts to reduce CLS. */\nexport interface FallbackMetrics {\n /** The fallback font to adjust. e.g. \"Arial\", \"Georgia\" */\n fallback: string\n /** Size adjustment factor. e.g. 1.05 */\n sizeAdjust?: number\n /** Ascent override percentage. e.g. 90 */\n ascentOverride?: number\n /** Descent override percentage. e.g. 22 */\n descentOverride?: number\n /** Line gap override percentage. e.g. 0 */\n lineGapOverride?: number\n}\n\ninterface ResolvedFontBase {\n family: string\n italic: boolean\n}\n\ninterface StaticFont extends ResolvedFontBase {\n variable: false\n weights: number[]\n}\n\ninterface VariableFont extends ResolvedFontBase {\n variable: true\n weightRange: [number, number]\n}\n\ntype ResolvedFont = StaticFont | VariableFont\n\n/**\n * Normalize a GoogleFontInput (string or object) into a ResolvedFont.\n */\nexport function resolveGoogleFont(input: GoogleFontInput): ResolvedFont {\n if (typeof input === 'string') {\n return parseGoogleFamily(input)\n }\n\n if (input.variable) {\n return {\n family: input.family,\n italic: input.italic ?? false,\n variable: true,\n weightRange: input.weightRange,\n }\n }\n\n return {\n family: input.family,\n italic: input.italic ?? false,\n variable: false,\n weights: input.weights,\n }\n}\n\n/**\n * Parse Google Fonts family string shorthand.\n *\n * Static weights: \"Inter:wght@400;500;700\"\n * Variable range: \"Inter:wght@100..900\"\n * Variable with italic: \"Inter:ital,wght@100..900\"\n */\nexport function parseGoogleFamily(input: string): ResolvedFont {\n const parts = input.split(':')\n const family = (parts[0] ?? '').trim()\n const spec = parts[1]\n let italic = false\n\n if (spec) {\n italic = spec.includes('ital')\n\n // Variable font range syntax: wght@100..900\n const rangeMatch = spec.match(/wght@(\\d+)\\.\\.(\\d+)/)\n if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {\n return {\n family,\n italic,\n variable: true,\n weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])],\n }\n }\n\n // Static weights: wght@400;500;700\n const weightMatch = spec.match(/wght@([\\d;]+)/)\n if (weightMatch && weightMatch[1]) {\n return {\n family,\n italic,\n variable: false,\n weights: weightMatch[1].split(';').map(Number),\n }\n }\n }\n\n return { family, italic, variable: false, weights: [400] }\n}\n\n/**\n * Generate a Google Fonts CSS URL.\n */\nexport function googleFontsUrl(families: ResolvedFont[], display: FontDisplay = 'swap'): string {\n const params = families\n .map((f) => {\n const axes = f.italic ? 'ital,wght' : 'wght'\n const name = f.family.replace(/ /g, '+')\n\n if (f.variable) {\n const range = `${f.weightRange[0]}..${f.weightRange[1]}`\n const value = f.italic ? `0,${range};1,${range}` : range\n return `family=${name}:${axes}@${value}`\n }\n\n const values = f.weights.map((w) => (f.italic ? `0,${w};1,${w}` : String(w))).join(';')\n return `family=${name}:${axes}@${values}`\n })\n .join('&')\n\n return `https://fonts.googleapis.com/css2?${params}&display=${display}`\n}\n\n/**\n * Generate @font-face CSS for local fonts.\n */\nfunction localFontFaces(fonts: LocalFont[], display: FontDisplay): string {\n return fonts\n .map(\n (f) => `@font-face {\n font-family: \"${f.family}\";\n src: url(\"${f.src}\");\n font-weight: ${f.weight ?? '400'};\n font-style: ${f.style ?? 'normal'};\n font-display: ${f.display ?? display};\n}`,\n )\n .join('\\n\\n')\n}\n\n/**\n * Generate size-adjusted fallback @font-face declarations to reduce CLS.\n */\nfunction fallbackFontFaces(fallbacks: Record<string, FallbackMetrics>): string {\n return Object.entries(fallbacks)\n .map(([family, metrics]) => {\n const overrides: string[] = []\n if (metrics.sizeAdjust != null) overrides.push(` size-adjust: ${metrics.sizeAdjust * 100}%;`)\n if (metrics.ascentOverride != null)\n overrides.push(` ascent-override: ${metrics.ascentOverride}%;`)\n if (metrics.descentOverride != null)\n overrides.push(` descent-override: ${metrics.descentOverride}%;`)\n if (metrics.lineGapOverride != null)\n overrides.push(` line-gap-override: ${metrics.lineGapOverride}%;`)\n\n return `@font-face {\n font-family: \"${family} Fallback\";\n src: local(\"${metrics.fallback}\");\n${overrides.join('\\n')}\n}`\n })\n .join('\\n\\n')\n}\n\n/**\n * Generate preload link tags for critical font files.\n */\nfunction preloadTags(fonts: LocalFont[]): string {\n return fonts\n .map((f) => {\n const ext = f.src.split('.').pop()\n const type =\n ext === 'woff2'\n ? 'font/woff2'\n : ext === 'woff'\n ? 'font/woff'\n : ext === 'ttf'\n ? 'font/ttf'\n : 'font/otf'\n return `<link rel=\"preload\" href=\"${f.src}\" as=\"font\" type=\"${type}\" crossorigin>`\n })\n .join('\\n')\n}\n\n/**\n * Download Google Fonts CSS with woff2 user agent.\n */\nasync function downloadGoogleFontsCSS(url: string): Promise<string> {\n const response = await fetch(url, {\n headers: {\n 'User-Agent':\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n },\n })\n if (!response.ok) {\n throw new Error(`Failed to fetch Google Fonts CSS: ${response.status}`)\n }\n return response.text()\n}\n\n/**\n * Download a font file.\n */\nasync function downloadFontFile(url: string): Promise<Buffer> {\n const response = await fetch(url)\n if (!response.ok) throw new Error(`Failed to download font: ${url}`)\n const arrayBuffer = await response.arrayBuffer()\n return Buffer.from(arrayBuffer)\n}\n\n/**\n * Extract font file URLs from Google Fonts CSS.\n */\nfunction extractFontUrls(css: string): string[] {\n const urls: string[] = []\n const regex = /url\\((https:\\/\\/fonts\\.gstatic\\.com\\/[^)]+)\\)/g\n for (const match of css.matchAll(regex)) {\n if (match[1]) urls.push(match[1])\n }\n return urls\n}\n\n/**\n * Self-host Google Fonts: download CSS + font files, rewrite URLs to local paths.\n */\nasync function selfHostFonts(\n cssUrl: string,\n fontsSubDir: string,\n): Promise<{\n css: string\n fontFiles: Array<{ name: string; content: Buffer }>\n}> {\n const css = await downloadGoogleFontsCSS(cssUrl)\n const fontUrls = extractFontUrls(css)\n const fontFiles: Array<{ name: string; content: Buffer }> = []\n\n let rewrittenCss = css\n\n for (const url of fontUrls) {\n const urlParts = url.split('/')\n const fileName = urlParts.at(-1)?.split('?')[0] ?? 'font'\n const content = await downloadFontFile(url)\n\n fontFiles.push({ name: fileName, content })\n rewrittenCss = rewrittenCss.replace(url, `/${fontsSubDir}/${fileName}`)\n }\n\n return { css: rewrittenCss, fontFiles }\n}\n\n/**\n * Zero font optimization Vite plugin.\n *\n * Dev mode: injects Google Fonts CDN link for fast startup.\n * Build mode: downloads and self-hosts fonts for maximum performance + privacy.\n *\n * @example\n * import { fontPlugin } from \"@pyreon/zero/font\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * fontPlugin({\n * google: [\"Inter:wght@400;500;600;700\", \"JetBrains Mono:wght@400\"],\n * fallbacks: {\n * \"Inter\": { fallback: \"Arial\", sizeAdjust: 1.07, ascentOverride: 90 },\n * },\n * }),\n * ],\n * }\n */\nexport function fontPlugin(config: FontConfig = {}): Plugin {\n const display = config.display ?? 'swap'\n const shouldPreload = config.preload !== false\n const shouldSelfHost = config.selfHost !== false\n const googleFamilies = (config.google ?? []).map(resolveGoogleFont)\n\n let isBuild = false\n let selfHostedCSS = ''\n let selfHostedFontFiles: Array<{ name: string; content: Buffer }> = []\n\n return {\n name: 'pyreon-zero-fonts',\n\n configResolved(resolvedConfig) {\n isBuild = resolvedConfig.command === 'build'\n },\n\n async buildStart() {\n if (isBuild && shouldSelfHost && googleFamilies.length > 0) {\n const cssUrl = googleFontsUrl(googleFamilies, display)\n try {\n const result = await selfHostFonts(cssUrl, 'assets/fonts')\n selfHostedCSS = result.css\n selfHostedFontFiles = result.fontFiles\n } catch {\n // Self-hosting failed — fall back to CDN link\n }\n }\n },\n\n generateBundle() {\n // Emit self-hosted font files as assets\n for (const file of selfHostedFontFiles) {\n this.emitFile({\n type: 'asset',\n fileName: `assets/fonts/${file.name}`,\n source: file.content,\n })\n }\n },\n\n transformIndexHtml(html) {\n const tags: string[] = []\n\n collectGoogleFontTags(tags, {\n isBuild,\n selfHostedCSS,\n selfHostedFontFiles,\n shouldPreload,\n googleFamilies,\n display,\n })\n collectLocalFontTags(tags, config, shouldPreload, display)\n\n if (tags.length === 0) return html\n return html.replace('</head>', `${tags.join('\\n')}\\n</head>`)\n },\n }\n}\n\nfunction collectGoogleFontTags(\n tags: string[],\n opts: {\n isBuild: boolean\n selfHostedCSS: string\n selfHostedFontFiles: Array<{ name: string; content: Buffer }>\n shouldPreload: boolean\n googleFamilies: ResolvedFont[]\n display: FontDisplay\n },\n) {\n if (opts.isBuild && opts.selfHostedCSS) {\n tags.push(`<style>${opts.selfHostedCSS}</style>`)\n if (opts.shouldPreload) {\n for (const file of opts.selfHostedFontFiles.slice(0, opts.googleFamilies.length)) {\n const ext = file.name.split('.').pop()\n const type = ext === 'woff2' ? 'font/woff2' : 'font/woff'\n tags.push(\n `<link rel=\"preload\" href=\"/assets/fonts/${file.name}\" as=\"font\" type=\"${type}\" crossorigin>`,\n )\n }\n }\n } else if (opts.googleFamilies.length > 0) {\n const cssUrl = googleFontsUrl(opts.googleFamilies, opts.display)\n tags.push(`<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">`)\n tags.push(`<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>`)\n tags.push(`<link rel=\"stylesheet\" href=\"${cssUrl}\">`)\n }\n}\n\nfunction collectLocalFontTags(\n tags: string[],\n config: FontConfig,\n shouldPreload: boolean,\n display: FontDisplay,\n) {\n if (shouldPreload && config.local?.length) {\n tags.push(preloadTags(config.local))\n }\n if (config.local?.length) {\n tags.push(`<style>${localFontFaces(config.local, display)}</style>`)\n }\n if (config.fallbacks && Object.keys(config.fallbacks).length > 0) {\n tags.push(`<style>${fallbackFontFaces(config.fallbacks)}</style>`)\n }\n}\n\n/**\n * Generate CSS variables for font families.\n */\nexport function fontVariables(families: Record<string, string>): string {\n const vars = Object.entries(families)\n .map(([key, value]) => ` --font-${key}: ${value};`)\n .join('\\n')\n return `:root {\\n${vars}\\n}`\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"font.js","names":[],"sources":["../src/font.ts"],"sourcesContent":["import type { Plugin } from 'vite'\n\n// ─── Font optimization ──────────────────────────────────────────────────────\n//\n// Zero provides automatic font optimization:\n// - Downloads and self-hosts Google Fonts at build time (privacy + performance)\n// - Falls back to CDN link in dev mode (for fast dev startup)\n// - Injects preconnect/preload hints into the HTML\n// - Sets font-display: swap to prevent FOIT (Flash of Invisible Text)\n// - Generates optimized @font-face declarations\n// - Size-adjusted fallback fonts to reduce CLS\n\nexport interface FontConfig {\n /**\n * Google Fonts families.\n *\n * Accepts both string shorthand and structured objects:\n * - String: \"Inter:wght@400;500;700\" or \"Inter:wght@100..900\"\n * - Object: { family: \"Inter\", weights: [400, 500, 700] }\n * - Variable: { family: \"Inter\", variable: true, weightRange: [100, 900] }\n */\n google?: GoogleFontInput[]\n /** Local font files. */\n local?: LocalFont[]\n /** Default font-display strategy. Default: \"swap\" */\n display?: FontDisplay\n /** Preload critical fonts. Default: true */\n preload?: boolean\n /** Self-host Google Fonts at build time. Default: true */\n selfHost?: boolean\n /** Fallback font metrics for reducing CLS. */\n fallbacks?: Record<string, FallbackMetrics>\n}\n\n/** Static Google Font config. */\nexport interface GoogleFontStatic {\n family: string\n weights: number[]\n italic?: boolean\n variable?: false\n}\n\n/** Variable Google Font config. */\nexport interface GoogleFontVariable {\n family: string\n /** Weight range as [min, max] tuple. e.g. [100, 900] */\n weightRange: [number, number]\n italic?: boolean\n variable: true\n}\n\n/** Google font input: structured object or string shorthand. */\nexport type GoogleFontInput = GoogleFontStatic | GoogleFontVariable | string\n\nexport interface LocalFont {\n family: string\n src: string\n /** Single weight (400) or variable range (\"100 900\"). */\n weight?: number | `${number} ${number}`\n style?: 'normal' | 'italic'\n display?: FontDisplay\n}\n\nexport type FontDisplay = 'auto' | 'block' | 'swap' | 'fallback' | 'optional'\n\n/** Metrics for generating size-adjusted fallback fonts to reduce CLS. */\nexport interface FallbackMetrics {\n /** The fallback font to adjust. e.g. \"Arial\", \"Georgia\" */\n fallback: string\n /** Size adjustment factor. e.g. 1.05 */\n sizeAdjust?: number\n /** Ascent override percentage. e.g. 90 */\n ascentOverride?: number\n /** Descent override percentage. e.g. 22 */\n descentOverride?: number\n /** Line gap override percentage. e.g. 0 */\n lineGapOverride?: number\n}\n\ninterface ResolvedFontBase {\n family: string\n italic: boolean\n}\n\ninterface StaticFont extends ResolvedFontBase {\n variable: false\n weights: number[]\n}\n\ninterface VariableFont extends ResolvedFontBase {\n variable: true\n weightRange: [number, number]\n}\n\ntype ResolvedFont = StaticFont | VariableFont\n\n/**\n * Normalize a GoogleFontInput (string or object) into a ResolvedFont.\n */\nexport function resolveGoogleFont(input: GoogleFontInput): ResolvedFont {\n if (typeof input === 'string') {\n return parseGoogleFamily(input)\n }\n\n if (input.variable) {\n return {\n family: input.family,\n italic: input.italic ?? false,\n variable: true,\n weightRange: input.weightRange,\n }\n }\n\n return {\n family: input.family,\n italic: input.italic ?? false,\n variable: false,\n weights: input.weights,\n }\n}\n\n/**\n * Parse Google Fonts family string shorthand.\n *\n * Static weights: \"Inter:wght@400;500;700\"\n * Variable range: \"Inter:wght@100..900\"\n * Variable with italic: \"Inter:ital,wght@100..900\"\n */\nexport function parseGoogleFamily(input: string): ResolvedFont {\n const parts = input.split(':')\n const family = (parts[0] ?? '').trim()\n const spec = parts[1]\n let italic = false\n\n if (spec) {\n italic = spec.includes('ital')\n\n // Variable font range syntax: wght@100..900\n const rangeMatch = spec.match(/wght@(\\d+)\\.\\.(\\d+)/)\n if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {\n return {\n family,\n italic,\n variable: true,\n weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])],\n }\n }\n\n // Static weights — two formats:\n // Simple: \"wght@400;500;700\"\n // Tuples: \"ital,wght@0,300;0,500;1,300;1,500\" (ital_flag,weight pairs)\n const afterAt = spec.split('@')[1]\n if (afterAt) {\n const entries = afterAt.split(';').filter(Boolean)\n const weights = new Set<number>()\n\n for (const entry of entries) {\n if (entry.includes(',')) {\n // Tuple format: \"0,300\" or \"1,500\" — last value is the weight\n const parts = entry.split(',')\n const weight = Number(parts[parts.length - 1])\n if (weight > 0) weights.add(weight)\n // Detect italic from tuple: \"1,xxx\" means italic\n if (parts[0] === '1') italic = true\n } else if (entry.includes('..')) {\n // Variable range already handled above — skip\n } else {\n // Simple weight: \"400\"\n const weight = Number(entry)\n if (weight > 0) weights.add(weight)\n }\n }\n\n if (weights.size > 0) {\n return {\n family,\n italic,\n variable: false,\n weights: [...weights].sort((a, b) => a - b),\n }\n }\n }\n }\n\n return { family, italic, variable: false, weights: [400] }\n}\n\n/**\n * Generate a Google Fonts CSS URL.\n */\nexport function googleFontsUrl(families: ResolvedFont[], display: FontDisplay = 'swap'): string {\n const params = families\n .map((f) => {\n const axes = f.italic ? 'ital,wght' : 'wght'\n const name = f.family.replace(/ /g, '+')\n\n if (f.variable) {\n const range = `${f.weightRange[0]}..${f.weightRange[1]}`\n const value = f.italic ? `0,${range};1,${range}` : range\n return `family=${name}:${axes}@${value}`\n }\n\n const values = f.weights.map((w) => (f.italic ? `0,${w};1,${w}` : String(w))).join(';')\n return `family=${name}:${axes}@${values}`\n })\n .join('&')\n\n return `https://fonts.googleapis.com/css2?${params}&display=${display}`\n}\n\n/**\n * Generate @font-face CSS for local fonts.\n */\nfunction localFontFaces(fonts: LocalFont[], display: FontDisplay): string {\n return fonts\n .map(\n (f) => `@font-face {\n font-family: \"${f.family}\";\n src: url(\"${f.src}\");\n font-weight: ${f.weight ?? '400'};\n font-style: ${f.style ?? 'normal'};\n font-display: ${f.display ?? display};\n}`,\n )\n .join('\\n\\n')\n}\n\n/**\n * Generate size-adjusted fallback @font-face declarations to reduce CLS.\n */\nfunction fallbackFontFaces(fallbacks: Record<string, FallbackMetrics>): string {\n return Object.entries(fallbacks)\n .map(([family, metrics]) => {\n const overrides: string[] = []\n if (metrics.sizeAdjust != null) overrides.push(` size-adjust: ${metrics.sizeAdjust * 100}%;`)\n if (metrics.ascentOverride != null)\n overrides.push(` ascent-override: ${metrics.ascentOverride}%;`)\n if (metrics.descentOverride != null)\n overrides.push(` descent-override: ${metrics.descentOverride}%;`)\n if (metrics.lineGapOverride != null)\n overrides.push(` line-gap-override: ${metrics.lineGapOverride}%;`)\n\n return `@font-face {\n font-family: \"${family} Fallback\";\n src: local(\"${metrics.fallback}\");\n${overrides.join('\\n')}\n}`\n })\n .join('\\n\\n')\n}\n\n/**\n * Generate preload link tags for critical font files.\n */\nfunction preloadTags(fonts: LocalFont[]): string {\n return fonts\n .map((f) => {\n const ext = f.src.split('.').pop()\n const type =\n ext === 'woff2'\n ? 'font/woff2'\n : ext === 'woff'\n ? 'font/woff'\n : ext === 'ttf'\n ? 'font/ttf'\n : 'font/otf'\n return `<link rel=\"preload\" href=\"${f.src}\" as=\"font\" type=\"${type}\" crossorigin>`\n })\n .join('\\n')\n}\n\n/**\n * Download Google Fonts CSS with woff2 user agent.\n */\nasync function downloadGoogleFontsCSS(url: string): Promise<string> {\n const response = await fetch(url, {\n headers: {\n 'User-Agent':\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n },\n })\n if (!response.ok) {\n throw new Error(`Failed to fetch Google Fonts CSS: ${response.status}`)\n }\n return response.text()\n}\n\n/**\n * Download a font file.\n */\nasync function downloadFontFile(url: string): Promise<Buffer> {\n const response = await fetch(url)\n if (!response.ok) throw new Error(`Failed to download font: ${url}`)\n const arrayBuffer = await response.arrayBuffer()\n return Buffer.from(arrayBuffer)\n}\n\n/**\n * Extract font file URLs from Google Fonts CSS.\n */\nfunction extractFontUrls(css: string): string[] {\n const urls: string[] = []\n const regex = /url\\((https:\\/\\/fonts\\.gstatic\\.com\\/[^)]+)\\)/g\n for (const match of css.matchAll(regex)) {\n if (match[1]) urls.push(match[1])\n }\n return urls\n}\n\n/**\n * Self-host Google Fonts: download CSS + font files, rewrite URLs to local paths.\n */\nasync function selfHostFonts(\n cssUrl: string,\n fontsSubDir: string,\n): Promise<{\n css: string\n fontFiles: Array<{ name: string; content: Buffer }>\n}> {\n const css = await downloadGoogleFontsCSS(cssUrl)\n const fontUrls = extractFontUrls(css)\n const fontFiles: Array<{ name: string; content: Buffer }> = []\n\n let rewrittenCss = css\n\n for (const url of fontUrls) {\n const urlParts = url.split('/')\n const fileName = urlParts.at(-1)?.split('?')[0] ?? 'font'\n const content = await downloadFontFile(url)\n\n fontFiles.push({ name: fileName, content })\n rewrittenCss = rewrittenCss.replace(url, `/${fontsSubDir}/${fileName}`)\n }\n\n return { css: rewrittenCss, fontFiles }\n}\n\n/**\n * Zero font optimization Vite plugin.\n *\n * Dev mode: injects Google Fonts CDN link for fast startup.\n * Build mode: downloads and self-hosts fonts for maximum performance + privacy.\n *\n * @example\n * import { fontPlugin } from \"@pyreon/zero/font\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * fontPlugin({\n * google: [\"Inter:wght@400;500;600;700\", \"JetBrains Mono:wght@400\"],\n * fallbacks: {\n * \"Inter\": { fallback: \"Arial\", sizeAdjust: 1.07, ascentOverride: 90 },\n * },\n * }),\n * ],\n * }\n */\nexport function fontPlugin(config: FontConfig = {}): Plugin {\n const display = config.display ?? 'swap'\n const shouldPreload = config.preload !== false\n const shouldSelfHost = config.selfHost !== false\n const googleFamilies = (config.google ?? []).map(resolveGoogleFont)\n\n let isBuild = false\n let selfHostedCSS = ''\n let selfHostedFontFiles: Array<{ name: string; content: Buffer }> = []\n\n return {\n name: 'pyreon-zero-fonts',\n\n configResolved(resolvedConfig) {\n isBuild = resolvedConfig.command === 'build'\n },\n\n async buildStart() {\n if (isBuild && shouldSelfHost && googleFamilies.length > 0) {\n const cssUrl = googleFontsUrl(googleFamilies, display)\n try {\n const result = await selfHostFonts(cssUrl, 'assets/fonts')\n selfHostedCSS = result.css\n selfHostedFontFiles = result.fontFiles\n } catch {\n // Self-hosting failed — fall back to CDN link\n }\n }\n },\n\n generateBundle() {\n // Emit self-hosted font files as assets\n for (const file of selfHostedFontFiles) {\n this.emitFile({\n type: 'asset',\n fileName: `assets/fonts/${file.name}`,\n source: file.content,\n })\n }\n },\n\n transformIndexHtml(html) {\n const tags: string[] = []\n\n collectGoogleFontTags(tags, {\n isBuild,\n selfHostedCSS,\n selfHostedFontFiles,\n shouldPreload,\n googleFamilies,\n display,\n })\n collectLocalFontTags(tags, config, shouldPreload, display)\n\n if (tags.length === 0) return html\n return html.replace('</head>', `${tags.join('\\n')}\\n</head>`)\n },\n }\n}\n\nfunction collectGoogleFontTags(\n tags: string[],\n opts: {\n isBuild: boolean\n selfHostedCSS: string\n selfHostedFontFiles: Array<{ name: string; content: Buffer }>\n shouldPreload: boolean\n googleFamilies: ResolvedFont[]\n display: FontDisplay\n },\n) {\n if (opts.isBuild && opts.selfHostedCSS) {\n tags.push(`<style>${opts.selfHostedCSS}</style>`)\n if (opts.shouldPreload) {\n for (const file of opts.selfHostedFontFiles.slice(0, opts.googleFamilies.length)) {\n const ext = file.name.split('.').pop()\n const type = ext === 'woff2' ? 'font/woff2' : 'font/woff'\n tags.push(\n `<link rel=\"preload\" href=\"/assets/fonts/${file.name}\" as=\"font\" type=\"${type}\" crossorigin>`,\n )\n }\n }\n } else if (opts.googleFamilies.length > 0) {\n const cssUrl = googleFontsUrl(opts.googleFamilies, opts.display)\n tags.push(`<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">`)\n tags.push(`<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>`)\n tags.push(`<link rel=\"stylesheet\" href=\"${cssUrl}\">`)\n }\n}\n\nfunction collectLocalFontTags(\n tags: string[],\n config: FontConfig,\n shouldPreload: boolean,\n display: FontDisplay,\n) {\n if (shouldPreload && config.local?.length) {\n tags.push(preloadTags(config.local))\n }\n if (config.local?.length) {\n tags.push(`<style>${localFontFaces(config.local, display)}</style>`)\n }\n if (config.fallbacks && Object.keys(config.fallbacks).length > 0) {\n tags.push(`<style>${fallbackFontFaces(config.fallbacks)}</style>`)\n }\n}\n\n/**\n * Generate CSS variables for font families.\n */\nexport function fontVariables(families: Record<string, string>): string {\n const vars = Object.entries(families)\n .map(([key, value]) => ` --font-${key}: ${value};`)\n .join('\\n')\n return `:root {\\n${vars}\\n}`\n}\n"],"mappings":"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"}
@@ -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 // biome-ignore lint/suspicious/noConsole: intentional build-time warning\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\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 /** File patterns to process. Default: /\\.(jpe?g|png|webp|avif)$/i */\n include?: RegExp\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 outSubDir = config.outDir ?? 'assets/img'\n const include = config.include ?? IMAGE_EXT_RE\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 // 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 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 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[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\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 /** File patterns to process. Default: /\\.(jpe?g|png|webp|avif)$/i */\n include?: RegExp\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 outSubDir = config.outDir ?? 'assets/img'\n const include = config.include ?? IMAGE_EXT_RE\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 // 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 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 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,kHACD;;AA6DH,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,YAAY,OAAO,UAAU;CACnC,MAAM,UAAU,OAAO,WAAW;CAElC,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,GAAG,SAAS,YAAY,IAAI,QAAQ,KAAK,GAAG,MAAM,IAAI,CAAC,GAAI,CAC7D,QAAO,wBAAwB;AAEjC,UAAO;;EAGT,MAAM,KAAK,IAAI;AACb,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;AAE1E,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
@@ -1050,6 +1050,7 @@ function useLink(props) {
1050
1050
  const elementRef = createRef();
1051
1051
  const strategy = props.prefetch ?? "hover";
1052
1052
  function handleClick(e) {
1053
+ if (props.onClick) props.onClick(e);
1053
1054
  if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || props.external) return;
1054
1055
  e.preventDefault();
1055
1056
  router.push(props.href);
@@ -1410,13 +1411,26 @@ function parseGoogleFamily(input) {
1410
1411
  variable: true,
1411
1412
  weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])]
1412
1413
  };
1413
- const weightMatch = spec.match(/wght@([\d;]+)/);
1414
- if (weightMatch && weightMatch[1]) return {
1415
- family,
1416
- italic,
1417
- variable: false,
1418
- weights: weightMatch[1].split(";").map(Number)
1419
- };
1414
+ const afterAt = spec.split("@")[1];
1415
+ if (afterAt) {
1416
+ const entries = afterAt.split(";").filter(Boolean);
1417
+ const weights = /* @__PURE__ */ new Set();
1418
+ for (const entry of entries) if (entry.includes(",")) {
1419
+ const parts = entry.split(",");
1420
+ const weight = Number(parts[parts.length - 1]);
1421
+ if (weight > 0) weights.add(weight);
1422
+ if (parts[0] === "1") italic = true;
1423
+ } else if (entry.includes("..")) {} else {
1424
+ const weight = Number(entry);
1425
+ if (weight > 0) weights.add(weight);
1426
+ }
1427
+ if (weights.size > 0) return {
1428
+ family,
1429
+ italic,
1430
+ variable: false,
1431
+ weights: [...weights].sort((a, b) => a - b)
1432
+ };
1433
+ }
1420
1434
  }
1421
1435
  return {
1422
1436
  family,