@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.
Files changed (51) hide show
  1. package/lib/client.js +41 -5
  2. package/lib/client.js.map +1 -1
  3. package/lib/env.js +6 -6
  4. package/lib/env.js.map +1 -1
  5. package/lib/favicon.js +2 -2
  6. package/lib/favicon.js.map +1 -1
  7. package/lib/font.js +2 -2
  8. package/lib/font.js.map +1 -1
  9. package/lib/i18n-routing.js.map +1 -1
  10. package/lib/image-plugin.js +1 -1
  11. package/lib/image-plugin.js.map +1 -1
  12. package/lib/index.js +39 -10
  13. package/lib/index.js.map +1 -1
  14. package/lib/link.js +12 -4
  15. package/lib/link.js.map +1 -1
  16. package/lib/meta.js.map +1 -1
  17. package/lib/og-image.js +2 -2
  18. package/lib/og-image.js.map +1 -1
  19. package/lib/script.js +1 -0
  20. package/lib/script.js.map +1 -1
  21. package/lib/server.js +132 -12
  22. package/lib/server.js.map +1 -1
  23. package/lib/theme.js +27 -7
  24. package/lib/theme.js.map +1 -1
  25. package/lib/types/client.d.ts +26 -0
  26. package/lib/types/client.d.ts.map +1 -1
  27. package/lib/types/config.d.ts +7 -0
  28. package/lib/types/config.d.ts.map +1 -1
  29. package/lib/types/index.d.ts +13 -1
  30. package/lib/types/index.d.ts.map +1 -1
  31. package/lib/types/link.d.ts.map +1 -1
  32. package/lib/types/server.d.ts +14 -0
  33. package/lib/types/server.d.ts.map +1 -1
  34. package/lib/types/theme.d.ts +6 -1
  35. package/lib/types/theme.d.ts.map +1 -1
  36. package/package.json +10 -10
  37. package/src/adapters/index.ts +1 -1
  38. package/src/adapters/validate.ts +2 -2
  39. package/src/client.ts +84 -6
  40. package/src/env.ts +6 -6
  41. package/src/favicon.ts +3 -3
  42. package/src/font.ts +2 -2
  43. package/src/i18n-routing.ts +1 -1
  44. package/src/image-plugin.ts +1 -1
  45. package/src/isr.ts +34 -2
  46. package/src/link.tsx +21 -5
  47. package/src/og-image.ts +2 -2
  48. package/src/script.tsx +4 -0
  49. package/src/theme.tsx +33 -11
  50. package/src/types.ts +7 -0
  51. package/src/vite-plugin.ts +204 -2
package/lib/client.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Fragment, h } from "@pyreon/core";
2
+ import { RouterProvider, RouterView, createRouter, hydrateLoaderData } from "@pyreon/router";
2
3
  import { hydrateRoot, mount } from "@pyreon/runtime-dom";
3
4
  import { HeadProvider } from "@pyreon/head";
4
- import { RouterProvider, RouterView, createRouter } from "@pyreon/router";
5
5
 
6
6
  //#region src/app.ts
7
7
  /**
@@ -34,6 +34,32 @@ function DefaultLayout(props) {
34
34
  /**
35
35
  * Start the client-side app — hydrates SSR content or mounts fresh for SPA.
36
36
  *
37
+ * ## Loader data flow
38
+ *
39
+ * Direct navigation to a route with a `loader` function needs data to be
40
+ * available on the VERY FIRST render. This is handled in two modes:
41
+ *
42
+ * - **SSR mode (zero's default)**: the server pre-runs loaders, renders the
43
+ * HTML with loader data already applied, and embeds a JSON blob in the
44
+ * HTML as `window.__PYREON_LOADER_DATA__`. On the client we read that
45
+ * blob and call `hydrateLoaderData(router, data)` BEFORE hydrating — so
46
+ * the hydration pass sees the same data the SSR render produced
47
+ * (avoids hydration mismatches and the flash of "not found" fallback).
48
+ *
49
+ * - **SPA cold start (no SSR content)**: no `__PYREON_LOADER_DATA__` was
50
+ * embedded, so we call `router.replace(currentPath)` after mount to
51
+ * trigger the loader pipeline for the initial route. The first render
52
+ * shows whatever the component displays for `useLoaderData() === undefined`
53
+ * (typically a loading state or fallback); once loaders resolve, the
54
+ * reactive `useLoaderData` re-renders with the data. This matches
55
+ * standard SPA loading behavior.
56
+ *
57
+ * Without this wiring, direct URL navigation to a loader-backed route
58
+ * (e.g. `/posts/3`) showed the "Post not found" fallback indefinitely
59
+ * because `useLoaderData()` returned `undefined` forever. The router
60
+ * only ran loaders on in-app navigation (push/replace), not on initial
61
+ * mount.
62
+ *
37
63
  * @example
38
64
  * import { routes } from "virtual:zero/routes"
39
65
  * import { startClient } from "@pyreon/zero/client"
@@ -41,16 +67,26 @@ function DefaultLayout(props) {
41
67
  * startClient({ routes })
42
68
  */
43
69
  function startClient(options) {
70
+ if (typeof document === "undefined") throw new Error("[Pyreon] startClient() can only be called in the browser.");
44
71
  const container = document.getElementById("app");
45
- if (!container) throw new Error("[zero] Missing #app container element");
46
- const { App } = createApp({
72
+ if (!container) throw new Error("[Pyreon] Missing #app container element");
73
+ const { App, router } = createApp({
47
74
  routes: options.routes,
48
75
  routerMode: "history",
49
76
  ...options.layout ? { layout: options.layout } : {}
50
77
  });
78
+ const ssrLoaderData = window.__PYREON_LOADER_DATA__;
79
+ const hasSSRLoaderData = ssrLoaderData !== void 0 && typeof ssrLoaderData === "object" && ssrLoaderData !== null;
80
+ if (hasSSRLoaderData) hydrateLoaderData(router, ssrLoaderData);
51
81
  const vnode = h(App, null);
52
- if (container.childNodes.length > 0) return hydrateRoot(container, vnode);
53
- return mount(vnode, container);
82
+ const cleanup = container.childNodes.length > 0 ? hydrateRoot(container, vnode) : mount(vnode, container);
83
+ if (!hasSSRLoaderData) {
84
+ const currentPath = router.currentRoute().path;
85
+ router.replace(currentPath).catch((err) => {
86
+ if (import.meta.env?.DEV === true) console.warn("[Pyreon] Initial loader run failed for route:", currentPath, err);
87
+ });
88
+ }
89
+ return cleanup;
54
90
  }
55
91
 
56
92
  //#endregion
package/lib/client.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":[],"sources":["../src/app.ts","../src/client.ts"],"sourcesContent":["import type { ComponentFn, Props } from '@pyreon/core'\nimport { Fragment, h } from '@pyreon/core'\nimport { HeadProvider } from '@pyreon/head'\nimport type { RouteRecord } from '@pyreon/router'\nimport { createRouter, RouterProvider, RouterView } from '@pyreon/router'\n\n// ─── App assembly ────────────────────────────────────────────────────────────\n\nexport interface CreateAppOptions {\n /** Route definitions (from file-based routing or manual). */\n routes: RouteRecord[]\n\n /** Router mode. Default: \"history\" for SSR, \"hash\" for SPA. */\n routerMode?: 'hash' | 'history'\n\n /** Initial URL for SSR. */\n url?: string\n\n /** Root layout component wrapping all routes. */\n layout?: ComponentFn\n\n /** Global error component. */\n errorComponent?: ComponentFn\n}\n\n/**\n * Create a full Zero app — assembles router, head provider, and root layout.\n *\n * Used internally by entry-server and entry-client.\n */\nexport function createApp(options: CreateAppOptions) {\n const router = createRouter({\n routes: options.routes,\n mode: options.routerMode ?? 'history',\n ...(options.url ? { url: options.url } : {}),\n scrollBehavior: 'top',\n })\n\n const Layout = options.layout ?? DefaultLayout\n\n function App() {\n return h(\n HeadProvider,\n null,\n h(\n RouterProvider as ComponentFn<Props>,\n { router },\n h(Layout, null, h(RouterView as ComponentFn<Props>, null)),\n ),\n )\n }\n\n return { App, router }\n}\n\nfunction DefaultLayout(props: Props) {\n return h(Fragment, null, ...(Array.isArray(props.children) ? props.children : [props.children]))\n}\n","import type { ComponentFn } from '@pyreon/core'\nimport { h } from '@pyreon/core'\nimport type { RouteRecord } from '@pyreon/router'\nimport { hydrateRoot, mount } from '@pyreon/runtime-dom'\nimport { createApp } from './app'\n\n// ─── Client entry factory ───────────────────────────────────────────────────\n\nexport interface StartClientOptions {\n /** Route definitions. */\n routes: RouteRecord[]\n /** Root layout component. */\n layout?: ComponentFn\n}\n\n/**\n * Start the client-side app — hydrates SSR content or mounts fresh for SPA.\n *\n * @example\n * import { routes } from \"virtual:zero/routes\"\n * import { startClient } from \"@pyreon/zero/client\"\n *\n * startClient({ routes })\n */\nexport function startClient(options: StartClientOptions) {\n const container = document.getElementById('app')\n if (!container) throw new Error('[zero] Missing #app container element')\n\n const { App } = createApp({\n routes: options.routes,\n routerMode: 'history',\n ...(options.layout ? { layout: options.layout } : {}),\n })\n\n const vnode = h(App, null)\n\n // If container has SSR content, hydrate. Otherwise mount fresh.\n if (container.childNodes.length > 0) {\n return hydrateRoot(container, vnode)\n }\n\n return mount(vnode, container)\n}\n"],"mappings":";;;;;;;;;;;AA8BA,SAAgB,UAAU,SAA2B;CACnD,MAAM,SAAS,aAAa;EAC1B,QAAQ,QAAQ;EAChB,MAAM,QAAQ,cAAc;EAC5B,GAAI,QAAQ,MAAM,EAAE,KAAK,QAAQ,KAAK,GAAG,EAAE;EAC3C,gBAAgB;EACjB,CAAC;CAEF,MAAM,SAAS,QAAQ,UAAU;CAEjC,SAAS,MAAM;AACb,SAAO,EACL,cACA,MACA,EACE,gBACA,EAAE,QAAQ,EACV,EAAE,QAAQ,MAAM,EAAE,YAAkC,KAAK,CAAC,CAC3D,CACF;;AAGH,QAAO;EAAE;EAAK;EAAQ;;AAGxB,SAAS,cAAc,OAAc;AACnC,QAAO,EAAE,UAAU,MAAM,GAAI,MAAM,QAAQ,MAAM,SAAS,GAAG,MAAM,WAAW,CAAC,MAAM,SAAS,CAAE;;;;;;;;;;;;;;AChClG,SAAgB,YAAY,SAA6B;CACvD,MAAM,YAAY,SAAS,eAAe,MAAM;AAChD,KAAI,CAAC,UAAW,OAAM,IAAI,MAAM,wCAAwC;CAExE,MAAM,EAAE,QAAQ,UAAU;EACxB,QAAQ,QAAQ;EAChB,YAAY;EACZ,GAAI,QAAQ,SAAS,EAAE,QAAQ,QAAQ,QAAQ,GAAG,EAAE;EACrD,CAAC;CAEF,MAAM,QAAQ,EAAE,KAAK,KAAK;AAG1B,KAAI,UAAU,WAAW,SAAS,EAChC,QAAO,YAAY,WAAW,MAAM;AAGtC,QAAO,MAAM,OAAO,UAAU"}
1
+ {"version":3,"file":"client.js","names":[],"sources":["../src/app.ts","../src/client.ts"],"sourcesContent":["import type { ComponentFn, Props } from '@pyreon/core'\nimport { Fragment, h } from '@pyreon/core'\nimport { HeadProvider } from '@pyreon/head'\nimport type { RouteRecord } from '@pyreon/router'\nimport { createRouter, RouterProvider, RouterView } from '@pyreon/router'\n\n// ─── App assembly ────────────────────────────────────────────────────────────\n\nexport interface CreateAppOptions {\n /** Route definitions (from file-based routing or manual). */\n routes: RouteRecord[]\n\n /** Router mode. Default: \"history\" for SSR, \"hash\" for SPA. */\n routerMode?: 'hash' | 'history'\n\n /** Initial URL for SSR. */\n url?: string\n\n /** Root layout component wrapping all routes. */\n layout?: ComponentFn\n\n /** Global error component. */\n errorComponent?: ComponentFn\n}\n\n/**\n * Create a full Zero app — assembles router, head provider, and root layout.\n *\n * Used internally by entry-server and entry-client.\n */\nexport function createApp(options: CreateAppOptions) {\n const router = createRouter({\n routes: options.routes,\n mode: options.routerMode ?? 'history',\n ...(options.url ? { url: options.url } : {}),\n scrollBehavior: 'top',\n })\n\n const Layout = options.layout ?? DefaultLayout\n\n function App() {\n return h(\n HeadProvider,\n null,\n h(\n RouterProvider as ComponentFn<Props>,\n { router },\n h(Layout, null, h(RouterView as ComponentFn<Props>, null)),\n ),\n )\n }\n\n return { App, router }\n}\n\nfunction DefaultLayout(props: Props) {\n return h(Fragment, null, ...(Array.isArray(props.children) ? props.children : [props.children]))\n}\n","import type { ComponentFn } from '@pyreon/core'\nimport { h } from '@pyreon/core'\nimport type { RouteRecord } from '@pyreon/router'\nimport { hydrateLoaderData } from '@pyreon/router'\nimport { hydrateRoot, mount } from '@pyreon/runtime-dom'\nimport { createApp } from './app'\n\n// ─── Client entry factory ───────────────────────────────────────────────────\n\nexport interface StartClientOptions {\n /** Route definitions. */\n routes: RouteRecord[]\n /** Root layout component. */\n layout?: ComponentFn\n}\n\n/**\n * Start the client-side app — hydrates SSR content or mounts fresh for SPA.\n *\n * ## Loader data flow\n *\n * Direct navigation to a route with a `loader` function needs data to be\n * available on the VERY FIRST render. This is handled in two modes:\n *\n * - **SSR mode (zero's default)**: the server pre-runs loaders, renders the\n * HTML with loader data already applied, and embeds a JSON blob in the\n * HTML as `window.__PYREON_LOADER_DATA__`. On the client we read that\n * blob and call `hydrateLoaderData(router, data)` BEFORE hydrating — so\n * the hydration pass sees the same data the SSR render produced\n * (avoids hydration mismatches and the flash of \"not found\" fallback).\n *\n * - **SPA cold start (no SSR content)**: no `__PYREON_LOADER_DATA__` was\n * embedded, so we call `router.replace(currentPath)` after mount to\n * trigger the loader pipeline for the initial route. The first render\n * shows whatever the component displays for `useLoaderData() === undefined`\n * (typically a loading state or fallback); once loaders resolve, the\n * reactive `useLoaderData` re-renders with the data. This matches\n * standard SPA loading behavior.\n *\n * Without this wiring, direct URL navigation to a loader-backed route\n * (e.g. `/posts/3`) showed the \"Post not found\" fallback indefinitely\n * because `useLoaderData()` returned `undefined` forever. The router\n * only ran loaders on in-app navigation (push/replace), not on initial\n * mount.\n *\n * @example\n * import { routes } from \"virtual:zero/routes\"\n * import { startClient } from \"@pyreon/zero/client\"\n *\n * startClient({ routes })\n */\nexport function startClient(options: StartClientOptions) {\n // `startClient` is the browser entry point — only ever called from a\n // user's `client.ts` mounted in the browser. Explicit guard documents\n // that contract and gives a clearer error than `document is not defined`.\n if (typeof document === 'undefined') {\n throw new Error('[Pyreon] startClient() can only be called in the browser.')\n }\n const container = document.getElementById('app')\n if (!container) throw new Error('[Pyreon] Missing #app container element')\n\n const { App, router } = createApp({\n routes: options.routes,\n routerMode: 'history',\n ...(options.layout ? { layout: options.layout } : {}),\n })\n\n // ── Loader data hydration (SSR path) ───────────────────────────────────────\n // If the server embedded loader data, hydrate it BEFORE mounting so the\n // initial render sees the same data the SSR pass produced. This avoids\n // hydration mismatches and eliminates the flash-of-fallback.\n const ssrLoaderData = (window as unknown as Record<string, unknown>)\n .__PYREON_LOADER_DATA__\n const hasSSRLoaderData =\n ssrLoaderData !== undefined &&\n typeof ssrLoaderData === 'object' &&\n ssrLoaderData !== null\n if (hasSSRLoaderData) {\n // `router` is the public Router<> type; hydrateLoaderData uses the\n // internal RouterInstance shape. The cast is safe because they're\n // the same object at runtime — just narrower/wider type views.\n hydrateLoaderData(router as never, ssrLoaderData as Record<string, unknown>)\n }\n\n const vnode = h(App, null)\n\n // ── Mount vs hydrate ───────────────────────────────────────────────────────\n const hasSSRContent = container.childNodes.length > 0\n const cleanup = hasSSRContent ? hydrateRoot(container, vnode) : mount(vnode, container)\n\n // ── Loader run (SPA cold-start path) ───────────────────────────────────────\n // If we had no SSR loader data AND no SSR content, this is a true SPA\n // cold start. Trigger the router's loader pipeline for the current route\n // via `replace()` with the same path — doesn't change the URL, just kicks\n // off the loader batch. Guards, middleware, and redirects run too, which\n // matches what any other route navigation would do.\n //\n // If we DID have SSR content but NO loader data — that's an unusual case\n // (SSR disabled for this route but loader defined). Run loaders anyway so\n // the client catches up.\n if (!hasSSRLoaderData) {\n const currentPath = router.currentRoute().path\n router.replace(currentPath).catch((err: unknown) => {\n // Loader failures are already reported via the route's error handling\n // pipeline. We swallow the promise rejection here to prevent unhandled\n // rejection warnings — the route's `errorComponent` (if any) already\n // handled the display.\n // @ts-ignore — `import.meta.env.DEV` is provided by Vite/Rolldown at build time\n if (import.meta.env?.DEV === true) {\n // oxlint-disable-next-line no-console\n console.warn(\n '[Pyreon] Initial loader run failed for route:',\n currentPath,\n err,\n )\n }\n })\n }\n\n return cleanup\n}\n"],"mappings":";;;;;;;;;;;AA8BA,SAAgB,UAAU,SAA2B;CACnD,MAAM,SAAS,aAAa;EAC1B,QAAQ,QAAQ;EAChB,MAAM,QAAQ,cAAc;EAC5B,GAAI,QAAQ,MAAM,EAAE,KAAK,QAAQ,KAAK,GAAG,EAAE;EAC3C,gBAAgB;EACjB,CAAC;CAEF,MAAM,SAAS,QAAQ,UAAU;CAEjC,SAAS,MAAM;AACb,SAAO,EACL,cACA,MACA,EACE,gBACA,EAAE,QAAQ,EACV,EAAE,QAAQ,MAAM,EAAE,YAAkC,KAAK,CAAC,CAC3D,CACF;;AAGH,QAAO;EAAE;EAAK;EAAQ;;AAGxB,SAAS,cAAc,OAAc;AACnC,QAAO,EAAE,UAAU,MAAM,GAAI,MAAM,QAAQ,MAAM,SAAS,GAAG,MAAM,WAAW,CAAC,MAAM,SAAS,CAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACLlG,SAAgB,YAAY,SAA6B;AAIvD,KAAI,OAAO,aAAa,YACtB,OAAM,IAAI,MAAM,4DAA4D;CAE9E,MAAM,YAAY,SAAS,eAAe,MAAM;AAChD,KAAI,CAAC,UAAW,OAAM,IAAI,MAAM,0CAA0C;CAE1E,MAAM,EAAE,KAAK,WAAW,UAAU;EAChC,QAAQ,QAAQ;EAChB,YAAY;EACZ,GAAI,QAAQ,SAAS,EAAE,QAAQ,QAAQ,QAAQ,GAAG,EAAE;EACrD,CAAC;CAMF,MAAM,gBAAiB,OACpB;CACH,MAAM,mBACJ,kBAAkB,UAClB,OAAO,kBAAkB,YACzB,kBAAkB;AACpB,KAAI,iBAIF,mBAAkB,QAAiB,cAAyC;CAG9E,MAAM,QAAQ,EAAE,KAAK,KAAK;CAI1B,MAAM,UADgB,UAAU,WAAW,SAAS,IACpB,YAAY,WAAW,MAAM,GAAG,MAAM,OAAO,UAAU;AAYvF,KAAI,CAAC,kBAAkB;EACrB,MAAM,cAAc,OAAO,cAAc,CAAC;AAC1C,SAAO,QAAQ,YAAY,CAAC,OAAO,QAAiB;AAMlD,OAAI,OAAO,KAAK,KAAK,QAAQ,KAE3B,SAAQ,KACN,iDACA,aACA,IACD;IAEH;;AAGJ,QAAO"}
package/lib/env.js CHANGED
@@ -98,7 +98,7 @@ function oneOf(values, options) {
98
98
  var EnvError = class extends Error {
99
99
  constructor(key, message, description) {
100
100
  const desc = description ? ` (${description})` : "";
101
- super(`[zero:env] ${key}${desc}: ${message}`);
101
+ super(`[Pyreon] ${key}${desc}: ${message}`);
102
102
  this.name = "EnvError";
103
103
  }
104
104
  };
@@ -124,7 +124,7 @@ function toValidator(value) {
124
124
  if (typeof value === "number") return num({ default: value });
125
125
  if (typeof value === "boolean") return bool({ default: value });
126
126
  if (typeof value === "string") return str({ default: value });
127
- throw new Error(`[zero:env] Invalid schema value: ${String(value)}. Use a default value, String/Number/Boolean, or a validator like url().`);
127
+ throw new Error(`[Pyreon] Invalid schema value: ${String(value)}. Use a default value, String/Number/Boolean, or a validator like url().`);
128
128
  }
129
129
  /**
130
130
  * Validate environment variables.
@@ -161,8 +161,8 @@ function validateEnv(schema, source) {
161
161
  }
162
162
  }
163
163
  if (errors.length > 0) {
164
- const header = `\n[zero:env] Environment validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n`;
165
- const body = errors.map((e) => ` ✗ ${e.replace("[zero:env] ", "")}`).join("\n");
164
+ const header = `\n[Pyreon] Environment validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n`;
165
+ const body = errors.map((e) => ` ✗ ${e.replace("[Pyreon] ", "")}`).join("\n");
166
166
  throw new Error(header + body + "\n");
167
167
  }
168
168
  return result;
@@ -201,12 +201,12 @@ function schema(parse) {
201
201
  required: true,
202
202
  defaultValue: void 0,
203
203
  parse(raw, key) {
204
- if (raw === void 0 || raw === "") throw new Error(`[zero:env] ${key}: is required but not set`);
204
+ if (raw === void 0 || raw === "") throw new Error(`[Pyreon] ${key}: is required but not set`);
205
205
  try {
206
206
  return parse(raw);
207
207
  } catch (e) {
208
208
  const msg = e instanceof Error ? e.message : String(e);
209
- throw new Error(`[zero:env] ${key}: ${msg}`);
209
+ throw new Error(`[Pyreon] ${key}: ${msg}`);
210
210
  }
211
211
  }
212
212
  };
package/lib/env.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"env.js","names":[],"sources":["../src/env.ts"],"sourcesContent":["/**\n * Environment variable validation.\n *\n * Infers types from default values — no verbose validator imports needed.\n * Explicit validators (`url()`, `oneOf()`) available for special cases.\n *\n * @example\n * ```ts\n * import { validateEnv, url, oneOf } from \"@pyreon/zero/env\"\n *\n * const env = validateEnv({\n * PORT: 3000, // number, default 3000\n * DEBUG: false, // boolean, default false\n * HOST: \"localhost\", // string, default \"localhost\"\n * DATABASE_URL: url(), // validated URL, required\n * NODE_ENV: oneOf([\"development\", \"production\", \"test\"]),\n * API_KEY: String, // required string, no default\n * MAX_RETRIES: Number, // required number, no default\n * })\n * ```\n */\n\nexport interface EnvValidatorOptions<T = string> {\n /** Whether this variable is required. Default: true */\n required?: boolean\n /** Default value when not set. Makes the variable optional. */\n default?: T\n /** Human-readable description for error messages. */\n description?: string\n}\n\nexport interface EnvValidator<T> {\n __type: 'env-validator'\n parse: (raw: string | undefined, key: string) => T\n required: boolean\n defaultValue?: T | undefined\n}\n\n// ─── Explicit validators (for special cases) ────────────────────────────────\n\n/**\n * String validator — accepts any non-empty string.\n */\nexport function str(options?: EnvValidatorOptions<string>): EnvValidator<string> {\n const required = options?.default === undefined && options?.required !== false\n return {\n __type: 'env-validator',\n required,\n defaultValue: options?.default,\n parse(raw, key) {\n if (raw === undefined || raw === '') {\n if (options?.default !== undefined) return options.default\n throw new EnvError(key, 'is required but not set', options?.description)\n }\n return raw\n },\n }\n}\n\n/**\n * Number validator — parses to a number, rejects NaN.\n */\nexport function num(options?: EnvValidatorOptions<number>): EnvValidator<number> {\n const required = options?.default === undefined && options?.required !== false\n return {\n __type: 'env-validator',\n required,\n defaultValue: options?.default,\n parse(raw, key) {\n if (raw === undefined || raw === '') {\n if (options?.default !== undefined) return options.default\n throw new EnvError(key, 'is required but not set', options?.description)\n }\n const n = Number(raw)\n if (Number.isNaN(n)) {\n throw new EnvError(key, `must be a number, got \"${raw}\"`, options?.description)\n }\n return n\n },\n }\n}\n\n/**\n * Boolean validator — accepts \"true\"/\"1\" as true, \"false\"/\"0\" as false.\n */\nexport function bool(options?: EnvValidatorOptions<boolean>): EnvValidator<boolean> {\n const required = options?.default === undefined && options?.required !== false\n return {\n __type: 'env-validator',\n required,\n defaultValue: options?.default,\n parse(raw, key) {\n if (raw === undefined || raw === '') {\n if (options?.default !== undefined) return options.default\n throw new EnvError(key, 'is required but not set', options?.description)\n }\n const lower = raw.toLowerCase()\n if (lower === 'true' || lower === '1') return true\n if (lower === 'false' || lower === '0') return false\n throw new EnvError(key, `must be \"true\" or \"false\", got \"${raw}\"`, options?.description)\n },\n }\n}\n\n/**\n * URL validator — validates that the value is a valid URL.\n */\nexport function url(options?: EnvValidatorOptions<string>): EnvValidator<string> {\n const required = options?.default === undefined && options?.required !== false\n return {\n __type: 'env-validator',\n required,\n defaultValue: options?.default,\n parse(raw, key) {\n if (raw === undefined || raw === '') {\n if (options?.default !== undefined) return options.default\n throw new EnvError(key, 'is required but not set', options?.description)\n }\n try {\n new URL(raw)\n return raw\n } catch {\n throw new EnvError(key, `must be a valid URL, got \"${raw}\"`, options?.description)\n }\n },\n }\n}\n\n/**\n * Enum validator — value must be one of the allowed values.\n */\nexport function oneOf<T extends string>(\n values: readonly T[],\n options?: EnvValidatorOptions<T>,\n): EnvValidator<T> {\n const required = options?.default === undefined && options?.required !== false\n return {\n __type: 'env-validator',\n required,\n defaultValue: options?.default,\n parse(raw, key) {\n if (raw === undefined || raw === '') {\n if (options?.default !== undefined) return options.default\n throw new EnvError(key, 'is required but not set', options?.description)\n }\n if (!values.includes(raw as T)) {\n throw new EnvError(\n key,\n `must be one of [${values.join(', ')}], got \"${raw}\"`,\n options?.description,\n )\n }\n return raw as T\n },\n }\n}\n\n// ─── Internal helpers ───────────────────────────────────────────────────────\n\nclass EnvError extends Error {\n constructor(key: string, message: string, description?: string) {\n const desc = description ? ` (${description})` : ''\n super(`[zero:env] ${key}${desc}: ${message}`)\n this.name = 'EnvError'\n }\n}\n\nfunction isEnvValidator(v: unknown): v is EnvValidator<unknown> {\n return typeof v === 'object' && v !== null && (v as any).__type === 'env-validator'\n}\n\n/**\n * Convert a plain schema value to an EnvValidator.\n *\n * - `3000` → num({ default: 3000 })\n * - `false` → bool({ default: false })\n * - `\"localhost\"` → str({ default: \"localhost\" })\n * - `String` → str() (required)\n * - `Number` → num() (required)\n * - `Boolean` → bool() (required)\n * - EnvValidator → pass through\n */\nfunction toValidator(value: unknown): EnvValidator<unknown> {\n if (isEnvValidator(value)) return value\n\n // Constructor markers → required, no default\n if (value === String) return str()\n if (value === Number) return num()\n if (value === Boolean) return bool()\n\n // Plain values → infer type + use as default\n if (typeof value === 'number') return num({ default: value })\n if (typeof value === 'boolean') return bool({ default: value })\n if (typeof value === 'string') return str({ default: value })\n\n throw new Error(`[zero:env] Invalid schema value: ${String(value)}. Use a default value, String/Number/Boolean, or a validator like url().`)\n}\n\n// ─── Type inference ─────────────────────────────────────────────────────────\n\n/** Schema entry: plain value, constructor, or explicit validator. */\ntype SchemaEntry =\n | string | number | boolean\n | StringConstructor | NumberConstructor | BooleanConstructor\n | EnvValidator<any>\n\n/** Infer the output type from a schema entry. */\ntype InferEntry<T> =\n T extends EnvValidator<infer V> ? V :\n T extends StringConstructor ? string :\n T extends NumberConstructor ? number :\n T extends BooleanConstructor ? boolean :\n T extends string ? string :\n T extends number ? number :\n T extends boolean ? boolean :\n never\n\ntype InferEnvSchema<T> = {\n [K in keyof T]: InferEntry<T[K]>\n}\n\n// ─── Main API ───────────────────────────────────────────────────────────────\n\n/**\n * Validate environment variables.\n *\n * Schema values can be:\n * - **Default values**: `3000`, `false`, `\"localhost\"` → type inferred, used as default\n * - **Constructors**: `String`, `Number`, `Boolean` → required, no default\n * - **Validators**: `url()`, `oneOf([...])`, `str()`, `num()`, `bool()` → explicit validation\n * - **Custom**: `schema(raw => z.coerce.number().parse(raw))` — bridge to any schema library\n *\n * @example\n * ```ts\n * import { validateEnv, url, oneOf } from \"@pyreon/zero/env\"\n *\n * const env = validateEnv({\n * PORT: 3000, // optional, default 3000\n * DATABASE_URL: url(), // required, validated URL\n * NODE_ENV: oneOf([\"dev\", \"prod\", \"test\"]), // required, must be one of\n * API_KEY: String, // required string\n * DEBUG: false, // optional, default false\n * })\n * ```\n */\nexport function validateEnv<T extends Record<string, SchemaEntry>>(\n schema: T,\n source?: Record<string, string | undefined>,\n): InferEnvSchema<T> {\n const env = source ?? (typeof process !== 'undefined' ? process.env : {})\n const result: Record<string, unknown> = {}\n const errors: string[] = []\n\n for (const [key, entry] of Object.entries(schema)) {\n const validator = toValidator(entry)\n try {\n result[key] = validator.parse(env[key], key)\n } catch (e) {\n errors.push((e as Error).message)\n }\n }\n\n if (errors.length > 0) {\n const header = `\\n[zero:env] Environment validation failed (${errors.length} error${errors.length > 1 ? 's' : ''}):\\n`\n const body = errors.map((e) => ` ✗ ${e.replace('[zero:env] ', '')}`).join('\\n')\n throw new Error(header + body + '\\n')\n }\n\n return result as InferEnvSchema<T>\n}\n\n// ─── Public env (client-safe) ────────────────────────────────────────────────\n\n/**\n * Extract public environment variables (prefixed with `ZERO_PUBLIC_`).\n *\n * @example\n * ```ts\n * const pub = publicEnv()\n * // → { API_URL: \"https://...\", APP_NAME: \"MyApp\" }\n *\n * const pub = publicEnv({ API_URL: url(), APP_NAME: \"Default\" })\n * // → validated against ZERO_PUBLIC_API_URL, ZERO_PUBLIC_APP_NAME\n * ```\n */\nexport function publicEnv(): Record<string, string>\nexport function publicEnv<T extends Record<string, SchemaEntry>>(schema: T): InferEnvSchema<T>\nexport function publicEnv(schema?: Record<string, SchemaEntry>): Record<string, unknown> {\n const prefix = 'ZERO_PUBLIC_'\n const env = typeof process !== 'undefined' ? process.env : {}\n\n if (!schema) {\n const result: Record<string, string> = {}\n for (const [key, value] of Object.entries(env)) {\n if (key.startsWith(prefix) && value !== undefined) {\n result[key.slice(prefix.length)] = value\n }\n }\n return result\n }\n\n const prefixedSource: Record<string, string | undefined> = {}\n for (const key of Object.keys(schema)) {\n prefixedSource[key] = env[`${prefix}${key}`]\n }\n return validateEnv(schema, prefixedSource)\n}\n\n// ─── Custom validator escape hatch ──────────────────────────────────────────\n\n/**\n * Create an env validator from a custom parse function.\n * Use this to integrate any schema library (Zod, Valibot, ArkType, etc.).\n *\n * @example\n * ```ts\n * import { z } from \"zod\"\n * import { validateEnv, schema } from \"@pyreon/zero/env\"\n *\n * const env = validateEnv({\n * PORT: schema(raw => z.coerce.number().parse(raw)),\n * DATABASE_URL: schema(raw => z.string().url().parse(raw)),\n * HOST: \"localhost\", // plain defaults still work alongside\n * })\n * ```\n */\nexport function schema<T>(parse: (raw: string) => T): EnvValidator<T> {\n return {\n __type: 'env-validator',\n required: true,\n defaultValue: undefined,\n parse(raw: string | undefined, key: string) {\n if (raw === undefined || raw === '') {\n throw new Error(`[zero:env] ${key}: is required but not set`)\n }\n try {\n return parse(raw)\n } catch (e) {\n const msg = e instanceof Error ? e.message : String(e)\n throw new Error(`[zero:env] ${key}: ${msg}`)\n }\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"env.js","names":[],"sources":["../src/env.ts"],"sourcesContent":["/**\n * Environment variable validation.\n *\n * Infers types from default values — no verbose validator imports needed.\n * Explicit validators (`url()`, `oneOf()`) available for special cases.\n *\n * @example\n * ```ts\n * import { validateEnv, url, oneOf } from \"@pyreon/zero/env\"\n *\n * const env = validateEnv({\n * PORT: 3000, // number, default 3000\n * DEBUG: false, // boolean, default false\n * HOST: \"localhost\", // string, default \"localhost\"\n * DATABASE_URL: url(), // validated URL, required\n * NODE_ENV: oneOf([\"development\", \"production\", \"test\"]),\n * API_KEY: String, // required string, no default\n * MAX_RETRIES: Number, // required number, no default\n * })\n * ```\n */\n\nexport interface EnvValidatorOptions<T = string> {\n /** Whether this variable is required. Default: true */\n required?: boolean\n /** Default value when not set. Makes the variable optional. */\n default?: T\n /** Human-readable description for error messages. */\n description?: string\n}\n\nexport interface EnvValidator<T> {\n __type: 'env-validator'\n parse: (raw: string | undefined, key: string) => T\n required: boolean\n defaultValue?: T | undefined\n}\n\n// ─── Explicit validators (for special cases) ────────────────────────────────\n\n/**\n * String validator — accepts any non-empty string.\n */\nexport function str(options?: EnvValidatorOptions<string>): EnvValidator<string> {\n const required = options?.default === undefined && options?.required !== false\n return {\n __type: 'env-validator',\n required,\n defaultValue: options?.default,\n parse(raw, key) {\n if (raw === undefined || raw === '') {\n if (options?.default !== undefined) return options.default\n throw new EnvError(key, 'is required but not set', options?.description)\n }\n return raw\n },\n }\n}\n\n/**\n * Number validator — parses to a number, rejects NaN.\n */\nexport function num(options?: EnvValidatorOptions<number>): EnvValidator<number> {\n const required = options?.default === undefined && options?.required !== false\n return {\n __type: 'env-validator',\n required,\n defaultValue: options?.default,\n parse(raw, key) {\n if (raw === undefined || raw === '') {\n if (options?.default !== undefined) return options.default\n throw new EnvError(key, 'is required but not set', options?.description)\n }\n const n = Number(raw)\n if (Number.isNaN(n)) {\n throw new EnvError(key, `must be a number, got \"${raw}\"`, options?.description)\n }\n return n\n },\n }\n}\n\n/**\n * Boolean validator — accepts \"true\"/\"1\" as true, \"false\"/\"0\" as false.\n */\nexport function bool(options?: EnvValidatorOptions<boolean>): EnvValidator<boolean> {\n const required = options?.default === undefined && options?.required !== false\n return {\n __type: 'env-validator',\n required,\n defaultValue: options?.default,\n parse(raw, key) {\n if (raw === undefined || raw === '') {\n if (options?.default !== undefined) return options.default\n throw new EnvError(key, 'is required but not set', options?.description)\n }\n const lower = raw.toLowerCase()\n if (lower === 'true' || lower === '1') return true\n if (lower === 'false' || lower === '0') return false\n throw new EnvError(key, `must be \"true\" or \"false\", got \"${raw}\"`, options?.description)\n },\n }\n}\n\n/**\n * URL validator — validates that the value is a valid URL.\n */\nexport function url(options?: EnvValidatorOptions<string>): EnvValidator<string> {\n const required = options?.default === undefined && options?.required !== false\n return {\n __type: 'env-validator',\n required,\n defaultValue: options?.default,\n parse(raw, key) {\n if (raw === undefined || raw === '') {\n if (options?.default !== undefined) return options.default\n throw new EnvError(key, 'is required but not set', options?.description)\n }\n try {\n new URL(raw)\n return raw\n } catch {\n throw new EnvError(key, `must be a valid URL, got \"${raw}\"`, options?.description)\n }\n },\n }\n}\n\n/**\n * Enum validator — value must be one of the allowed values.\n */\nexport function oneOf<T extends string>(\n values: readonly T[],\n options?: EnvValidatorOptions<T>,\n): EnvValidator<T> {\n const required = options?.default === undefined && options?.required !== false\n return {\n __type: 'env-validator',\n required,\n defaultValue: options?.default,\n parse(raw, key) {\n if (raw === undefined || raw === '') {\n if (options?.default !== undefined) return options.default\n throw new EnvError(key, 'is required but not set', options?.description)\n }\n if (!values.includes(raw as T)) {\n throw new EnvError(\n key,\n `must be one of [${values.join(', ')}], got \"${raw}\"`,\n options?.description,\n )\n }\n return raw as T\n },\n }\n}\n\n// ─── Internal helpers ───────────────────────────────────────────────────────\n\nclass EnvError extends Error {\n constructor(key: string, message: string, description?: string) {\n const desc = description ? ` (${description})` : ''\n super(`[Pyreon] ${key}${desc}: ${message}`)\n this.name = 'EnvError'\n }\n}\n\nfunction isEnvValidator(v: unknown): v is EnvValidator<unknown> {\n return typeof v === 'object' && v !== null && (v as any).__type === 'env-validator'\n}\n\n/**\n * Convert a plain schema value to an EnvValidator.\n *\n * - `3000` → num({ default: 3000 })\n * - `false` → bool({ default: false })\n * - `\"localhost\"` → str({ default: \"localhost\" })\n * - `String` → str() (required)\n * - `Number` → num() (required)\n * - `Boolean` → bool() (required)\n * - EnvValidator → pass through\n */\nfunction toValidator(value: unknown): EnvValidator<unknown> {\n if (isEnvValidator(value)) return value\n\n // Constructor markers → required, no default\n if (value === String) return str()\n if (value === Number) return num()\n if (value === Boolean) return bool()\n\n // Plain values → infer type + use as default\n if (typeof value === 'number') return num({ default: value })\n if (typeof value === 'boolean') return bool({ default: value })\n if (typeof value === 'string') return str({ default: value })\n\n throw new Error(`[Pyreon] Invalid schema value: ${String(value)}. Use a default value, String/Number/Boolean, or a validator like url().`)\n}\n\n// ─── Type inference ─────────────────────────────────────────────────────────\n\n/** Schema entry: plain value, constructor, or explicit validator. */\ntype SchemaEntry =\n | string | number | boolean\n | StringConstructor | NumberConstructor | BooleanConstructor\n | EnvValidator<any>\n\n/** Infer the output type from a schema entry. */\ntype InferEntry<T> =\n T extends EnvValidator<infer V> ? V :\n T extends StringConstructor ? string :\n T extends NumberConstructor ? number :\n T extends BooleanConstructor ? boolean :\n T extends string ? string :\n T extends number ? number :\n T extends boolean ? boolean :\n never\n\ntype InferEnvSchema<T> = {\n [K in keyof T]: InferEntry<T[K]>\n}\n\n// ─── Main API ───────────────────────────────────────────────────────────────\n\n/**\n * Validate environment variables.\n *\n * Schema values can be:\n * - **Default values**: `3000`, `false`, `\"localhost\"` → type inferred, used as default\n * - **Constructors**: `String`, `Number`, `Boolean` → required, no default\n * - **Validators**: `url()`, `oneOf([...])`, `str()`, `num()`, `bool()` → explicit validation\n * - **Custom**: `schema(raw => z.coerce.number().parse(raw))` — bridge to any schema library\n *\n * @example\n * ```ts\n * import { validateEnv, url, oneOf } from \"@pyreon/zero/env\"\n *\n * const env = validateEnv({\n * PORT: 3000, // optional, default 3000\n * DATABASE_URL: url(), // required, validated URL\n * NODE_ENV: oneOf([\"dev\", \"prod\", \"test\"]), // required, must be one of\n * API_KEY: String, // required string\n * DEBUG: false, // optional, default false\n * })\n * ```\n */\nexport function validateEnv<T extends Record<string, SchemaEntry>>(\n schema: T,\n source?: Record<string, string | undefined>,\n): InferEnvSchema<T> {\n const env = source ?? (typeof process !== 'undefined' ? process.env : {})\n const result: Record<string, unknown> = {}\n const errors: string[] = []\n\n for (const [key, entry] of Object.entries(schema)) {\n const validator = toValidator(entry)\n try {\n result[key] = validator.parse(env[key], key)\n } catch (e) {\n errors.push((e as Error).message)\n }\n }\n\n if (errors.length > 0) {\n const header = `\\n[Pyreon] Environment validation failed (${errors.length} error${errors.length > 1 ? 's' : ''}):\\n`\n const body = errors.map((e) => ` ✗ ${e.replace('[Pyreon] ', '')}`).join('\\n')\n throw new Error(header + body + '\\n')\n }\n\n return result as InferEnvSchema<T>\n}\n\n// ─── Public env (client-safe) ────────────────────────────────────────────────\n\n/**\n * Extract public environment variables (prefixed with `ZERO_PUBLIC_`).\n *\n * @example\n * ```ts\n * const pub = publicEnv()\n * // → { API_URL: \"https://...\", APP_NAME: \"MyApp\" }\n *\n * const pub = publicEnv({ API_URL: url(), APP_NAME: \"Default\" })\n * // → validated against ZERO_PUBLIC_API_URL, ZERO_PUBLIC_APP_NAME\n * ```\n */\nexport function publicEnv(): Record<string, string>\nexport function publicEnv<T extends Record<string, SchemaEntry>>(schema: T): InferEnvSchema<T>\nexport function publicEnv(schema?: Record<string, SchemaEntry>): Record<string, unknown> {\n const prefix = 'ZERO_PUBLIC_'\n const env = typeof process !== 'undefined' ? process.env : {}\n\n if (!schema) {\n const result: Record<string, string> = {}\n for (const [key, value] of Object.entries(env)) {\n if (key.startsWith(prefix) && value !== undefined) {\n result[key.slice(prefix.length)] = value\n }\n }\n return result\n }\n\n const prefixedSource: Record<string, string | undefined> = {}\n for (const key of Object.keys(schema)) {\n prefixedSource[key] = env[`${prefix}${key}`]\n }\n return validateEnv(schema, prefixedSource)\n}\n\n// ─── Custom validator escape hatch ──────────────────────────────────────────\n\n/**\n * Create an env validator from a custom parse function.\n * Use this to integrate any schema library (Zod, Valibot, ArkType, etc.).\n *\n * @example\n * ```ts\n * import { z } from \"zod\"\n * import { validateEnv, schema } from \"@pyreon/zero/env\"\n *\n * const env = validateEnv({\n * PORT: schema(raw => z.coerce.number().parse(raw)),\n * DATABASE_URL: schema(raw => z.string().url().parse(raw)),\n * HOST: \"localhost\", // plain defaults still work alongside\n * })\n * ```\n */\nexport function schema<T>(parse: (raw: string) => T): EnvValidator<T> {\n return {\n __type: 'env-validator',\n required: true,\n defaultValue: undefined,\n parse(raw: string | undefined, key: string) {\n if (raw === undefined || raw === '') {\n throw new Error(`[Pyreon] ${key}: is required but not set`)\n }\n try {\n return parse(raw)\n } catch (e) {\n const msg = e instanceof Error ? e.message : String(e)\n throw new Error(`[Pyreon] ${key}: ${msg}`)\n }\n },\n }\n}\n"],"mappings":"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"}
package/lib/favicon.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[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
10
+ console.warn("\n[Pyreon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
11
11
  }
12
12
  const SIZES = [
13
13
  {
@@ -327,7 +327,7 @@ function resolveLocaleSource(url, config, rootDir) {
327
327
  async function generateFaviconSet(rootDir, source, darkSource, prefix, config, themeColor, backgroundColor, generateManifest) {
328
328
  const sourcePath = join(rootDir, source);
329
329
  if (!existsSync(sourcePath)) {
330
- console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
330
+ console.warn(`[Pyreon] Source not found: ${sourcePath}`);
331
331
  return;
332
332
  }
333
333
  if (source.endsWith(".svg")) {
@@ -1 +1 @@
1
- {"version":3,"file":"favicon.js","names":[],"sources":["../src/favicon.ts"],"sourcesContent":["import { existsSync } from 'node:fs'\nimport { readFile } from 'node:fs/promises'\nimport { 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:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\\n',\n )\n}\n\n// ─── Favicon generation plugin ──────────────────────────────────────────────\n//\n// Generates all favicon formats from a single source file (SVG or PNG):\n// - favicon.ico (16x16 + 32x32 combined)\n// - favicon.svg (copied if source is SVG)\n// - apple-touch-icon.png (180x180)\n// - icon-192.png (for web manifest)\n// - icon-512.png (for web manifest)\n// - site.webmanifest\n//\n// Usage:\n// import { faviconPlugin } from \"@pyreon/zero\"\n// export default { plugins: [zero(), faviconPlugin({ source: \"./icon.svg\" })] }\n\nexport interface FaviconLocaleConfig {\n /** Locale-specific source icon (SVG or PNG). */\n source: string\n /** Optional dark mode variant for this locale. */\n darkSource?: string\n}\n\nexport interface FaviconPluginConfig {\n /** Path to the source icon (SVG or PNG, at least 512x512 for PNG). */\n source: string\n /** Theme color for web manifest. Default: \"#ffffff\" */\n themeColor?: string\n /** Background color for web manifest. Default: \"#ffffff\" */\n backgroundColor?: string\n /** App name for web manifest. Uses package.json name if not set. */\n name?: string\n /** Generate web manifest. Default: true */\n manifest?: boolean\n /**\n * Dark mode favicon (SVG only).\n * When provided, the SVG favicon uses prefers-color-scheme media query\n * to switch between light and dark variants.\n */\n darkSource?: string\n /**\n * Locale-specific icon overrides. Each key is a locale code,\n * value is a source icon (and optional dark variant).\n * Locales not in this map use the base `source`.\n *\n * Generated files are placed under `/{locale}/` prefix:\n * /de/favicon.svg, /de/favicon-32x32.png, etc.\n *\n * @example\n * ```ts\n * faviconPlugin({\n * source: \"./icon.svg\",\n * locales: {\n * de: { source: \"./icon-de.svg\" },\n * cs: { source: \"./icon-cs.svg\" },\n * },\n * })\n * ```\n */\n locales?: Record<string, FaviconLocaleConfig>\n /**\n * Dev mode favicon — shown only during development to distinguish\n * dev tabs from production. Can be:\n * - A path to a separate icon file\n * - `true` to auto-generate a dev badge (grayscale + \"DEV\" overlay)\n *\n * @example\n * ```ts\n * faviconPlugin({\n * source: \"./icon.svg\",\n * devSource: \"./icon-dev.svg\", // custom dev icon\n * // OR\n * devSource: true, // auto-generate grayscale badge\n * })\n * ```\n */\n devSource?: string | boolean\n}\n\ninterface FaviconSize {\n size: number\n name: string\n}\n\nconst SIZES: FaviconSize[] = [\n { size: 16, name: 'favicon-16x16.png' },\n { size: 32, name: 'favicon-32x32.png' },\n { size: 180, name: 'apple-touch-icon.png' },\n { size: 192, name: 'icon-192.png' },\n { size: 512, name: 'icon-512.png' },\n]\n\n/**\n * Favicon generation Vite plugin.\n *\n * Generates all required favicon formats at build time from a single source.\n * In dev mode, serves the source directly.\n *\n * @example\n * ```ts\n * // vite.config.ts\n * import { faviconPlugin } from \"@pyreon/zero\"\n *\n * export default {\n * plugins: [faviconPlugin({ source: \"./src/assets/icon.svg\" })],\n * }\n * ```\n */\nexport function faviconPlugin(config: FaviconPluginConfig): Plugin {\n const themeColor = config.themeColor ?? '#ffffff'\n const backgroundColor = config.backgroundColor ?? '#ffffff'\n const generateManifest = config.manifest !== false\n\n let root = ''\n let isBuild = false\n\n return {\n name: 'pyreon-zero-favicon',\n enforce: 'pre',\n\n configResolved(resolvedConfig) {\n root = resolvedConfig.root\n isBuild = resolvedConfig.command === 'build'\n },\n\n // Dev server: serve generated favicons on-the-fly\n configureServer(server) {\n const sourcePath = join(root, config.source)\n const darkPath = config.darkSource ? join(root, config.darkSource) : null\n const devSourcePath = typeof config.devSource === 'string'\n ? join(root, config.devSource)\n : null\n const autoDevBadge = config.devSource === true\n const devCache = new Map<string, Uint8Array>()\n\n /** Resolve source path for a request — handles dark variants and dev badge. */\n function resolveSourceForDev(baseName: string, defaultSource: string): string {\n // Dark variant: favicon-dark-32x32.png → use darkSource\n if (darkPath && baseName.includes('-dark-')) return darkPath\n // Light variant: favicon-light-32x32.png → use source\n if (baseName.includes('-light-')) return defaultSource\n return defaultSource\n }\n\n server.middlewares.use(async (req, res, next) => {\n const url = req.url ?? ''\n\n // Resolve locale-specific source\n const localeSource = resolveLocaleSource(url, config, root)\n const svgUrl = localeSource ? localeSource.url : url\n const svgPath = localeSource ? localeSource.sourcePath : sourcePath\n const isSvgSource = localeSource ? localeSource.source.endsWith('.svg') : config.source.endsWith('.svg')\n\n // Serve favicon.svg — in dev, add dev badge overlay if configured\n if (svgUrl.endsWith('/favicon.svg') && isSvgSource) {\n try {\n let content = await readFile(svgPath, 'utf-8')\n if (autoDevBadge) content = addDevBadgeToSvg(content)\n else if (devSourcePath && existsSync(devSourcePath)) {\n content = await readFile(devSourcePath, 'utf-8')\n }\n res.setHeader('Content-Type', 'image/svg+xml')\n res.end(content)\n return\n } catch { /* fall through */ }\n }\n\n // Serve generated PNGs on-demand — supports dark variants + dev badge\n const baseName = svgUrl.split('/').pop() ?? ''\n // Strip light-/dark- prefix for size matching\n const cleanName = baseName.replace(/-?(light|dark)-/, '-')\n const sizeMatch = SIZES.find((s) => s.name === cleanName || baseName === s.name)\n if (sizeMatch) {\n const resolvedSource = resolveSourceForDev(baseName, svgPath)\n const cacheKey = `${resolvedSource}:${sizeMatch.size}:${autoDevBadge}`\n let png = devCache.get(cacheKey)\n if (!png) {\n let result = await resizeToPng(resolvedSource, sizeMatch.size)\n if (result && autoDevBadge) {\n result = await addDevBadgeToPng(result, sizeMatch.size)\n }\n if (result) {\n png = result\n devCache.set(cacheKey, result)\n }\n }\n if (png) {\n res.setHeader('Content-Type', 'image/png')\n res.setHeader('Cache-Control', 'no-cache')\n res.end(Buffer.from(png))\n return\n }\n }\n\n // Serve generated ICO on-demand\n if (baseName === 'favicon.ico') {\n const cacheKey = `ico:${svgPath}`\n let ico: Uint8Array | undefined = devCache.get(cacheKey)\n if (!ico) {\n const result = await generateIco(svgPath)\n if (result) {\n ico = result\n devCache.set(cacheKey, result)\n }\n }\n if (ico) {\n res.setHeader('Content-Type', 'image/x-icon')\n res.setHeader('Cache-Control', 'no-cache')\n res.end(Buffer.from(ico))\n return\n }\n }\n\n // Serve manifest (supports /{locale}/site.webmanifest)\n if (baseName === 'site.webmanifest' && generateManifest) {\n const prefix = localeSource ? `/${localeSource.locale}` : ''\n const manifest = {\n name: config.name ?? 'App',\n short_name: config.name ?? 'App',\n icons: [\n { src: `${prefix}/icon-192.png`, sizes: '192x192', type: 'image/png' },\n { src: `${prefix}/icon-512.png`, sizes: '512x512', type: 'image/png' },\n ],\n theme_color: themeColor,\n background_color: backgroundColor,\n display: 'standalone',\n }\n res.setHeader('Content-Type', 'application/manifest+json')\n res.end(JSON.stringify(manifest, null, 2))\n return\n }\n\n next()\n })\n },\n\n // Inject favicon <link> tags into HTML\n transformIndexHtml() {\n const isSvg = config.source.endsWith('.svg')\n const hasDark = !!config.darkSource\n const tags: Array<{\n tag: string\n attrs: Record<string, string>\n injectTo: 'head'\n }> = []\n\n // SVG favicon (with prefers-color-scheme media query when dark variant exists)\n if (isSvg) {\n tags.push({\n tag: 'link',\n attrs: { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },\n injectTo: 'head',\n })\n }\n\n if (hasDark) {\n // Dual-variant PNG/ICO favicons — light active, dark hidden via media=\"not all\".\n // The themeScript and initTheme() swap these based on the resolved theme.\n const lightAttrs = { 'data-favicon-theme': 'light' }\n const darkAttrs = { 'data-favicon-theme': 'dark', media: 'not all' }\n\n tags.push(\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-light-32x32.png', ...lightAttrs }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-dark-32x32.png', ...darkAttrs }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-light-16x16.png', ...lightAttrs }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-dark-16x16.png', ...darkAttrs }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-light.png', ...lightAttrs }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-dark.png', ...darkAttrs }, injectTo: 'head' },\n )\n } else {\n // Single-variant (no dark mode)\n tags.push(\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }, injectTo: 'head' },\n )\n }\n\n if (generateManifest) {\n tags.push({\n tag: 'link',\n attrs: { rel: 'manifest', href: '/site.webmanifest' },\n injectTo: 'head',\n })\n }\n\n tags.push({\n tag: 'meta',\n attrs: { name: 'theme-color', content: themeColor },\n injectTo: 'head',\n })\n\n // Auto-inject favicon swap script when dark variant exists.\n // This runs in the blocking <head> before any render — no flash.\n // Reads theme from localStorage or OS preference, then swaps\n // data-favicon-theme media attributes.\n if (hasDark) {\n tags.push({\n tag: 'script',\n attrs: {},\n injectTo: 'head',\n children: `(function(){try{var t=localStorage.getItem(\"zero-theme\");var r=t===\"light\"?\"light\":t===\"dark\"?\"dark\":window.matchMedia(\"(prefers-color-scheme:dark)\").matches?\"dark\":\"light\";document.querySelectorAll(\"[data-favicon-theme]\").forEach(function(l){l.media=l.dataset.faviconTheme===r?\"\":\"not all\"})}catch(e){}})()`,\n } as any)\n }\n\n return tags\n },\n\n async generateBundle() {\n if (!isBuild) return\n\n // Generate favicons for the base (default) source\n await generateFaviconSet.call(this, root, config.source, config.darkSource, '', config, themeColor, backgroundColor, generateManifest)\n\n // Generate locale-specific favicon sets\n if (config.locales) {\n for (const [locale, localeConfig] of Object.entries(config.locales)) {\n await generateFaviconSet.call(this, root, localeConfig.source, localeConfig.darkSource, `${locale}/`, config, themeColor, backgroundColor, generateManifest)\n }\n }\n },\n }\n}\n\n/**\n * Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.\n */\nfunction wrapSvgWithDarkMode(lightSvg: string, darkSvg: string): string {\n // Extract viewBox from light SVG\n const viewBoxMatch = lightSvg.match(/viewBox=\"([^\"]*)\"/)\n const viewBox = viewBoxMatch?.[1] ?? '0 0 32 32'\n\n return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${viewBox}\">\n <style>\n :root { color-scheme: light dark; }\n @media (prefers-color-scheme: dark) { .light { display: none; } }\n @media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }\n </style>\n <g class=\"light\">${stripSvgWrapper(lightSvg)}</g>\n <g class=\"dark\">${stripSvgWrapper(darkSvg)}</g>\n</svg>`\n}\n\nfunction stripSvgWrapper(svg: string): string {\n return svg\n .replace(/<svg[^>]*>/, '')\n .replace(/<\\/svg>\\s*$/, '')\n .trim()\n}\n\n/**\n * Resolve the source path for a locale-prefixed favicon URL.\n * Returns null if the URL is not locale-prefixed or locale has no override.\n */\nfunction resolveLocaleSource(\n url: string,\n config: FaviconPluginConfig,\n rootDir: string,\n): { locale: string; url: string; source: string; sourcePath: string } | null {\n if (!config.locales) return null\n\n for (const [locale, localeConfig] of Object.entries(config.locales)) {\n const prefix = `/${locale}/`\n if (url.startsWith(prefix)) {\n return {\n locale,\n url,\n source: localeConfig.source,\n sourcePath: join(rootDir, localeConfig.source),\n }\n }\n }\n return null\n}\n\n/**\n * Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.\n * Called once for base (prefix = '') and once per locale (prefix = '{locale}/').\n */\nasync function generateFaviconSet(\n this: any,\n rootDir: string,\n source: string,\n darkSource: string | undefined,\n prefix: string,\n config: FaviconPluginConfig,\n themeColor: string,\n backgroundColor: string,\n generateManifest: boolean,\n): Promise<void> {\n const sourcePath = join(rootDir, source)\n if (!existsSync(sourcePath)) {\n // oxlint-disable-next-line no-console\n console.warn(`[zero:favicon] Source not found: ${sourcePath}`)\n return\n }\n\n const isSvg = source.endsWith('.svg')\n\n // Copy SVG as favicon.svg\n if (isSvg) {\n const svgContent = await readFile(sourcePath, 'utf-8')\n let finalSvg = svgContent\n\n if (darkSource) {\n const darkPath = join(rootDir, darkSource)\n if (existsSync(darkPath)) {\n const darkSvg = await readFile(darkPath, 'utf-8')\n finalSvg = wrapSvgWithDarkMode(svgContent, darkSvg)\n }\n }\n\n this.emitFile({\n type: 'asset',\n fileName: `${prefix}favicon.svg`,\n source: finalSvg,\n })\n }\n\n // Generate PNG sizes via sharp\n if (darkSource) {\n // Dual-variant: generate light + dark PNGs with prefixed names\n const darkPath = join(rootDir, darkSource)\n const darkExists = existsSync(darkPath)\n\n for (const { size, name } of SIZES) {\n // Light variant\n const lightName = name.replace(/^(favicon-)/, '$1light-').replace(/^(apple-touch-icon)/, '$1-light').replace(/^(icon-)/, '$1light-')\n const lightPng = await resizeToPng(sourcePath, size)\n if (lightPng) {\n this.emitFile({ type: 'asset', fileName: `${prefix}${lightName}`, source: lightPng })\n }\n\n // Dark variant\n if (darkExists) {\n const darkName = name.replace(/^(favicon-)/, '$1dark-').replace(/^(apple-touch-icon)/, '$1-dark').replace(/^(icon-)/, '$1dark-')\n const darkPng = await resizeToPng(darkPath, size)\n if (darkPng) {\n this.emitFile({ type: 'asset', fileName: `${prefix}${darkName}`, source: darkPng })\n }\n }\n }\n\n // Also generate standard names (used by manifest + external references)\n for (const { size, name } of SIZES) {\n const pngBuffer = await resizeToPng(sourcePath, size)\n if (pngBuffer) {\n this.emitFile({ type: 'asset', fileName: `${prefix}${name}`, source: pngBuffer })\n }\n }\n } else {\n // Single-variant\n for (const { size, name } of SIZES) {\n const pngBuffer = await resizeToPng(sourcePath, size)\n if (pngBuffer) {\n this.emitFile({ type: 'asset', fileName: `${prefix}${name}`, source: pngBuffer })\n }\n }\n }\n\n // Generate favicon.ico (16 + 32)\n const ico = await generateIco(sourcePath)\n if (ico) {\n this.emitFile({\n type: 'asset',\n fileName: `${prefix}favicon.ico`,\n source: ico,\n })\n }\n\n // Generate web manifest\n if (generateManifest) {\n const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : ''\n const manifest = {\n name: config.name ?? 'App',\n short_name: config.name ?? 'App',\n icons: [\n { src: `${manifestPrefix}/icon-192.png`, sizes: '192x192', type: 'image/png' },\n { src: `${manifestPrefix}/icon-512.png`, sizes: '512x512', type: 'image/png' },\n ],\n theme_color: themeColor,\n background_color: backgroundColor,\n display: 'standalone',\n }\n\n this.emitFile({\n type: 'asset',\n fileName: `${prefix}site.webmanifest`,\n source: JSON.stringify(manifest, null, 2),\n })\n }\n}\n\n/**\n * Get favicon link tags for a specific locale.\n * Returns link objects suitable for `useHead()` or direct HTML injection.\n *\n * @example\n * ```ts\n * const links = faviconLinks(\"de\", { source: \"./icon.svg\", locales: { de: { source: \"./icon-de.svg\" } } })\n * // → [{ rel: \"icon\", type: \"image/svg+xml\", href: \"/de/favicon.svg\" }, ...]\n * ```\n */\nexport function faviconLinks(\n locale: string | undefined,\n config: FaviconPluginConfig,\n): Array<{ rel: string; type?: string; sizes?: string; href: string }> {\n const hasLocaleOverride = locale && config.locales?.[locale]\n const prefix = hasLocaleOverride ? `/${locale}` : ''\n const isSvg = (hasLocaleOverride ? config.locales![locale]!.source : config.source).endsWith('.svg')\n\n const links: Array<{ rel: string; type?: string; sizes?: string; href: string }> = []\n\n if (isSvg) {\n links.push({ rel: 'icon', type: 'image/svg+xml', href: `${prefix}/favicon.svg` })\n }\n\n links.push(\n { rel: 'icon', type: 'image/png', sizes: '32x32', href: `${prefix}/favicon-32x32.png` },\n { rel: 'icon', type: 'image/png', sizes: '16x16', href: `${prefix}/favicon-16x16.png` },\n { rel: 'apple-touch-icon', sizes: '180x180', href: `${prefix}/apple-touch-icon.png` },\n )\n\n if (config.manifest !== false) {\n links.push({ rel: 'manifest', href: `${prefix}/site.webmanifest` })\n }\n\n return links\n}\n\nasync function resizeToPng(input: string, size: number): Promise<Uint8Array | null> {\n try {\n const sharp = await import('sharp').then((m) => m.default ?? m)\n return await sharp(input).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()\n } catch {\n warnSharpMissing()\n return null\n }\n}\n\nasync function generateIco(input: string): Promise<Uint8Array | null> {\n try {\n const sharp = await import('sharp').then((m) => m.default ?? m)\n const png16 = await sharp(input).resize(16, 16, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()\n const png32 = await sharp(input).resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()\n\n // ICO format: header + directory entries + PNG data\n return createIcoFromPngs([\n { buffer: png16, size: 16 },\n { buffer: png32, size: 32 },\n ])\n } catch {\n warnSharpMissing()\n return null\n }\n}\n\nexport interface IcoEntry {\n buffer: Buffer\n size: number\n}\n\n/** @internal Exported for testing */\nexport function createIcoFromPngs(entries: IcoEntry[]): Uint8Array {\n const headerSize = 6\n const dirEntrySize = 16\n const dirSize = dirEntrySize * entries.length\n let dataOffset = headerSize + dirSize\n\n // ICO header\n const header = Buffer.alloc(headerSize)\n header.writeUInt16LE(0, 0) // reserved\n header.writeUInt16LE(1, 2) // type: icon\n header.writeUInt16LE(entries.length, 4) // count\n\n // Directory entries\n const dirEntries = Buffer.alloc(dirSize)\n const dataBuffers: Buffer[] = []\n\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i]!\n const offset = i * dirEntrySize\n dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset) // width\n dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1) // height\n dirEntries.writeUInt8(0, offset + 2) // palette\n dirEntries.writeUInt8(0, offset + 3) // reserved\n dirEntries.writeUInt16LE(1, offset + 4) // color planes\n dirEntries.writeUInt16LE(32, offset + 6) // bits per pixel\n dirEntries.writeUInt32LE(entry.buffer.length, offset + 8) // size\n dirEntries.writeUInt32LE(dataOffset, offset + 12) // offset\n\n dataOffset += entry.buffer.length\n dataBuffers.push(entry.buffer)\n }\n\n return Buffer.concat([header, dirEntries, ...dataBuffers])\n}\n\n// ─── Dev badge helpers ──────────────────────────────────────────────────────\n\n/**\n * Add a \"DEV\" badge overlay to an SVG string.\n * Adds a small colored circle with \"DEV\" text in the bottom-right corner.\n */\nfunction addDevBadgeToSvg(svg: string): string {\n const viewBoxMatch = svg.match(/viewBox=\"([^\"]*)\"/)\n const viewBox = viewBoxMatch?.[1] ?? '0 0 32 32'\n const [, , w, h] = viewBox.split(' ').map(Number)\n const size = Math.min(w ?? 32, h ?? 32)\n const r = size * 0.28\n const cx = (w ?? 32) - r\n const cy = (h ?? 32) - r\n const fontSize = r * 0.85\n\n const badge = `<circle cx=\"${cx}\" cy=\"${cy}\" r=\"${r}\" fill=\"#ef4444\" stroke=\"white\" stroke-width=\"${size * 0.03}\"/>` +\n `<text x=\"${cx}\" y=\"${cy}\" font-size=\"${fontSize}\" font-weight=\"bold\" fill=\"white\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\">D</text>`\n\n // Insert badge before closing </svg>\n return svg.replace(/<\\/svg>\\s*$/, `${badge}</svg>`)\n}\n\n/**\n * Add a \"DEV\" badge to a PNG buffer via sharp composite.\n * Composites a red circle with \"D\" in the bottom-right corner.\n */\nasync function addDevBadgeToPng(pngBuffer: Uint8Array, size: number): Promise<Uint8Array> {\n try {\n const sharp = await import('sharp').then((m) => m.default ?? m)\n const r = Math.round(size * 0.28)\n const d = r * 2\n const fontSize = Math.round(r * 0.85)\n\n const badgeSvg = `<svg width=\"${d}\" height=\"${d}\" xmlns=\"http://www.w3.org/2000/svg\">\n <circle cx=\"${r}\" cy=\"${r}\" r=\"${r}\" fill=\"#ef4444\"/>\n <text x=\"${r}\" y=\"${r}\" font-size=\"${fontSize}\" font-weight=\"bold\" fill=\"white\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\">D</text>\n </svg>`\n\n const badgePng = await sharp(Buffer.from(badgeSvg)).png().toBuffer()\n\n return await (sharp(Buffer.from(pngBuffer)) as any)\n .composite([{\n input: badgePng,\n gravity: 'southeast',\n }])\n .png()\n .toBuffer()\n } catch {\n // sharp not available — return original\n return pngBuffer\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"favicon.js","names":[],"sources":["../src/favicon.ts"],"sourcesContent":["import { existsSync } from 'node:fs'\nimport { readFile } from 'node:fs/promises'\nimport { 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 — favicons will not be generated. Install for full support: bun add -D sharp\\n',\n )\n}\n\n// ─── Favicon generation plugin ──────────────────────────────────────────────\n//\n// Generates all favicon formats from a single source file (SVG or PNG):\n// - favicon.ico (16x16 + 32x32 combined)\n// - favicon.svg (copied if source is SVG)\n// - apple-touch-icon.png (180x180)\n// - icon-192.png (for web manifest)\n// - icon-512.png (for web manifest)\n// - site.webmanifest\n//\n// Usage:\n// import { faviconPlugin } from \"@pyreon/zero\"\n// export default { plugins: [Pyreon] }\n\nexport interface FaviconLocaleConfig {\n /** Locale-specific source icon (SVG or PNG). */\n source: string\n /** Optional dark mode variant for this locale. */\n darkSource?: string\n}\n\nexport interface FaviconPluginConfig {\n /** Path to the source icon (SVG or PNG, at least 512x512 for PNG). */\n source: string\n /** Theme color for web manifest. Default: \"#ffffff\" */\n themeColor?: string\n /** Background color for web manifest. Default: \"#ffffff\" */\n backgroundColor?: string\n /** App name for web manifest. Uses package.json name if not set. */\n name?: string\n /** Generate web manifest. Default: true */\n manifest?: boolean\n /**\n * Dark mode favicon (SVG only).\n * When provided, the SVG favicon uses prefers-color-scheme media query\n * to switch between light and dark variants.\n */\n darkSource?: string\n /**\n * Locale-specific icon overrides. Each key is a locale code,\n * value is a source icon (and optional dark variant).\n * Locales not in this map use the base `source`.\n *\n * Generated files are placed under `/{locale}/` prefix:\n * /de/favicon.svg, /de/favicon-32x32.png, etc.\n *\n * @example\n * ```ts\n * faviconPlugin({\n * source: \"./icon.svg\",\n * locales: {\n * de: { source: \"./icon-de.svg\" },\n * cs: { source: \"./icon-cs.svg\" },\n * },\n * })\n * ```\n */\n locales?: Record<string, FaviconLocaleConfig>\n /**\n * Dev mode favicon — shown only during development to distinguish\n * dev tabs from production. Can be:\n * - A path to a separate icon file\n * - `true` to auto-generate a dev badge (grayscale + \"DEV\" overlay)\n *\n * @example\n * ```ts\n * faviconPlugin({\n * source: \"./icon.svg\",\n * devSource: \"./icon-dev.svg\", // custom dev icon\n * // OR\n * devSource: true, // auto-generate grayscale badge\n * })\n * ```\n */\n devSource?: string | boolean\n}\n\ninterface FaviconSize {\n size: number\n name: string\n}\n\nconst SIZES: FaviconSize[] = [\n { size: 16, name: 'favicon-16x16.png' },\n { size: 32, name: 'favicon-32x32.png' },\n { size: 180, name: 'apple-touch-icon.png' },\n { size: 192, name: 'icon-192.png' },\n { size: 512, name: 'icon-512.png' },\n]\n\n/**\n * Favicon generation Vite plugin.\n *\n * Generates all required favicon formats at build time from a single source.\n * In dev mode, serves the source directly.\n *\n * @example\n * ```ts\n * // vite.config.ts\n * import { faviconPlugin } from \"@pyreon/zero\"\n *\n * export default {\n * plugins: [faviconPlugin({ source: \"./src/assets/icon.svg\" })],\n * }\n * ```\n */\nexport function faviconPlugin(config: FaviconPluginConfig): Plugin {\n const themeColor = config.themeColor ?? '#ffffff'\n const backgroundColor = config.backgroundColor ?? '#ffffff'\n const generateManifest = config.manifest !== false\n\n let root = ''\n let isBuild = false\n\n return {\n name: 'pyreon-zero-favicon',\n enforce: 'pre',\n\n configResolved(resolvedConfig) {\n root = resolvedConfig.root\n isBuild = resolvedConfig.command === 'build'\n },\n\n // Dev server: serve generated favicons on-the-fly\n configureServer(server) {\n const sourcePath = join(root, config.source)\n const darkPath = config.darkSource ? join(root, config.darkSource) : null\n const devSourcePath = typeof config.devSource === 'string'\n ? join(root, config.devSource)\n : null\n const autoDevBadge = config.devSource === true\n const devCache = new Map<string, Uint8Array>()\n\n /** Resolve source path for a request — handles dark variants and dev badge. */\n function resolveSourceForDev(baseName: string, defaultSource: string): string {\n // Dark variant: favicon-dark-32x32.png → use darkSource\n if (darkPath && baseName.includes('-dark-')) return darkPath\n // Light variant: favicon-light-32x32.png → use source\n if (baseName.includes('-light-')) return defaultSource\n return defaultSource\n }\n\n server.middlewares.use(async (req, res, next) => {\n const url = req.url ?? ''\n\n // Resolve locale-specific source\n const localeSource = resolveLocaleSource(url, config, root)\n const svgUrl = localeSource ? localeSource.url : url\n const svgPath = localeSource ? localeSource.sourcePath : sourcePath\n const isSvgSource = localeSource ? localeSource.source.endsWith('.svg') : config.source.endsWith('.svg')\n\n // Serve favicon.svg — in dev, add dev badge overlay if configured\n if (svgUrl.endsWith('/favicon.svg') && isSvgSource) {\n try {\n let content = await readFile(svgPath, 'utf-8')\n if (autoDevBadge) content = addDevBadgeToSvg(content)\n else if (devSourcePath && existsSync(devSourcePath)) {\n content = await readFile(devSourcePath, 'utf-8')\n }\n res.setHeader('Content-Type', 'image/svg+xml')\n res.end(content)\n return\n } catch { /* fall through */ }\n }\n\n // Serve generated PNGs on-demand — supports dark variants + dev badge\n const baseName = svgUrl.split('/').pop() ?? ''\n // Strip light-/dark- prefix for size matching\n const cleanName = baseName.replace(/-?(light|dark)-/, '-')\n const sizeMatch = SIZES.find((s) => s.name === cleanName || baseName === s.name)\n if (sizeMatch) {\n const resolvedSource = resolveSourceForDev(baseName, svgPath)\n const cacheKey = `${resolvedSource}:${sizeMatch.size}:${autoDevBadge}`\n let png = devCache.get(cacheKey)\n if (!png) {\n let result = await resizeToPng(resolvedSource, sizeMatch.size)\n if (result && autoDevBadge) {\n result = await addDevBadgeToPng(result, sizeMatch.size)\n }\n if (result) {\n png = result\n devCache.set(cacheKey, result)\n }\n }\n if (png) {\n res.setHeader('Content-Type', 'image/png')\n res.setHeader('Cache-Control', 'no-cache')\n res.end(Buffer.from(png))\n return\n }\n }\n\n // Serve generated ICO on-demand\n if (baseName === 'favicon.ico') {\n const cacheKey = `ico:${svgPath}`\n let ico: Uint8Array | undefined = devCache.get(cacheKey)\n if (!ico) {\n const result = await generateIco(svgPath)\n if (result) {\n ico = result\n devCache.set(cacheKey, result)\n }\n }\n if (ico) {\n res.setHeader('Content-Type', 'image/x-icon')\n res.setHeader('Cache-Control', 'no-cache')\n res.end(Buffer.from(ico))\n return\n }\n }\n\n // Serve manifest (supports /{locale}/site.webmanifest)\n if (baseName === 'site.webmanifest' && generateManifest) {\n const prefix = localeSource ? `/${localeSource.locale}` : ''\n const manifest = {\n name: config.name ?? 'App',\n short_name: config.name ?? 'App',\n icons: [\n { src: `${prefix}/icon-192.png`, sizes: '192x192', type: 'image/png' },\n { src: `${prefix}/icon-512.png`, sizes: '512x512', type: 'image/png' },\n ],\n theme_color: themeColor,\n background_color: backgroundColor,\n display: 'standalone',\n }\n res.setHeader('Content-Type', 'application/manifest+json')\n res.end(JSON.stringify(manifest, null, 2))\n return\n }\n\n next()\n })\n },\n\n // Inject favicon <link> tags into HTML\n transformIndexHtml() {\n const isSvg = config.source.endsWith('.svg')\n const hasDark = !!config.darkSource\n const tags: Array<{\n tag: string\n attrs: Record<string, string>\n injectTo: 'head'\n }> = []\n\n // SVG favicon (with prefers-color-scheme media query when dark variant exists)\n if (isSvg) {\n tags.push({\n tag: 'link',\n attrs: { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },\n injectTo: 'head',\n })\n }\n\n if (hasDark) {\n // Dual-variant PNG/ICO favicons — light active, dark hidden via media=\"not all\".\n // The themeScript and initTheme() swap these based on the resolved theme.\n const lightAttrs = { 'data-favicon-theme': 'light' }\n const darkAttrs = { 'data-favicon-theme': 'dark', media: 'not all' }\n\n tags.push(\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-light-32x32.png', ...lightAttrs }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-dark-32x32.png', ...darkAttrs }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-light-16x16.png', ...lightAttrs }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-dark-16x16.png', ...darkAttrs }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-light.png', ...lightAttrs }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-dark.png', ...darkAttrs }, injectTo: 'head' },\n )\n } else {\n // Single-variant (no dark mode)\n tags.push(\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' }, injectTo: 'head' },\n { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }, injectTo: 'head' },\n )\n }\n\n if (generateManifest) {\n tags.push({\n tag: 'link',\n attrs: { rel: 'manifest', href: '/site.webmanifest' },\n injectTo: 'head',\n })\n }\n\n tags.push({\n tag: 'meta',\n attrs: { name: 'theme-color', content: themeColor },\n injectTo: 'head',\n })\n\n // Auto-inject favicon swap script when dark variant exists.\n // This runs in the blocking <head> before any render — no flash.\n // Reads theme from localStorage or OS preference, then swaps\n // data-favicon-theme media attributes.\n if (hasDark) {\n tags.push({\n tag: 'script',\n attrs: {},\n injectTo: 'head',\n children: `(function(){try{var t=localStorage.getItem(\"zero-theme\");var r=t===\"light\"?\"light\":t===\"dark\"?\"dark\":window.matchMedia(\"(prefers-color-scheme:dark)\").matches?\"dark\":\"light\";document.querySelectorAll(\"[data-favicon-theme]\").forEach(function(l){l.media=l.dataset.faviconTheme===r?\"\":\"not all\"})}catch(e){}})()`,\n } as any)\n }\n\n return tags\n },\n\n async generateBundle() {\n if (!isBuild) return\n\n // Generate favicons for the base (default) source\n await generateFaviconSet.call(this, root, config.source, config.darkSource, '', config, themeColor, backgroundColor, generateManifest)\n\n // Generate locale-specific favicon sets\n if (config.locales) {\n for (const [locale, localeConfig] of Object.entries(config.locales)) {\n await generateFaviconSet.call(this, root, localeConfig.source, localeConfig.darkSource, `${locale}/`, config, themeColor, backgroundColor, generateManifest)\n }\n }\n },\n }\n}\n\n/**\n * Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.\n */\nfunction wrapSvgWithDarkMode(lightSvg: string, darkSvg: string): string {\n // Extract viewBox from light SVG\n const viewBoxMatch = lightSvg.match(/viewBox=\"([^\"]*)\"/)\n const viewBox = viewBoxMatch?.[1] ?? '0 0 32 32'\n\n return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${viewBox}\">\n <style>\n :root { color-scheme: light dark; }\n @media (prefers-color-scheme: dark) { .light { display: none; } }\n @media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }\n </style>\n <g class=\"light\">${stripSvgWrapper(lightSvg)}</g>\n <g class=\"dark\">${stripSvgWrapper(darkSvg)}</g>\n</svg>`\n}\n\nfunction stripSvgWrapper(svg: string): string {\n return svg\n .replace(/<svg[^>]*>/, '')\n .replace(/<\\/svg>\\s*$/, '')\n .trim()\n}\n\n/**\n * Resolve the source path for a locale-prefixed favicon URL.\n * Returns null if the URL is not locale-prefixed or locale has no override.\n */\nfunction resolveLocaleSource(\n url: string,\n config: FaviconPluginConfig,\n rootDir: string,\n): { locale: string; url: string; source: string; sourcePath: string } | null {\n if (!config.locales) return null\n\n for (const [locale, localeConfig] of Object.entries(config.locales)) {\n const prefix = `/${locale}/`\n if (url.startsWith(prefix)) {\n return {\n locale,\n url,\n source: localeConfig.source,\n sourcePath: join(rootDir, localeConfig.source),\n }\n }\n }\n return null\n}\n\n/**\n * Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.\n * Called once for base (prefix = '') and once per locale (prefix = '{locale}/').\n */\nasync function generateFaviconSet(\n this: any,\n rootDir: string,\n source: string,\n darkSource: string | undefined,\n prefix: string,\n config: FaviconPluginConfig,\n themeColor: string,\n backgroundColor: string,\n generateManifest: boolean,\n): Promise<void> {\n const sourcePath = join(rootDir, source)\n if (!existsSync(sourcePath)) {\n // oxlint-disable-next-line no-console\n console.warn(`[Pyreon] Source not found: ${sourcePath}`)\n return\n }\n\n const isSvg = source.endsWith('.svg')\n\n // Copy SVG as favicon.svg\n if (isSvg) {\n const svgContent = await readFile(sourcePath, 'utf-8')\n let finalSvg = svgContent\n\n if (darkSource) {\n const darkPath = join(rootDir, darkSource)\n if (existsSync(darkPath)) {\n const darkSvg = await readFile(darkPath, 'utf-8')\n finalSvg = wrapSvgWithDarkMode(svgContent, darkSvg)\n }\n }\n\n this.emitFile({\n type: 'asset',\n fileName: `${prefix}favicon.svg`,\n source: finalSvg,\n })\n }\n\n // Generate PNG sizes via sharp\n if (darkSource) {\n // Dual-variant: generate light + dark PNGs with prefixed names\n const darkPath = join(rootDir, darkSource)\n const darkExists = existsSync(darkPath)\n\n for (const { size, name } of SIZES) {\n // Light variant\n const lightName = name.replace(/^(favicon-)/, '$1light-').replace(/^(apple-touch-icon)/, '$1-light').replace(/^(icon-)/, '$1light-')\n const lightPng = await resizeToPng(sourcePath, size)\n if (lightPng) {\n this.emitFile({ type: 'asset', fileName: `${prefix}${lightName}`, source: lightPng })\n }\n\n // Dark variant\n if (darkExists) {\n const darkName = name.replace(/^(favicon-)/, '$1dark-').replace(/^(apple-touch-icon)/, '$1-dark').replace(/^(icon-)/, '$1dark-')\n const darkPng = await resizeToPng(darkPath, size)\n if (darkPng) {\n this.emitFile({ type: 'asset', fileName: `${prefix}${darkName}`, source: darkPng })\n }\n }\n }\n\n // Also generate standard names (used by manifest + external references)\n for (const { size, name } of SIZES) {\n const pngBuffer = await resizeToPng(sourcePath, size)\n if (pngBuffer) {\n this.emitFile({ type: 'asset', fileName: `${prefix}${name}`, source: pngBuffer })\n }\n }\n } else {\n // Single-variant\n for (const { size, name } of SIZES) {\n const pngBuffer = await resizeToPng(sourcePath, size)\n if (pngBuffer) {\n this.emitFile({ type: 'asset', fileName: `${prefix}${name}`, source: pngBuffer })\n }\n }\n }\n\n // Generate favicon.ico (16 + 32)\n const ico = await generateIco(sourcePath)\n if (ico) {\n this.emitFile({\n type: 'asset',\n fileName: `${prefix}favicon.ico`,\n source: ico,\n })\n }\n\n // Generate web manifest\n if (generateManifest) {\n const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : ''\n const manifest = {\n name: config.name ?? 'App',\n short_name: config.name ?? 'App',\n icons: [\n { src: `${manifestPrefix}/icon-192.png`, sizes: '192x192', type: 'image/png' },\n { src: `${manifestPrefix}/icon-512.png`, sizes: '512x512', type: 'image/png' },\n ],\n theme_color: themeColor,\n background_color: backgroundColor,\n display: 'standalone',\n }\n\n this.emitFile({\n type: 'asset',\n fileName: `${prefix}site.webmanifest`,\n source: JSON.stringify(manifest, null, 2),\n })\n }\n}\n\n/**\n * Get favicon link tags for a specific locale.\n * Returns link objects suitable for `useHead()` or direct HTML injection.\n *\n * @example\n * ```ts\n * const links = faviconLinks(\"de\", { source: \"./icon.svg\", locales: { de: { source: \"./icon-de.svg\" } } })\n * // → [{ rel: \"icon\", type: \"image/svg+xml\", href: \"/de/favicon.svg\" }, ...]\n * ```\n */\nexport function faviconLinks(\n locale: string | undefined,\n config: FaviconPluginConfig,\n): Array<{ rel: string; type?: string; sizes?: string; href: string }> {\n const hasLocaleOverride = locale && config.locales?.[locale]\n const prefix = hasLocaleOverride ? `/${locale}` : ''\n const isSvg = (hasLocaleOverride ? config.locales![locale]!.source : config.source).endsWith('.svg')\n\n const links: Array<{ rel: string; type?: string; sizes?: string; href: string }> = []\n\n if (isSvg) {\n links.push({ rel: 'icon', type: 'image/svg+xml', href: `${prefix}/favicon.svg` })\n }\n\n links.push(\n { rel: 'icon', type: 'image/png', sizes: '32x32', href: `${prefix}/favicon-32x32.png` },\n { rel: 'icon', type: 'image/png', sizes: '16x16', href: `${prefix}/favicon-16x16.png` },\n { rel: 'apple-touch-icon', sizes: '180x180', href: `${prefix}/apple-touch-icon.png` },\n )\n\n if (config.manifest !== false) {\n links.push({ rel: 'manifest', href: `${prefix}/site.webmanifest` })\n }\n\n return links\n}\n\nasync function resizeToPng(input: string, size: number): Promise<Uint8Array | null> {\n try {\n const sharp = await import('sharp').then((m) => m.default ?? m)\n return await sharp(input).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()\n } catch {\n warnSharpMissing()\n return null\n }\n}\n\nasync function generateIco(input: string): Promise<Uint8Array | null> {\n try {\n const sharp = await import('sharp').then((m) => m.default ?? m)\n const png16 = await sharp(input).resize(16, 16, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()\n const png32 = await sharp(input).resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()\n\n // ICO format: header + directory entries + PNG data\n return createIcoFromPngs([\n { buffer: png16, size: 16 },\n { buffer: png32, size: 32 },\n ])\n } catch {\n warnSharpMissing()\n return null\n }\n}\n\nexport interface IcoEntry {\n buffer: Buffer\n size: number\n}\n\n/** @internal Exported for testing */\nexport function createIcoFromPngs(entries: IcoEntry[]): Uint8Array {\n const headerSize = 6\n const dirEntrySize = 16\n const dirSize = dirEntrySize * entries.length\n let dataOffset = headerSize + dirSize\n\n // ICO header\n const header = Buffer.alloc(headerSize)\n header.writeUInt16LE(0, 0) // reserved\n header.writeUInt16LE(1, 2) // type: icon\n header.writeUInt16LE(entries.length, 4) // count\n\n // Directory entries\n const dirEntries = Buffer.alloc(dirSize)\n const dataBuffers: Buffer[] = []\n\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i]!\n const offset = i * dirEntrySize\n dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset) // width\n dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1) // height\n dirEntries.writeUInt8(0, offset + 2) // palette\n dirEntries.writeUInt8(0, offset + 3) // reserved\n dirEntries.writeUInt16LE(1, offset + 4) // color planes\n dirEntries.writeUInt16LE(32, offset + 6) // bits per pixel\n dirEntries.writeUInt32LE(entry.buffer.length, offset + 8) // size\n dirEntries.writeUInt32LE(dataOffset, offset + 12) // offset\n\n dataOffset += entry.buffer.length\n dataBuffers.push(entry.buffer)\n }\n\n return Buffer.concat([header, dirEntries, ...dataBuffers])\n}\n\n// ─── Dev badge helpers ──────────────────────────────────────────────────────\n\n/**\n * Add a \"DEV\" badge overlay to an SVG string.\n * Adds a small colored circle with \"DEV\" text in the bottom-right corner.\n */\nfunction addDevBadgeToSvg(svg: string): string {\n const viewBoxMatch = svg.match(/viewBox=\"([^\"]*)\"/)\n const viewBox = viewBoxMatch?.[1] ?? '0 0 32 32'\n const [, , w, h] = viewBox.split(' ').map(Number)\n const size = Math.min(w ?? 32, h ?? 32)\n const r = size * 0.28\n const cx = (w ?? 32) - r\n const cy = (h ?? 32) - r\n const fontSize = r * 0.85\n\n const badge = `<circle cx=\"${cx}\" cy=\"${cy}\" r=\"${r}\" fill=\"#ef4444\" stroke=\"white\" stroke-width=\"${size * 0.03}\"/>` +\n `<text x=\"${cx}\" y=\"${cy}\" font-size=\"${fontSize}\" font-weight=\"bold\" fill=\"white\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\">D</text>`\n\n // Insert badge before closing </svg>\n return svg.replace(/<\\/svg>\\s*$/, `${badge}</svg>`)\n}\n\n/**\n * Add a \"DEV\" badge to a PNG buffer via sharp composite.\n * Composites a red circle with \"D\" in the bottom-right corner.\n */\nasync function addDevBadgeToPng(pngBuffer: Uint8Array, size: number): Promise<Uint8Array> {\n try {\n const sharp = await import('sharp').then((m) => m.default ?? m)\n const r = Math.round(size * 0.28)\n const d = r * 2\n const fontSize = Math.round(r * 0.85)\n\n const badgeSvg = `<svg width=\"${d}\" height=\"${d}\" xmlns=\"http://www.w3.org/2000/svg\">\n <circle cx=\"${r}\" cy=\"${r}\" r=\"${r}\" fill=\"#ef4444\"/>\n <text x=\"${r}\" y=\"${r}\" font-size=\"${fontSize}\" font-weight=\"bold\" fill=\"white\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\">D</text>\n </svg>`\n\n const badgePng = await sharp(Buffer.from(badgeSvg)).png().toBuffer()\n\n return await (sharp(Buffer.from(pngBuffer)) as any)\n .composite([{\n input: badgePng,\n gravity: 'southeast',\n }])\n .png()\n .toBuffer()\n } catch {\n // sharp not available — return original\n return pngBuffer\n }\n}\n"],"mappings":"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"}
package/lib/font.js CHANGED
@@ -127,7 +127,7 @@ function preloadTags(fonts) {
127
127
  */
128
128
  async function downloadGoogleFontsCSS(url) {
129
129
  const response = await fetch(url, { headers: { "User-Agent": "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" } });
130
- if (!response.ok) throw new Error(`Failed to fetch Google Fonts CSS: ${response.status}`);
130
+ if (!response.ok) throw new Error(`[Pyreon] Failed to fetch Google Fonts CSS: ${response.status}`);
131
131
  return response.text();
132
132
  }
133
133
  /**
@@ -135,7 +135,7 @@ async function downloadGoogleFontsCSS(url) {
135
135
  */
136
136
  async function downloadFontFile(url) {
137
137
  const response = await fetch(url);
138
- if (!response.ok) throw new Error(`Failed to download font: ${url}`);
138
+ if (!response.ok) throw new Error(`[Pyreon] Failed to download font: ${url}`);
139
139
  const arrayBuffer = await response.arrayBuffer();
140
140
  return Buffer.from(arrayBuffer);
141
141
  }
package/lib/font.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"font.js","names":[],"sources":["../src/font.ts"],"sourcesContent":["import { mkdir, readFile, writeFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport 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 root: string,\n): Promise<{\n css: string\n fontFiles: Array<{ name: string; content: Buffer }>\n}> {\n // Cache fonts between builds to avoid re-downloading (~6s penalty)\n const cacheDir = join(root, 'node_modules', '.cache', 'zero-fonts')\n const cacheKey = Buffer.from(cssUrl).toString('base64url')\n const cachePath = join(cacheDir, `${cacheKey}.json`)\n\n try {\n const cached = JSON.parse(await readFile(cachePath, 'utf-8'))\n if (cached.css && cached.fontFiles) {\n return {\n css: cached.css,\n fontFiles: cached.fontFiles.map((f: any) => ({\n name: f.name,\n content: Buffer.from(f.content, 'base64'),\n })),\n }\n }\n } catch {\n // No cache — download fresh\n }\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 // Write cache\n try {\n await mkdir(cacheDir, { recursive: true })\n await writeFile(cachePath, JSON.stringify({\n css: rewrittenCss,\n fontFiles: fontFiles.map((f) => ({ name: f.name, content: f.content.toString('base64') })),\n }))\n } catch {\n // Cache write failure is non-fatal\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 root = ''\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 root = resolvedConfig.root\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', root)\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 { mkdir, readFile, writeFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport 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(`[Pyreon] 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(`[Pyreon] 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 root: string,\n): Promise<{\n css: string\n fontFiles: Array<{ name: string; content: Buffer }>\n}> {\n // Cache fonts between builds to avoid re-downloading (~6s penalty)\n const cacheDir = join(root, 'node_modules', '.cache', 'zero-fonts')\n const cacheKey = Buffer.from(cssUrl).toString('base64url')\n const cachePath = join(cacheDir, `${cacheKey}.json`)\n\n try {\n const cached = JSON.parse(await readFile(cachePath, 'utf-8'))\n if (cached.css && cached.fontFiles) {\n return {\n css: cached.css,\n fontFiles: cached.fontFiles.map((f: any) => ({\n name: f.name,\n content: Buffer.from(f.content, 'base64'),\n })),\n }\n }\n } catch {\n // No cache — download fresh\n }\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 // Write cache\n try {\n await mkdir(cacheDir, { recursive: true })\n await writeFile(cachePath, JSON.stringify({\n css: rewrittenCss,\n fontFiles: fontFiles.map((f) => ({ name: f.name, content: f.content.toString('base64') })),\n }))\n } catch {\n // Cache write failure is non-fatal\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 root = ''\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 root = resolvedConfig.root\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', root)\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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"}