@pyreon/zero 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/lib/api-routes-DANluJic.js +146 -0
  2. package/lib/client.js +3 -1
  3. package/lib/csp.js +19 -9
  4. package/lib/{fs-router-CQ7Zxeca.js → fs-router-ZebyutPa.js} +43 -6
  5. package/lib/image-plugin.js +4 -0
  6. package/lib/image.js +1 -50
  7. package/lib/index.js +1 -50
  8. package/lib/link.js +1 -49
  9. package/lib/script.js +1 -49
  10. package/lib/server.js +6 -688
  11. package/lib/theme.js +1 -50
  12. package/lib/types/i18n-routing.d.ts +4 -4
  13. package/lib/types/index.d.ts +23 -13
  14. package/lib/types/link.d.ts +3 -3
  15. package/lib/types/server.d.ts +28 -5
  16. package/lib/types/theme.d.ts +2 -2
  17. package/lib/vite-plugin-E4BHYvYW.js +855 -0
  18. package/package.json +15 -13
  19. package/src/app.ts +21 -1
  20. package/src/csp.ts +28 -12
  21. package/src/fs-router.ts +53 -3
  22. package/src/ssg-plugin.ts +366 -0
  23. package/src/types.ts +28 -9
  24. package/src/vite-plugin.ts +220 -40
  25. package/lib/actions.js.map +0 -1
  26. package/lib/ai.js.map +0 -1
  27. package/lib/api-routes.js.map +0 -1
  28. package/lib/cache.js.map +0 -1
  29. package/lib/client.js.map +0 -1
  30. package/lib/compression.js.map +0 -1
  31. package/lib/config.js.map +0 -1
  32. package/lib/cors.js.map +0 -1
  33. package/lib/csp.js.map +0 -1
  34. package/lib/env.js.map +0 -1
  35. package/lib/favicon.js.map +0 -1
  36. package/lib/font.js.map +0 -1
  37. package/lib/fs-router-3xzp-4Wj.js.map +0 -1
  38. package/lib/fs-router-CQ7Zxeca.js.map +0 -1
  39. package/lib/i18n-routing.js.map +0 -1
  40. package/lib/image-plugin.js.map +0 -1
  41. package/lib/image.js.map +0 -1
  42. package/lib/index.js.map +0 -1
  43. package/lib/link.js.map +0 -1
  44. package/lib/logger.js.map +0 -1
  45. package/lib/meta.js.map +0 -1
  46. package/lib/middleware.js.map +0 -1
  47. package/lib/og-image.js.map +0 -1
  48. package/lib/rate-limit.js.map +0 -1
  49. package/lib/script.js.map +0 -1
  50. package/lib/seo.js.map +0 -1
  51. package/lib/server.js.map +0 -1
  52. package/lib/testing.js.map +0 -1
  53. package/lib/theme.js.map +0 -1
  54. package/lib/types/actions.d.ts.map +0 -1
  55. package/lib/types/ai.d.ts.map +0 -1
  56. package/lib/types/api-routes.d.ts.map +0 -1
  57. package/lib/types/cache.d.ts.map +0 -1
  58. package/lib/types/client.d.ts.map +0 -1
  59. package/lib/types/compression.d.ts.map +0 -1
  60. package/lib/types/config.d.ts.map +0 -1
  61. package/lib/types/cors.d.ts.map +0 -1
  62. package/lib/types/csp.d.ts.map +0 -1
  63. package/lib/types/env.d.ts.map +0 -1
  64. package/lib/types/favicon.d.ts.map +0 -1
  65. package/lib/types/font.d.ts.map +0 -1
  66. package/lib/types/i18n-routing.d.ts.map +0 -1
  67. package/lib/types/image-plugin.d.ts.map +0 -1
  68. package/lib/types/image.d.ts.map +0 -1
  69. package/lib/types/index.d.ts.map +0 -1
  70. package/lib/types/link.d.ts.map +0 -1
  71. package/lib/types/logger.d.ts.map +0 -1
  72. package/lib/types/meta.d.ts.map +0 -1
  73. package/lib/types/middleware.d.ts.map +0 -1
  74. package/lib/types/og-image.d.ts.map +0 -1
  75. package/lib/types/rate-limit.d.ts.map +0 -1
  76. package/lib/types/script.d.ts.map +0 -1
  77. package/lib/types/seo.d.ts.map +0 -1
  78. package/lib/types/server.d.ts.map +0 -1
  79. package/lib/types/testing.d.ts.map +0 -1
  80. package/lib/types/theme.d.ts.map +0 -1
package/lib/ai.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"ai.js","names":[],"sources":["../src/fs-router.ts","../src/ai.ts"],"sourcesContent":["import { readFileSync } from 'node:fs'\nimport { join } from 'node:path'\nimport type { FileRoute, RenderMode, RouteFileExports } from './types'\n\n// ─── File-system route conventions ──────────────────────────────────────────\n//\n// src/routes/\n// _layout.tsx → layout for all routes\n// index.tsx → /\n// about.tsx → /about\n// users/\n// _layout.tsx → layout for /users/*\n// _loading.tsx → loading fallback for /users/*\n// _error.tsx → error boundary for /users/*\n// index.tsx → /users\n// [id].tsx → /users/:id\n// [id]/\n// settings.tsx → /users/:id/settings\n// blog/\n// [...slug].tsx → /blog/* (catch-all)\n//\n// Conventions:\n// [param] → dynamic segment → :param\n// [...param] → catch-all → :param*\n// _layout → layout wrapper — must use <RouterView /> to render child routes\n// (props.children is NOT passed — the router handles nesting)\n// _error → error component\n// _loading → loading component\n// _404 → not-found component (renders on 404)\n// _not-found → alias for _404\n// (group) → route group (directory ignored in URL)\n\nconst ROUTE_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js']\n\n/** Names whose top-level export presence we care about. */\nconst ROUTE_EXPORT_NAMES = [\n 'loader',\n 'guard',\n 'meta',\n 'renderMode',\n 'error',\n 'middleware',\n] as const\n\ntype RouteExportName = (typeof ROUTE_EXPORT_NAMES)[number]\n\n/**\n * Detect which optional metadata exports a route file source declares.\n *\n * Walks the source character-by-character, tracking string-literal and\n * comment state, then collects top-level `export …` statements. This is\n * more accurate than regex (no false matches inside string literals,\n * template literals, or comments) and lighter than a full AST parse\n * (no oxc/babel dependency, ~1µs per file).\n *\n * Recognizes:\n * • `export const NAME = …`\n * • `export let NAME = …`\n * • `export var NAME = …`\n * • `export function NAME(…)`\n * • `export async function NAME(…)`\n * • `export { NAME }` and `export { localName as NAME }`\n * • `export { NAME } from '…'` (re-export)\n *\n * Names checked: loader, guard, meta, renderMode, error, middleware.\n */\nexport function detectRouteExports(source: string): RouteFileExports {\n const found = new Set<RouteExportName>()\n const tokens = scanTopLevelExportTokens(source)\n\n for (const tok of tokens) {\n if (tok.kind === 'declaration') {\n // `export const NAME` / `export function NAME`\n if ((ROUTE_EXPORT_NAMES as readonly string[]).includes(tok.name)) {\n found.add(tok.name as RouteExportName)\n }\n } else {\n // `export { localName as exportedName, ... }`\n for (const name of tok.names) {\n if ((ROUTE_EXPORT_NAMES as readonly string[]).includes(name)) {\n found.add(name as RouteExportName)\n }\n }\n }\n }\n\n // Capture literal `meta` and `renderMode` initializers when present\n // so the route generator can inline them and avoid forcing a static\n // import of the entire route module just to read the metadata.\n // Strip any trailing `as const` / `satisfies T` type assertions —\n // the generated routes module is plain JS, not TS.\n //\n // We then run `isPureLiteral()` to make sure the captured expression\n // doesn't reference any free identifiers (e.g. `meta = { title: foo }`\n // where `foo` is a const declared elsewhere in the file). Inlining\n // such an expression into the routes module would produce a runtime\n // ReferenceError, so we drop the literal and let the generator fall\n // back to a static module import in those cases.\n const rawMeta = found.has('meta') ? extractLiteralExport(source, 'meta') : undefined\n const rawRenderMode = found.has('renderMode')\n ? extractLiteralExport(source, 'renderMode')\n : undefined\n const cleanMeta = rawMeta !== undefined ? stripTypeAssertions(rawMeta) : undefined\n const cleanRenderMode =\n rawRenderMode !== undefined ? stripTypeAssertions(rawRenderMode) : undefined\n const metaLiteral = cleanMeta !== undefined && isPureLiteral(cleanMeta) ? cleanMeta : undefined\n const renderModeLiteral =\n cleanRenderMode !== undefined && isPureLiteral(cleanRenderMode) ? cleanRenderMode : undefined\n\n return {\n hasLoader: found.has('loader'),\n hasGuard: found.has('guard'),\n hasMeta: found.has('meta'),\n hasRenderMode: found.has('renderMode'),\n hasError: found.has('error'),\n hasMiddleware: found.has('middleware'),\n ...(metaLiteral !== undefined ? { metaLiteral } : {}),\n ...(renderModeLiteral !== undefined ? { renderModeLiteral } : {}),\n }\n}\n\n/**\n * Extract the literal initializer of an `export const NAME = …` statement\n * as a raw text slice — used by the route generator to inline `meta` and\n * `renderMode` values into the generated routes module.\n *\n * Walks the source character-by-character respecting strings, template\n * literals, comments, and brace/bracket/paren nesting. The slice runs\n * from the first non-whitespace character after `=` to the matching\n * end-of-expression terminator (`;`, newline at depth 0, or top-level\n * `export`). Whatever the slice contains is handed to V8 verbatim by\n * embedding it inside `{ … }` in the generated module — which means\n * the original source must already be valid JavaScript (which it is,\n * since the route file compiles).\n *\n * Returns `undefined` when extraction fails for any reason — the\n * generator falls back to a static module import in that case.\n */\nfunction extractLiteralExport(source: string, name: string): string | undefined {\n // Find `export const NAME = ` at top level. Reuse the same\n // string/comment/depth tracking as the token scanner so we don't\n // false-match inside literals.\n const len = source.length\n let i = 0\n let depth = 0\n\n const isIdCont = (c: string) => /[A-Za-z0-9_$]/.test(c)\n const skipWs = (p: number): number => {\n while (p < len && /\\s/.test(source[p] as string)) p++\n return p\n }\n\n while (i < len) {\n const ch = source[i] as string\n const next = source[i + 1] ?? ''\n\n // Skip comments\n if (ch === '/' && next === '/') {\n while (i < len && source[i] !== '\\n') i++\n continue\n }\n if (ch === '/' && next === '*') {\n i += 2\n while (i < len - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++\n i += 2\n continue\n }\n\n // Skip string literals\n if (ch === '\"' || ch === \"'\") {\n const quote = ch\n i++\n while (i < len && source[i] !== quote) {\n if (source[i] === '\\\\') i += 2\n else i++\n }\n i++\n continue\n }\n if (ch === '`') {\n i++\n while (i < len && source[i] !== '`') {\n if (source[i] === '\\\\') {\n i += 2\n continue\n }\n if (source[i] === '$' && source[i + 1] === '{') {\n i += 2\n let exprDepth = 1\n while (i < len && exprDepth > 0) {\n const c = source[i] as string\n if (c === '{') exprDepth++\n else if (c === '}') exprDepth--\n if (exprDepth === 0) {\n i++\n break\n }\n i++\n }\n continue\n }\n i++\n }\n i++\n continue\n }\n\n // Brace depth tracking\n if (ch === '{') {\n depth++\n i++\n continue\n }\n if (ch === '}') {\n depth--\n i++\n continue\n }\n\n // Look for `export const NAME = …` at depth 0\n if (depth === 0 && ch === 'e') {\n const afterExport = source.slice(i, i + 6) === 'export' && !isIdCont(source[i + 6] ?? '')\n if (afterExport) {\n let p = skipWs(i + 6)\n if (source.slice(p, p + 5) === 'const' && !isIdCont(source[p + 5] ?? '')) {\n p = skipWs(p + 5)\n // Check that the identifier matches our target name\n if (\n source.slice(p, p + name.length) === name &&\n !isIdCont(source[p + name.length] ?? '')\n ) {\n p = skipWs(p + name.length)\n if (source[p] === '=') {\n p = skipWs(p + 1)\n return readExpressionUntilEnd(source, p)\n }\n }\n }\n i = i + 6\n continue\n }\n }\n\n i++\n }\n\n return undefined\n}\n\n/**\n * Read a JavaScript expression starting at `start` and return the raw\n * text up to (but not including) its end. The end is whichever comes\n * first of:\n * • a `;` at depth 0\n * • a newline at depth 0 that is not inside a string/template\n * • the next top-level `export` / `const` / `function` keyword\n * • end of file\n *\n * Tracks `()`, `[]`, and `{}` nesting plus string/template/comment\n * state so depth-0 boundaries are detected correctly even for nested\n * objects, arrays, and tagged templates.\n */\nfunction readExpressionUntilEnd(source: string, start: number): string | undefined {\n const len = source.length\n let i = start\n let depth = 0 // combined paren/bracket/brace depth\n\n while (i < len) {\n const ch = source[i] as string\n const next = source[i + 1] ?? ''\n\n // End conditions at depth 0\n if (depth === 0) {\n if (ch === ';') return source.slice(start, i).trim() || undefined\n if (ch === '\\n') {\n // Allow trailing whitespace/comma but stop at the newline.\n // Some authors close objects on the same line, others span\n // them across lines — the depth check above handles the\n // multi-line case so a depth-0 newline really is the end.\n const trimmed = source.slice(start, i).trim()\n if (trimmed.length === 0) {\n i++\n continue\n }\n return trimmed\n }\n }\n\n // Skip comments\n if (ch === '/' && next === '/') {\n while (i < len && source[i] !== '\\n') i++\n continue\n }\n if (ch === '/' && next === '*') {\n i += 2\n while (i < len - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++\n i += 2\n continue\n }\n\n // Skip strings\n if (ch === '\"' || ch === \"'\") {\n const quote = ch\n i++\n while (i < len && source[i] !== quote) {\n if (source[i] === '\\\\') i += 2\n else i++\n }\n i++\n continue\n }\n if (ch === '`') {\n i++\n while (i < len && source[i] !== '`') {\n if (source[i] === '\\\\') {\n i += 2\n continue\n }\n if (source[i] === '$' && source[i + 1] === '{') {\n i += 2\n let exprDepth = 1\n while (i < len && exprDepth > 0) {\n const c = source[i] as string\n if (c === '{') exprDepth++\n else if (c === '}') exprDepth--\n if (exprDepth === 0) {\n i++\n break\n }\n i++\n }\n continue\n }\n i++\n }\n i++\n continue\n }\n\n // Track depth across all bracket families\n if (ch === '{' || ch === '[' || ch === '(') {\n depth++\n i++\n continue\n }\n if (ch === '}' || ch === ']' || ch === ')') {\n depth--\n if (depth < 0) {\n // We ran past our scope without seeing a terminator. The\n // expression must have ended right before this closer.\n return source.slice(start, i).trim() || undefined\n }\n i++\n continue\n }\n\n i++\n }\n\n // Hit EOF without an explicit terminator — return whatever we have\n // if it looks plausible, otherwise undefined.\n const trimmed = source.slice(start).trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\n/**\n * True if `text` is a pure JS literal — only string/number/boolean/null\n * literals plus the structural punctuation needed to compose them into\n * objects, arrays, and tuples. Identifiers, operators, function calls,\n * template-literal expression slots, and references to other names all\n * disqualify the expression.\n *\n * Walks the source character-by-character, tracking string/template/\n * comment state. Inside a string or template head (no `${}` slot) every\n * character is fine; outside strings, only the structural symbols\n * `{}[](),:` plus whitespace, digits, the literal keywords `true`,\n * `false`, `null`, and `-` (for negative numbers) are allowed.\n *\n * The check is conservative on purpose — anything fancier than a flat\n * literal falls back to the static-import path, which still works,\n * just at the cost of one un-split chunk.\n */\nfunction isPureLiteral(text: string): boolean {\n const len = text.length\n let i = 0\n\n while (i < len) {\n const ch = text[i] as string\n\n // Strings — anything inside is literal data\n if (ch === '\"' || ch === \"'\") {\n const quote = ch\n i++\n while (i < len && text[i] !== quote) {\n if (text[i] === '\\\\') i += 2\n else i++\n }\n i++\n continue\n }\n\n // Template literals — only allowed if they contain no ${} slots\n if (ch === '`') {\n i++\n while (i < len && text[i] !== '`') {\n if (text[i] === '\\\\') {\n i += 2\n continue\n }\n if (text[i] === '$' && text[i + 1] === '{') {\n // Template with an expression slot — not a pure literal\n return false\n }\n i++\n }\n i++\n continue\n }\n\n // Whitespace + structural punctuation are fine\n if (/\\s/.test(ch)) {\n i++\n continue\n }\n if (ch === '{' || ch === '}' || ch === '[' || ch === ']' || ch === ',' || ch === ':') {\n i++\n continue\n }\n\n // Number literals (including leading - and 0x/0b/0o)\n if (/[0-9]/.test(ch) || (ch === '-' && /[0-9]/.test(text[i + 1] ?? ''))) {\n while (i < len && /[0-9a-fA-Fxob.eE+\\-_]/.test(text[i] as string)) i++\n continue\n }\n\n // Allowed bare identifiers — only the literal keywords\n if (text.slice(i, i + 4) === 'true' && !isIdContChar(text[i + 4] ?? '')) {\n i += 4\n continue\n }\n if (text.slice(i, i + 5) === 'false' && !isIdContChar(text[i + 5] ?? '')) {\n i += 5\n continue\n }\n if (text.slice(i, i + 4) === 'null' && !isIdContChar(text[i + 4] ?? '')) {\n i += 4\n continue\n }\n if (text.slice(i, i + 9) === 'undefined' && !isIdContChar(text[i + 9] ?? '')) {\n i += 9\n continue\n }\n\n // Property keys can be unquoted identifiers — they're followed by `:`.\n // Walk over the identifier; if the next non-whitespace char is `:`,\n // accept it as a key. Otherwise the identifier is a free reference\n // and the expression isn't pure.\n if (/[A-Za-z_$]/.test(ch)) {\n let end = i + 1\n while (end < len && isIdContChar(text[end] as string)) end++\n let after = end\n while (after < len && /\\s/.test(text[after] as string)) after++\n if (text[after] === ':') {\n // unquoted property key — fine\n i = end\n continue\n }\n return false\n }\n\n // Anything else (operators, parens for function calls, etc.) → not pure\n return false\n }\n\n return true\n}\n\nfunction isIdContChar(c: string): boolean {\n return /[A-Za-z0-9_$]/.test(c)\n}\n\n/**\n * Strip TypeScript type-only suffixes (`as const`, `as SomeType`,\n * `satisfies SomeType`) from a literal expression so the generated\n * JS module is syntactically valid.\n *\n * The route file is TypeScript so authors freely write\n * `export const renderMode = 'ssg' as const` — but the generated\n * `virtual:zero/routes` module is JavaScript and can't keep the cast.\n * Strip from the rightmost top-level `as` or `satisfies` keyword.\n */\nexport function stripTypeAssertions(literal: string): string {\n let result = literal.trim()\n\n // Walk from the right at depth 0, find the LAST occurrence of\n // ` as ` or ` satisfies ` and cut everything to the right of it.\n // We use a depth-aware right-to-left scan because the literal can\n // contain `as`/`satisfies` inside nested objects (e.g. a string\n // value `'satisfies the schema'` should be left untouched).\n let depth = 0\n for (let i = result.length - 1; i > 0; i--) {\n const ch = result[i] as string\n if (ch === ')' || ch === ']' || ch === '}') depth++\n else if (ch === '(' || ch === '[' || ch === '{') depth--\n\n if (depth !== 0) continue\n\n // Check for ` as ` boundary\n if (\n i >= 4 &&\n result[i - 3] === ' ' &&\n result[i - 2] === 'a' &&\n result[i - 1] === 's' &&\n result[i] === ' '\n ) {\n result = result.slice(0, i - 3).trim()\n i = result.length\n depth = 0\n continue\n }\n // Check for ` satisfies ` boundary\n if (\n i >= 11 &&\n result.slice(i - 10, i + 1) === ' satisfies '\n ) {\n result = result.slice(0, i - 10).trim()\n i = result.length\n depth = 0\n continue\n }\n }\n\n return result\n}\n\n/**\n * Lightweight tokenizer for the export forms detectRouteExports cares about.\n * Returns an array of either:\n * • `{ kind: 'declaration', name }` — `export const NAME = …`\n * • `{ kind: 'list', names }` — `export { NAME, other as NAME2 }`\n *\n * Only top-level statements (brace depth 0) are considered. String literals,\n * template literals, and comments are skipped so their contents can't trigger\n * false matches.\n */\ntype ExportToken =\n | { kind: 'declaration'; name: string }\n | { kind: 'list'; names: string[] }\n\nfunction scanTopLevelExportTokens(source: string): ExportToken[] {\n const tokens: ExportToken[] = []\n const len = source.length\n let i = 0\n let depth = 0 // brace depth — we only care about top-level (depth 0)\n\n // Identifier characters used to skip past names and to validate that\n // a match isn't a substring of a longer identifier.\n const isIdStart = (c: string) => /[A-Za-z_$]/.test(c)\n const isIdCont = (c: string) => /[A-Za-z0-9_$]/.test(c)\n\n // Read an identifier starting at position p; returns [name, nextPos] or null.\n const readIdentifier = (p: number): [string, number] | null => {\n if (p >= len || !isIdStart(source[p] as string)) return null\n let end = p + 1\n while (end < len && isIdCont(source[end] as string)) end++\n return [source.slice(p, end), end]\n }\n\n // Skip whitespace including newlines.\n const skipWs = (p: number): number => {\n while (p < len && /\\s/.test(source[p] as string)) p++\n return p\n }\n\n // Match the literal `keyword` at position p, requiring an identifier\n // boundary on both sides. Returns nextPos or -1.\n const matchKeyword = (p: number, keyword: string): number => {\n if (source.slice(p, p + keyword.length) !== keyword) return -1\n const after = p + keyword.length\n if (after < len && isIdCont(source[after] as string)) return -1\n if (p > 0 && isIdCont(source[p - 1] as string)) return -1\n return after\n }\n\n while (i < len) {\n const ch = source[i] as string\n const next = source[i + 1] ?? ''\n\n // ── Comments ──────────────────────────────────────────────────────\n if (ch === '/' && next === '/') {\n // Line comment — skip to newline\n while (i < len && source[i] !== '\\n') i++\n continue\n }\n if (ch === '/' && next === '*') {\n // Block comment — skip to closing */\n i += 2\n while (i < len - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++\n i += 2\n continue\n }\n\n // ── String / template literals ────────────────────────────────────\n if (ch === '\"' || ch === \"'\") {\n const quote = ch\n i++\n while (i < len && source[i] !== quote) {\n if (source[i] === '\\\\') i += 2\n else i++\n }\n i++\n continue\n }\n if (ch === '`') {\n // Template literal — skip to closing backtick, handling ${...} blocks\n i++\n while (i < len && source[i] !== '`') {\n if (source[i] === '\\\\') {\n i += 2\n continue\n }\n if (source[i] === '$' && source[i + 1] === '{') {\n // Skip a balanced ${ ... } expression\n i += 2\n let exprDepth = 1\n while (i < len && exprDepth > 0) {\n const c = source[i] as string\n if (c === '{') exprDepth++\n else if (c === '}') exprDepth--\n if (exprDepth === 0) {\n i++\n break\n }\n i++\n }\n continue\n }\n i++\n }\n i++\n continue\n }\n\n // ── Brace depth tracking ──────────────────────────────────────────\n if (ch === '{') {\n depth++\n i++\n continue\n }\n if (ch === '}') {\n depth--\n i++\n continue\n }\n\n // ── `export …` at top level ──────────────────────────────────────\n if (depth === 0 && ch === 'e') {\n const afterExport = matchKeyword(i, 'export')\n if (afterExport > 0) {\n // Found `export` token at top level. Look at what follows.\n let p = skipWs(afterExport)\n\n // `export default …` — not a named export we care about\n const afterDefault = matchKeyword(p, 'default')\n if (afterDefault > 0) {\n i = afterDefault\n continue\n }\n\n // `export { … }` (export list, possibly followed by `from '…'`)\n if (source[p] === '{') {\n p++\n const names: string[] = []\n while (p < len && source[p] !== '}') {\n p = skipWs(p)\n if (source[p] === '}') break\n const id = readIdentifier(p)\n if (!id) {\n p++\n continue\n }\n const [first, afterFirst] = id\n // `localName as exportedName` — the EXPORTED name is what counts\n let exportedName = first\n const afterFirstWs = skipWs(afterFirst)\n const afterAs = matchKeyword(afterFirstWs, 'as')\n if (afterAs > 0) {\n const aliasStart = skipWs(afterAs)\n const alias = readIdentifier(aliasStart)\n if (alias) {\n exportedName = alias[0]\n p = alias[1]\n } else {\n p = afterFirst\n }\n } else {\n p = afterFirst\n }\n names.push(exportedName)\n p = skipWs(p)\n if (source[p] === ',') p++\n }\n tokens.push({ kind: 'list', names })\n i = p + 1 // past closing brace\n continue\n }\n\n // `export async function NAME …`\n const afterAsync = matchKeyword(p, 'async')\n if (afterAsync > 0) p = skipWs(afterAsync)\n\n // `export const | let | var | function NAME …`\n let foundDecl = false\n for (const kw of ['const', 'let', 'var', 'function'] as const) {\n const afterKw = matchKeyword(p, kw)\n if (afterKw > 0) {\n const nameStart = skipWs(afterKw)\n const id = readIdentifier(nameStart)\n if (id) {\n tokens.push({ kind: 'declaration', name: id[0] })\n i = id[1] // advance past the identifier we just consumed\n foundDecl = true\n break\n }\n }\n }\n // If we couldn't recognize a declaration form, advance past `export`\n // so the outer loop doesn't re-match the same token forever.\n if (!foundDecl) i = afterExport\n continue\n }\n }\n\n i++\n }\n\n return tokens\n}\n\n/** All-false exports record. Used when source detection fails. */\nconst EMPTY_EXPORTS: RouteFileExports = {\n hasLoader: false,\n hasGuard: false,\n hasMeta: false,\n hasRenderMode: false,\n hasError: false,\n hasMiddleware: false,\n}\n\n/**\n * True if a route file declares ANY metadata export.\n * Used by the code generator to decide whether to emit a static\n * `import * as mod` (for metadata access) instead of lazy().\n */\nexport function hasAnyMetaExport(exports: RouteFileExports): boolean {\n return (\n exports.hasLoader ||\n exports.hasGuard ||\n exports.hasMeta ||\n exports.hasRenderMode ||\n exports.hasError ||\n exports.hasMiddleware\n )\n}\n\n/**\n * Parse a set of file paths (relative to routes dir) into FileRoute objects.\n *\n * @param files Array of file paths like [\"index.tsx\", \"users/[id].tsx\"]\n * @param defaultMode Default rendering mode from config\n * @param exportsMap Optional map of filePath → detected exports. When\n * provided, the resulting FileRoute objects carry export info that the\n * code generator uses to optimize imports (skip metadata namespace\n * imports for routes that only export `default`).\n */\nexport function parseFileRoutes(\n files: string[],\n defaultMode: RenderMode = 'ssr',\n exportsMap?: Map<string, RouteFileExports>,\n): FileRoute[] {\n return files\n .filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext)))\n .map((filePath) => {\n const route = parseFilePath(filePath, defaultMode)\n const exp = exportsMap?.get(filePath)\n return exp ? { ...route, exports: exp } : route\n })\n .sort(sortRoutes)\n}\n\nfunction parseFilePath(filePath: string, defaultMode: RenderMode): FileRoute {\n // Remove extension\n let route = filePath\n for (const ext of ROUTE_EXTENSIONS) {\n if (route.endsWith(ext)) {\n route = route.slice(0, -ext.length)\n break\n }\n }\n\n const fileName = getFileName(route)\n const isLayout = fileName === '_layout'\n const isError = fileName === '_error'\n const isLoading = fileName === '_loading'\n const isNotFound = fileName === '_404' || fileName === '_not-found'\n const isCatchAll = route.includes('[...')\n\n // Get directory path (strip groups for consistent grouping)\n const parts = route.split('/')\n parts.pop() // remove filename\n const dirPath = parts.filter((s) => !(s.startsWith('(') && s.endsWith(')'))).join('/')\n\n // Convert file path to URL pattern\n const urlPath = filePathToUrlPath(route)\n const depth = urlPath === '/' ? 0 : urlPath.split('/').filter(Boolean).length\n\n return {\n filePath,\n urlPath,\n dirPath,\n depth,\n isLayout,\n isError,\n isLoading,\n isNotFound,\n isCatchAll,\n renderMode: defaultMode,\n }\n}\n\n/**\n * Convert a file path (without extension) to a URL path pattern.\n *\n * Examples:\n * \"index\" → \"/\"\n * \"about\" → \"/about\"\n * \"users/index\" → \"/users\"\n * \"users/[id]\" → \"/users/:id\"\n * \"blog/[...slug]\" → \"/blog/:slug*\"\n * \"(auth)/login\" → \"/login\" (group stripped)\n * \"_layout\" → \"/\" (layout marker)\n */\nexport function filePathToUrlPath(filePath: string): string {\n const segments = filePath.split('/')\n const urlSegments: string[] = []\n\n for (const seg of segments) {\n // Skip route groups \"(name)\"\n if (seg.startsWith('(') && seg.endsWith(')')) continue\n\n // Skip special files\n if (seg === '_layout' || seg === '_error' || seg === '_loading' || seg === '_404' || seg === '_not-found') continue\n\n // \"index\" maps to the parent path\n if (seg === 'index') continue\n\n // Catch-all: [...param] → :param*\n const catchAll = seg.match(/^\\[\\.\\.\\.(\\w+)\\]$/)\n if (catchAll) {\n urlSegments.push(`:${catchAll[1]}*`)\n continue\n }\n\n // Dynamic: [param] → :param\n const dynamic = seg.match(/^\\[(\\w+)\\]$/)\n if (dynamic) {\n urlSegments.push(`:${dynamic[1]}`)\n continue\n }\n\n urlSegments.push(seg)\n }\n\n const path = `/${urlSegments.join('/')}`\n return path || '/'\n}\n\n/** Sort routes: static before dynamic, catch-all last. */\nfunction sortRoutes(a: FileRoute, b: FileRoute): number {\n // Catch-all routes go last\n if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1\n // Layouts go first within same depth\n if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1\n // Static segments before dynamic\n const aDynamic = a.urlPath.includes(':')\n const bDynamic = b.urlPath.includes(':')\n if (aDynamic !== bDynamic) return aDynamic ? 1 : -1\n // Alphabetical\n return a.urlPath.localeCompare(b.urlPath)\n}\n\nfunction getFileName(filePath: string): string {\n const parts = filePath.split('/')\n return parts[parts.length - 1] ?? ''\n}\n\n// ─── Route generation (for Vite plugin) ─────────────────────────────────────\n\n/** Internal tree node for building nested route structures. */\ninterface RouteNode {\n /** Page routes at this directory level. */\n pages: FileRoute[]\n /** Layout file for this directory (if any). */\n layout?: FileRoute\n /** Error boundary file (if any). */\n error?: FileRoute\n /** Loading fallback file (if any). */\n loading?: FileRoute\n /** Not-found (404) file (if any). */\n notFound?: FileRoute\n /** Child directories. */\n children: Map<string, RouteNode>\n}\n\n/**\n * Group flat file routes into a directory tree.\n */\nfunction getOrCreateChild(node: RouteNode, segment: string): RouteNode {\n let child = node.children.get(segment)\n if (!child) {\n child = { pages: [], children: new Map() }\n node.children.set(segment, child)\n }\n return child\n}\n\nfunction resolveNode(root: RouteNode, dirPath: string): RouteNode {\n let node = root\n if (dirPath) {\n for (const segment of dirPath.split('/')) {\n node = getOrCreateChild(node, segment)\n }\n }\n return node\n}\n\nfunction placeRoute(node: RouteNode, route: FileRoute) {\n if (route.isLayout) node.layout = route\n else if (route.isError) node.error = route\n else if (route.isLoading) node.loading = route\n else if (route.isNotFound) node.notFound = route\n else node.pages.push(route)\n}\n\nfunction buildRouteTree(routes: FileRoute[]): RouteNode {\n const root: RouteNode = { pages: [], children: new Map() }\n for (const route of routes) {\n placeRoute(resolveNode(root, route.dirPath), route)\n }\n return root\n}\n\n/**\n * Generate a virtual module that exports a nested route tree.\n * Wires up layouts as parent routes with children, loaders, guards,\n * error/loading components, middleware, and meta from route module exports.\n */\nexport interface GenerateRouteModuleOptions {\n /**\n * When true, skip lazy() for route components and use static imports.\n * Use for SSG/prerender mode where all routes are rendered at build time\n * and code splitting provides no benefit at request time.\n */\n staticImports?: boolean\n}\n\nexport function generateRouteModule(\n files: string[],\n routesDir: string,\n options?: GenerateRouteModuleOptions,\n): string {\n // Synchronously read each route file's source and detect its optional\n // metadata exports. This produces the optimal shape every time:\n // • `lazy(() => import(...))` for routes with no metadata\n // • Direct `mod.loader`/`.guard`/`.meta` for routes with metadata\n // • Zero `IMPORT_IS_UNDEFINED` and zero `INEFFECTIVE_DYNAMIC_IMPORT` warnings\n //\n // If a file can't be read (e.g. caller passing synthetic paths), the\n // FileRoute gets EMPTY_EXPORTS — the generator emits the same lazy()\n // shape used for routes that genuinely have no metadata. Callers that\n // need metadata wiring with synthetic paths should use\n // `generateRouteModuleFromRoutes()` directly with explicit exports.\n const exportsMap = new Map<string, RouteFileExports>()\n for (const filePath of files) {\n if (!ROUTE_EXTENSIONS.some((ext) => filePath.endsWith(ext))) continue\n try {\n const source = readFileSync(join(routesDir, filePath), 'utf-8')\n exportsMap.set(filePath, detectRouteExports(source))\n } catch {\n exportsMap.set(filePath, EMPTY_EXPORTS)\n }\n }\n return generateRouteModuleFromRoutes(\n parseFileRoutes(files, undefined, exportsMap),\n routesDir,\n options,\n )\n}\n\n/**\n * Lower-level entry point that accepts pre-parsed FileRoute[] (so callers\n * can attach `.exports` info from source detection). Use this when you've\n * already read the files and want optimal output.\n */\nexport function generateRouteModuleFromRoutes(\n routes: FileRoute[],\n routesDir: string,\n options?: GenerateRouteModuleOptions,\n): string {\n const tree = buildRouteTree(routes)\n const imports: string[] = []\n let importCounter = 0\n const useStaticOnly = options?.staticImports ?? false\n\n // Track whether we need lazy() at all (omitted in static-only mode and\n // when there are no routes that use it).\n let needsLazyImport = false\n\n function nextImport(filePath: string, exportName = 'default'): string {\n const name = `_${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n if (exportName === 'default') {\n imports.push(`import ${name} from \"${fullPath}\"`)\n } else {\n imports.push(`import { ${exportName} as ${name} } from \"${fullPath}\"`)\n }\n return name\n }\n\n function nextModuleImport(filePath: string): string {\n const name = `_m${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n imports.push(`import * as ${name} from \"${fullPath}\"`)\n return name\n }\n\n function nextLazy(filePath: string, loadingName?: string, errorName?: string): string {\n const name = `_${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n needsLazyImport = true\n const opts: string[] = []\n if (loadingName) opts.push(`loading: ${loadingName}`)\n if (errorName) opts.push(`error: ${errorName}`)\n const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''\n imports.push(`const ${name} = lazy(() => import(\"${fullPath}\")${optsStr})`)\n return name\n }\n\n /**\n * Emit a `meta: { ... }` prop using the literal initializers captured\n * from the route file source. Either or both of `metaLiteral` and\n * `renderModeLiteral` may be present; the result is always a single\n * inline object literal.\n */\n function emitInlineMeta(exp: RouteFileExports, props: string[], indent: string): void {\n if (!exp.hasMeta && !exp.hasRenderMode) return\n const parts: string[] = []\n if (exp.hasMeta && exp.metaLiteral !== undefined) {\n parts.push(`...(${exp.metaLiteral})`)\n }\n if (exp.hasRenderMode && exp.renderModeLiteral !== undefined) {\n parts.push(`renderMode: ${exp.renderModeLiteral}`)\n }\n if (parts.length > 0) {\n props.push(`${indent} meta: { ${parts.join(', ')} }`)\n }\n }\n\n function generatePageRoute(\n page: FileRoute,\n indent: string,\n loadingName: string | undefined,\n errorName: string | undefined,\n notFoundName: string | undefined,\n ): string {\n const exp = page.exports ?? EMPTY_EXPORTS\n const props: string[] = [`${indent} path: ${JSON.stringify(page.urlPath)}`]\n const hasMeta = hasAnyMetaExport(exp)\n\n if (useStaticOnly) {\n // SSG / static mode: bundle everything synchronously, no lazy().\n if (hasMeta) {\n // Single namespace import covers component AND metadata.\n const mod = nextModuleImport(page.filePath)\n props.push(`${indent} component: ${mod}.default`)\n if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)\n if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)\n if (exp.hasMeta || exp.hasRenderMode) {\n const metaParts: string[] = []\n if (exp.hasMeta) metaParts.push(`...${mod}.meta`)\n if (exp.hasRenderMode) metaParts.push(`renderMode: ${mod}.renderMode`)\n props.push(`${indent} meta: { ${metaParts.join(', ')} }`)\n }\n if (errorName) {\n const errorRef = exp.hasError ? `${mod}.error || ${errorName}` : errorName\n props.push(`${indent} errorComponent: ${errorRef}`)\n }\n } else {\n // No metadata — single static default import.\n const comp = nextImport(page.filePath, 'default')\n props.push(`${indent} component: ${comp}`)\n if (errorName) props.push(`${indent} errorComponent: ${errorName}`)\n }\n } else {\n // SSR/SPA mode: prefer lazy() for code splitting wherever possible.\n //\n // Three cases, in order of preference:\n // 1. metaLiteral / renderModeLiteral are extracted AND there's\n // no loader/guard/error/middleware → fully lazy. Component\n // is `lazy()`'d, metadata is inlined as a literal in the\n // generated module. The route file's entire dependency\n // graph chunks separately.\n // 2. metaLiteral / renderModeLiteral are extracted but a\n // function-shaped export (loader/guard/error/middleware)\n // is also present → mixed: component still lazy, metadata\n // inlined, function exports come from a static `import * as`.\n // The static import shares the chunk with the lazy chunk\n // via Rolldown's deduplication.\n // 3. No literal extraction succeeded → fall back to the previous\n // pessimistic shape: single namespace import covering both\n // component and metadata.\n const inlineableMeta =\n (!exp.hasMeta || exp.metaLiteral !== undefined) &&\n (!exp.hasRenderMode || exp.renderModeLiteral !== undefined)\n const needsFunctionExports = exp.hasLoader || exp.hasGuard || exp.hasError\n\n if (hasMeta && inlineableMeta && !needsFunctionExports) {\n // Optimal path — component lazy, metadata inlined.\n const comp = nextLazy(page.filePath, loadingName, errorName)\n props.push(`${indent} component: ${comp}`)\n emitInlineMeta(exp, props, indent)\n if (errorName) props.push(`${indent} errorComponent: ${errorName}`)\n } else if (hasMeta && inlineableMeta) {\n // Mixed — metadata is inlinable but the route also exports\n // function-shaped values (loader/guard/error). Wrap them as\n // lazy thunks so the route file's full dependency tree stays\n // out of the main bundle: each thunk calls the same dynamic\n // import as the lazy() component, and Rolldown deduplicates\n // them into one chunk. Inlining the literal metadata is what\n // makes this safe — without it, the meta access would force\n // a static import that would collide with the dynamic one.\n const comp = nextLazy(page.filePath, loadingName, errorName)\n const fullPath = `${routesDir}/${page.filePath}`\n props.push(`${indent} component: ${comp}`)\n if (exp.hasLoader) {\n props.push(\n `${indent} loader: (ctx) => import(\"${fullPath}\").then((m) => m.loader(ctx))`,\n )\n }\n if (exp.hasGuard) {\n props.push(\n `${indent} beforeEnter: (to, from) => import(\"${fullPath}\").then((m) => m.guard(to, from))`,\n )\n }\n emitInlineMeta(exp, props, indent)\n if (errorName) {\n // For error components we can't easily await — pass the lazy\n // thunk through `lazy()` so the router resolves it like any\n // other lazy component when an error fires.\n const errorRef = exp.hasError\n ? `lazy(() => import(\"${fullPath}\").then((m) => ({ default: m.error })))`\n : errorName\n if (exp.hasError) needsLazyImport = true\n props.push(`${indent} errorComponent: ${errorRef}`)\n }\n } else if (hasMeta) {\n // Fallback — metadata couldn't be extracted as a literal (e.g.\n // computed values, references to other declarations). Fall\n // back to the pessimistic single-namespace-import shape.\n const mod = nextModuleImport(page.filePath)\n props.push(`${indent} component: ${mod}.default`)\n if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)\n if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)\n if (exp.hasMeta || exp.hasRenderMode) {\n const metaParts: string[] = []\n if (exp.hasMeta) metaParts.push(`...${mod}.meta`)\n if (exp.hasRenderMode) metaParts.push(`renderMode: ${mod}.renderMode`)\n props.push(`${indent} meta: { ${metaParts.join(', ')} }`)\n }\n if (errorName) {\n const errorRef = exp.hasError ? `${mod}.error || ${errorName}` : errorName\n props.push(`${indent} errorComponent: ${errorRef}`)\n }\n } else {\n // No metadata at all — pure lazy() for code splitting.\n const comp = nextLazy(page.filePath, loadingName, errorName)\n props.push(`${indent} component: ${comp}`)\n if (errorName) props.push(`${indent} errorComponent: ${errorName}`)\n }\n }\n\n if (notFoundName) {\n props.push(`${indent} notFoundComponent: ${notFoundName}`)\n }\n\n return `${indent}{\\n${props.join(',\\n')}\\n${indent}}`\n }\n\n function wrapWithLayout(\n node: RouteNode,\n children: string[],\n indent: string,\n errorName: string | undefined,\n notFoundName: string | undefined,\n ): string {\n const layout = node.layout as FileRoute\n const exp = layout.exports ?? EMPTY_EXPORTS\n const hasMeta = hasAnyMetaExport(exp)\n\n // Decide between two import shapes:\n // • Layout HAS metadata exports → single `import * as mod` for both\n // the layout component (mod.layout) AND metadata. One import.\n // • Layout has NO metadata → just `import { layout as _N }`. One import.\n let layoutComp: string\n let layoutMod: string | undefined\n\n if (hasMeta) {\n // Single namespace import covers both component and metadata.\n layoutMod = nextModuleImport(layout.filePath)\n layoutComp = `${layoutMod}.layout`\n } else {\n // No metadata — named `layout` import is enough.\n layoutComp = nextImport(layout.filePath, 'layout')\n }\n\n const props: string[] = [\n `${indent}path: ${JSON.stringify(layout.urlPath)}`,\n `${indent}component: ${layoutComp}`,\n ]\n\n if (layoutMod !== undefined) {\n if (exp.hasLoader) props.push(`${indent}loader: ${layoutMod}.loader`)\n if (exp.hasGuard) props.push(`${indent}beforeEnter: ${layoutMod}.guard`)\n if (exp.hasMeta || exp.hasRenderMode) {\n const metaParts: string[] = []\n if (exp.hasMeta) metaParts.push(`...${layoutMod}.meta`)\n if (exp.hasRenderMode) metaParts.push(`renderMode: ${layoutMod}.renderMode`)\n props.push(`${indent}meta: { ${metaParts.join(', ')} }`)\n }\n }\n\n if (errorName) {\n props.push(`${indent}errorComponent: ${errorName}`)\n }\n if (notFoundName) {\n props.push(`${indent}notFoundComponent: ${notFoundName}`)\n }\n if (children.length > 0) {\n props.push(`${indent}children: [\\n${children.join(',\\n')}\\n${indent}]`)\n }\n\n return `${indent}{\\n${props.map((p) => ` ${p}`).join(',\\n')}\\n${indent}}`\n }\n\n /**\n * Generate route definitions for a tree node.\n */\n function generateNode(node: RouteNode, depth: number): string[] {\n const indent = ' '.repeat(depth + 1)\n\n const errorName = node.error ? nextImport(node.error.filePath) : undefined\n const loadingName = node.loading ? nextImport(node.loading.filePath) : undefined\n const notFoundName = node.notFound ? nextImport(node.notFound.filePath) : undefined\n\n const childRouteDefs: string[] = []\n for (const [, childNode] of node.children) {\n childRouteDefs.push(...generateNode(childNode, depth + 1))\n }\n\n const pageRouteDefs = node.pages.map((page) =>\n generatePageRoute(page, indent, loadingName, errorName, notFoundName),\n )\n\n const allChildren = [...pageRouteDefs, ...childRouteDefs]\n\n if (node.layout) {\n return [wrapWithLayout(node, allChildren, indent, errorName, notFoundName)]\n }\n return allChildren\n }\n\n const routeDefs = generateNode(tree, 0)\n\n const lines: string[] = []\n if (needsLazyImport) lines.push(`import { lazy } from \"@pyreon/router\"`, '')\n lines.push(...imports, '')\n\n lines.push(\n // Filter out undefined properties at runtime\n `function clean(routes) {`,\n ` return routes.map(r => {`,\n ` const c = {}`,\n ` for (const k in r) if (r[k] !== undefined) c[k] = r[k]`,\n ` if (c.children) c.children = clean(c.children)`,\n ` return c`,\n ` })`,\n `}`,\n '',\n `export const routes = clean([`,\n routeDefs.join(',\\n'),\n `])`,\n )\n\n return lines.join('\\n')\n}\n\n/**\n * Generate a virtual module that maps URL patterns to their middleware exports.\n * Used by the server entry to dispatch per-route middleware.\n */\nexport function generateMiddlewareModule(files: string[], routesDir: string): string {\n const routes = parseFileRoutes(files)\n const imports: string[] = []\n const entries: string[] = []\n let counter = 0\n\n for (const route of routes) {\n if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue\n const name = `_mw${counter++}`\n const fullPath = `${routesDir}/${route.filePath}`\n imports.push(`import { middleware as ${name} } from \"${fullPath}\"`)\n entries.push(` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`)\n }\n\n return [\n ...imports,\n '',\n `export const routeMiddleware = [`,\n entries.join(',\\n'),\n `].filter(e => e.middleware)`,\n ].join('\\n')\n}\n\n/**\n * Scan a directory for route files.\n * Returns paths relative to the routes directory.\n */\nexport async function scanRouteFiles(routesDir: string): Promise<string[]> {\n const { readdir } = await import('node:fs/promises')\n const { relative } = await import('node:path')\n\n const files: string[] = []\n\n async function walk(dir: string) {\n const entries = await readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n const fullPath = join(dir, entry.name)\n if (entry.isDirectory()) {\n await walk(fullPath)\n } else if (ROUTE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {\n files.push(relative(routesDir, fullPath))\n }\n }\n }\n\n await walk(routesDir)\n return files\n}\n\n/**\n * Scan route files AND read each one to detect optional metadata exports\n * (loader, guard, meta, renderMode, error, middleware).\n *\n * Returns FileRoute[] with `.exports` populated, ready to feed into\n * `generateRouteModuleFromRoutes()` for optimal output:\n * • lazy() for components without metadata (best code splitting)\n * • Direct property access for components with metadata (no _pick)\n * • No spurious IMPORT_IS_UNDEFINED warnings\n */\nexport async function scanRouteFilesWithExports(\n routesDir: string,\n defaultMode: RenderMode = 'ssr',\n): Promise<FileRoute[]> {\n const { readFile } = await import('node:fs/promises')\n\n const files = await scanRouteFiles(routesDir)\n const exportsMap = new Map<string, RouteFileExports>()\n\n await Promise.all(\n files.map(async (filePath) => {\n try {\n const source = await readFile(join(routesDir, filePath), 'utf-8')\n exportsMap.set(filePath, detectRouteExports(source))\n } catch {\n // File can't be read — generator treats this as no metadata\n // and emits the optimal lazy() shape.\n exportsMap.set(filePath, EMPTY_EXPORTS)\n }\n }),\n )\n\n return parseFileRoutes(files, defaultMode, exportsMap)\n}\n","/**\n * AI integration utilities for Zero.\n *\n * - llms.txt auto-generation from routes and API routes\n * - JSON-LD auto-inference from route meta + Meta props\n * - AI plugin manifest (/.well-known/ai-plugin.json) from API routes\n *\n * @example\n * ```ts\n * // vite.config.ts\n * import { aiPlugin } from \"@pyreon/zero/ai\"\n *\n * export default {\n * plugins: [\n * aiPlugin({\n * name: \"My App\",\n * origin: \"https://example.com\",\n * description: \"A modern web application\",\n * }),\n * ],\n * }\n * ```\n */\nimport type { Plugin } from 'vite'\nimport { parseFileRoutes } from './fs-router'\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport interface AiPluginConfig {\n /** App/API name. */\n name: string\n /** App description for AI agents. */\n description: string\n /** Base URL. e.g. \"https://example.com\" */\n origin: string\n /** Contact email (required by OpenAI plugin spec). */\n contactEmail?: string\n /** Legal info URL. */\n legalUrl?: string\n /** Logo URL for the plugin. */\n logoUrl?: string\n /** Routes directory relative to project root. Default: \"src/routes\" */\n routesDir?: string\n /** API routes directory relative to project root. Default: \"src/api\" */\n apiDir?: string\n /**\n * API route descriptions — map of pattern to description.\n * Used for llms.txt and ai-plugin.json.\n *\n * @example\n * ```ts\n * apiDescriptions: {\n * \"GET /api/posts\": \"List all blog posts, supports ?page=N&limit=N\",\n * \"GET /api/posts/:id\": \"Get a single post by ID\",\n * \"POST /api/posts\": \"Create a new post (requires auth)\",\n * }\n * ```\n */\n apiDescriptions?: Record<string, string>\n /**\n * Page descriptions — map of URL path to description.\n * Used for llms.txt. Falls back to route meta.title/description.\n */\n pageDescriptions?: Record<string, string>\n /**\n * Additional content to append to llms.txt.\n * Useful for authentication instructions, rate limits, etc.\n */\n llmsExtra?: string\n}\n\n// ─── llms.txt generation ────────────────────────────────────────────────────\n\n/**\n * Generate llms.txt content from route files and config.\n *\n * Format follows the llms.txt proposal:\n * ```\n * # {name}\n * > {description}\n *\n * ## Pages\n * - [/about](/about): About page\n *\n * ## API\n * - GET /api/posts: List posts\n * ```\n *\n * @internal Exported for testing.\n */\nexport function generateLlmsTxt(\n routeFiles: string[],\n apiFiles: string[],\n config: AiPluginConfig,\n): string {\n const lines: string[] = []\n\n // Header\n lines.push(`# ${config.name}`)\n lines.push(`> ${config.description}`)\n lines.push('')\n\n // Pages section\n const routes = parseFileRoutes(routeFiles)\n const pages = routes.filter(\n (r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound\n && !r.isCatchAll && !r.urlPath.includes(':'),\n )\n\n if (pages.length > 0) {\n lines.push('## Pages')\n lines.push('')\n for (const page of pages) {\n const desc = config.pageDescriptions?.[page.urlPath]\n const url = `${config.origin}${page.urlPath === '/' ? '' : page.urlPath}`\n if (desc) {\n lines.push(`- [${page.urlPath}](${url}): ${desc}`)\n } else {\n lines.push(`- [${page.urlPath}](${url})`)\n }\n }\n lines.push('')\n }\n\n // Dynamic routes (documented separately — AI needs to know about params)\n const dynamicRoutes = routes.filter(\n (r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound\n && (r.urlPath.includes(':') || r.isCatchAll),\n )\n if (dynamicRoutes.length > 0) {\n lines.push('## Dynamic Pages')\n lines.push('')\n for (const route of dynamicRoutes) {\n const desc = config.pageDescriptions?.[route.urlPath]\n if (desc) {\n lines.push(`- ${route.urlPath}: ${desc}`)\n } else {\n lines.push(`- ${route.urlPath}`)\n }\n }\n lines.push('')\n }\n\n // API section\n const apiPatterns = parseApiFiles(apiFiles)\n if (apiPatterns.length > 0 || config.apiDescriptions) {\n lines.push('## API Endpoints')\n lines.push('')\n\n // From apiDescriptions (most detailed — user-provided)\n if (config.apiDescriptions) {\n for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {\n lines.push(`- ${endpoint}: ${desc}`)\n }\n }\n\n // From auto-discovered API files (only those not already described)\n const describedPatterns = new Set(\n Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\s+/, '')),\n )\n for (const pattern of apiPatterns) {\n if (!describedPatterns.has(pattern)) {\n lines.push(`- ${pattern}`)\n }\n }\n lines.push('')\n }\n\n // Extra content\n if (config.llmsExtra) {\n lines.push(config.llmsExtra)\n lines.push('')\n }\n\n return lines.join('\\n')\n}\n\n/**\n * Generate llms-full.txt — expanded version with more detail.\n * Includes all route metadata and API descriptions.\n *\n * @internal Exported for testing.\n */\nexport function generateLlmsFullTxt(\n routeFiles: string[],\n apiFiles: string[],\n config: AiPluginConfig,\n): string {\n const lines: string[] = []\n\n lines.push(`# ${config.name} — Full Reference`)\n lines.push(`> ${config.description}`)\n lines.push('')\n lines.push(`Base URL: ${config.origin}`)\n lines.push('')\n\n // All pages with details\n const routes = parseFileRoutes(routeFiles)\n const pages = routes.filter(\n (r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound,\n )\n\n if (pages.length > 0) {\n lines.push('## All Routes')\n lines.push('')\n for (const page of pages) {\n const desc = config.pageDescriptions?.[page.urlPath] ?? ''\n const dynamic = page.urlPath.includes(':') ? ' (dynamic)' : ''\n const catchAll = page.isCatchAll ? ' (catch-all)' : ''\n lines.push(`### ${page.urlPath}${dynamic}${catchAll}`)\n if (desc) lines.push(desc)\n lines.push(`- File: ${page.filePath}`)\n lines.push(`- Render mode: ${page.renderMode}`)\n lines.push('')\n }\n }\n\n // API endpoints with full detail\n if (config.apiDescriptions) {\n lines.push('## API Reference')\n lines.push('')\n for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {\n lines.push(`### ${endpoint}`)\n lines.push(desc)\n lines.push('')\n }\n }\n\n if (config.llmsExtra) {\n lines.push('## Additional Information')\n lines.push('')\n lines.push(config.llmsExtra)\n lines.push('')\n }\n\n return lines.join('\\n')\n}\n\n// ─── JSON-LD auto-inference ─────────────────────────────────────────────────\n\nexport interface InferJsonLdOptions {\n /** Page URL. */\n url: string\n /** Page title. */\n title?: string\n /** Page description. */\n description?: string\n /** Page image. */\n image?: string\n /** Site name. */\n siteName?: string\n /** Page type hint. */\n type?: 'website' | 'article' | 'product' | 'profile'\n /** Article metadata. */\n publishedTime?: string\n /** Article author. */\n author?: string\n /** Article tags. */\n tags?: string[]\n /** Breadcrumb path segments. */\n breadcrumbs?: Array<{ name: string; url: string }>\n}\n\n/**\n * Auto-infer JSON-LD structured data from page metadata.\n *\n * Returns an array of JSON-LD objects (multiple schemas can apply to one page).\n * For example, an article page gets both `Article` and `BreadcrumbList`.\n *\n * @example\n * ```tsx\n * const schemas = inferJsonLd({\n * url: \"https://example.com/blog/my-post\",\n * title: \"My Post\",\n * description: \"A great article\",\n * type: \"article\",\n * author: \"Vit Bokisch\",\n * publishedTime: \"2026-03-31\",\n * })\n * // → [Article schema, BreadcrumbList schema]\n * ```\n */\nexport function inferJsonLd(options: InferJsonLdOptions): Record<string, unknown>[] {\n const schemas: Record<string, unknown>[] = []\n\n // Base: WebPage or Article\n if (options.type === 'article') {\n const article: Record<string, unknown> = {\n '@context': 'https://schema.org',\n '@type': 'Article',\n headline: options.title,\n url: options.url,\n }\n if (options.description) article.description = options.description\n if (options.image) article.image = options.image\n if (options.publishedTime) article.datePublished = options.publishedTime\n if (options.author) {\n article.author = { '@type': 'Person', name: options.author }\n }\n if (options.tags && options.tags.length > 0) {\n article.keywords = options.tags.join(', ')\n }\n if (options.siteName) {\n article.publisher = { '@type': 'Organization', name: options.siteName }\n }\n schemas.push(article)\n } else if (options.type === 'product') {\n const product: Record<string, unknown> = {\n '@context': 'https://schema.org',\n '@type': 'Product',\n name: options.title,\n url: options.url,\n }\n if (options.description) product.description = options.description\n if (options.image) product.image = options.image\n schemas.push(product)\n } else {\n const webpage: Record<string, unknown> = {\n '@context': 'https://schema.org',\n '@type': 'WebPage',\n name: options.title,\n url: options.url,\n }\n if (options.description) webpage.description = options.description\n if (options.image) webpage.thumbnailUrl = options.image\n schemas.push(webpage)\n }\n\n // BreadcrumbList from URL path or explicit breadcrumbs\n if (options.breadcrumbs && options.breadcrumbs.length > 0) {\n schemas.push({\n '@context': 'https://schema.org',\n '@type': 'BreadcrumbList',\n itemListElement: options.breadcrumbs.map((bc, i) => ({\n '@type': 'ListItem',\n position: i + 1,\n name: bc.name,\n item: bc.url,\n })),\n })\n } else {\n // Auto-generate breadcrumbs from URL path\n const urlObj = safeParseUrl(options.url)\n if (urlObj) {\n const segments = urlObj.pathname.split('/').filter(Boolean)\n if (segments.length > 0) {\n const items = [\n { '@type': 'ListItem', position: 1, name: 'Home', item: urlObj.origin },\n ]\n let path = ''\n for (let i = 0; i < segments.length; i++) {\n path += `/${segments[i]}`\n items.push({\n '@type': 'ListItem',\n position: i + 2,\n name: capitalize(segments[i]!.replace(/-/g, ' ')),\n item: `${urlObj.origin}${path}`,\n })\n }\n schemas.push({\n '@context': 'https://schema.org',\n '@type': 'BreadcrumbList',\n itemListElement: items,\n })\n }\n }\n }\n\n return schemas\n}\n\n// ─── AI plugin manifest ─────────────────────────────────────────────────────\n\n/**\n * Generate an OpenAI-compatible AI plugin manifest.\n *\n * Follows the /.well-known/ai-plugin.json spec.\n *\n * @internal Exported for testing.\n */\nexport function generateAiPluginManifest(config: AiPluginConfig): Record<string, unknown> {\n return {\n schema_version: 'v1',\n name_for_human: config.name,\n name_for_model: config.name.toLowerCase().replace(/\\s+/g, '_').replace(/[^a-z0-9_]/g, ''),\n description_for_human: config.description,\n description_for_model: config.description,\n auth: { type: 'none' },\n api: {\n type: 'openapi',\n url: `${config.origin}/.well-known/openapi.yaml`,\n },\n logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,\n contact_email: config.contactEmail ?? '',\n legal_info_url: config.legalUrl ?? `${config.origin}/legal`,\n }\n}\n\n/**\n * Generate a minimal OpenAPI 3.0 spec from API route descriptions.\n *\n * @internal Exported for testing.\n */\nexport function generateOpenApiSpec(\n apiFiles: string[],\n config: AiPluginConfig,\n): Record<string, unknown> {\n const paths: Record<string, Record<string, unknown>> = {}\n\n // From user-provided descriptions\n if (config.apiDescriptions) {\n for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {\n const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\s+(.+)$/)\n if (match) {\n const method = match[1]!.toLowerCase()\n const path = match[2]!\n // Convert :param to {param} for OpenAPI\n const openApiPath = path.replace(/:(\\w+)/g, '{$1}')\n if (!paths[openApiPath]) paths[openApiPath] = {}\n paths[openApiPath][method] = {\n summary: desc,\n responses: { '200': { description: 'Success' } },\n }\n }\n }\n }\n\n // Auto-discovered API files (fill in gaps)\n for (const pattern of parseApiFiles(apiFiles)) {\n const openApiPath = pattern.replace(/:(\\w+)/g, '{$1}')\n if (!paths[openApiPath]) {\n paths[openApiPath] = {\n get: {\n summary: `${openApiPath} endpoint`,\n responses: { '200': { description: 'Success' } },\n },\n }\n }\n }\n\n return {\n openapi: '3.0.0',\n info: {\n title: config.name,\n description: config.description,\n version: '1.0.0',\n },\n servers: [{ url: config.origin }],\n paths,\n }\n}\n\n// ─── Vite plugin ────────────────────────────────────────────────────────────\n\n/**\n * AI integration Vite plugin.\n *\n * Generates at build time:\n * - `/llms.txt` — concise site summary for AI agents\n * - `/llms-full.txt` — detailed reference for AI agents\n * - `/.well-known/ai-plugin.json` — OpenAI plugin manifest\n * - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes\n *\n * In dev, serves these files via middleware.\n *\n * @example\n * ```ts\n * import { aiPlugin } from \"@pyreon/zero/ai\"\n *\n * export default {\n * plugins: [\n * aiPlugin({\n * name: \"My App\",\n * origin: \"https://example.com\",\n * description: \"A modern web application\",\n * apiDescriptions: {\n * \"GET /api/posts\": \"List blog posts\",\n * \"GET /api/posts/:id\": \"Get post by ID\",\n * },\n * }),\n * ],\n * }\n * ```\n */\nexport function aiPlugin(config: AiPluginConfig): Plugin {\n let root = ''\n let isBuild = false\n let routeFiles: string[] = []\n let apiFiles: string[] = []\n\n return {\n name: 'pyreon-zero-ai',\n enforce: 'post',\n\n configResolved(resolvedConfig) {\n root = resolvedConfig.root\n isBuild = resolvedConfig.command === 'build'\n },\n\n async buildStart() {\n // Scan for route and API files\n try {\n const { join } = await import('node:path')\n\n const routesDir = join(root, config.routesDir ?? 'src/routes')\n const apiDir = join(root, config.apiDir ?? 'src/api')\n\n routeFiles = await scanDir(routesDir, routesDir)\n apiFiles = await scanDir(apiDir, apiDir)\n } catch {\n // Directories may not exist\n }\n },\n\n configureServer(server) {\n server.middlewares.use(async (req, res, next) => {\n const url = req.url ?? ''\n\n if (url === '/llms.txt') {\n res.setHeader('Content-Type', 'text/plain; charset=utf-8')\n res.end(generateLlmsTxt(routeFiles, apiFiles, config))\n return\n }\n\n if (url === '/llms-full.txt') {\n res.setHeader('Content-Type', 'text/plain; charset=utf-8')\n res.end(generateLlmsFullTxt(routeFiles, apiFiles, config))\n return\n }\n\n if (url === '/.well-known/ai-plugin.json') {\n res.setHeader('Content-Type', 'application/json')\n res.end(JSON.stringify(generateAiPluginManifest(config), null, 2))\n return\n }\n\n if (url === '/.well-known/openapi.yaml' || url === '/.well-known/openapi.json') {\n res.setHeader('Content-Type', 'application/json')\n res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2))\n return\n }\n\n next()\n })\n },\n\n async generateBundle() {\n if (!isBuild) return\n\n this.emitFile({\n type: 'asset',\n fileName: 'llms.txt',\n source: generateLlmsTxt(routeFiles, apiFiles, config),\n })\n\n this.emitFile({\n type: 'asset',\n fileName: 'llms-full.txt',\n source: generateLlmsFullTxt(routeFiles, apiFiles, config),\n })\n\n this.emitFile({\n type: 'asset',\n fileName: '.well-known/ai-plugin.json',\n source: JSON.stringify(generateAiPluginManifest(config), null, 2),\n })\n\n this.emitFile({\n type: 'asset',\n fileName: '.well-known/openapi.json',\n source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2),\n })\n },\n }\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────────\n\nfunction parseApiFiles(files: string[]): string[] {\n return files\n .filter((f) => f.endsWith('.ts') || f.endsWith('.js'))\n .map((f) => {\n let path = f.replace(/\\.\\w+$/, '').replace(/\\/index$/, '')\n if (!path.startsWith('/')) path = `/${path}`\n // Convert [param] to :param\n path = path.replace(/\\[\\.\\.\\.(\\w+)\\]/g, ':$1*').replace(/\\[(\\w+)\\]/g, ':$1')\n return `/api${path === '/' ? '' : path}`\n })\n}\n\nasync function scanDir(dir: string, base: string): Promise<string[]> {\n const { readdir, stat } = await import('node:fs/promises')\n const { join, relative } = await import('node:path')\n\n try {\n const entries = await readdir(dir)\n const files: string[] = []\n for (const entry of entries) {\n const full = join(dir, entry)\n const s = await stat(full)\n if (s.isDirectory()) {\n files.push(...(await scanDir(full, base)))\n } else {\n files.push(relative(base, full))\n }\n }\n return files\n } catch {\n return []\n }\n}\n\nfunction safeParseUrl(url: string): URL | null {\n try {\n return new URL(url)\n } catch {\n return null\n }\n}\n\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1)\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"api-routes.js","names":[],"sources":["../src/api-routes.ts"],"sourcesContent":["import type { Middleware, MiddlewareContext } from '@pyreon/server'\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** HTTP methods supported by API routes. */\nexport type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'\n\n/** Context passed to API route handlers. */\nexport interface ApiContext {\n /** The incoming request. */\n request: Request\n /** Parsed URL. */\n url: URL\n /** URL path. */\n path: string\n /** Dynamic route parameters (e.g., { id: \"123\" }). */\n params: Record<string, string>\n /** Request headers. */\n headers: Headers\n}\n\n/** An API route handler function. */\nexport type ApiHandler = (ctx: ApiContext) => Response | Promise<Response>\n\n/** An API route module — exports named HTTP method handlers. */\nexport interface ApiRouteModule {\n GET?: ApiHandler\n POST?: ApiHandler\n PUT?: ApiHandler\n PATCH?: ApiHandler\n DELETE?: ApiHandler\n HEAD?: ApiHandler\n OPTIONS?: ApiHandler\n}\n\n/** A registered API route entry. */\nexport interface ApiRouteEntry {\n /** URL pattern (e.g., \"/api/posts/:id\"). */\n pattern: string\n /** The route module with method handlers. */\n module: ApiRouteModule\n}\n\n// ─── Pattern matching ────────────────────────────────────────────────────────\n\n/**\n * Match a URL path against an API route pattern.\n * Returns extracted params or null if no match.\n */\nexport function matchApiRoute(pattern: string, path: string): Record<string, string> | null {\n const patternParts = pattern.split('/').filter(Boolean)\n const pathParts = path.split('/').filter(Boolean)\n const params: Record<string, string> = {}\n\n for (let i = 0; i < patternParts.length; i++) {\n const pp = patternParts[i]\n if (!pp) continue\n\n // Catch-all: :param*\n if (pp.endsWith('*')) {\n const paramName = pp.slice(1, -1)\n params[paramName] = pathParts.slice(i).join('/')\n return params\n }\n\n // No more path segments\n if (i >= pathParts.length) return null\n\n // Dynamic segment: :param\n if (pp.startsWith(':')) {\n params[pp.slice(1)] = pathParts[i]!\n continue\n }\n\n // Static segment\n if (pp !== pathParts[i]) return null\n }\n\n return patternParts.length === pathParts.length ? params : null\n}\n\n// ─── Middleware ───────────────────────────────────────────────────────────────\n\nconst HTTP_METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']\n\n/**\n * Create a middleware that dispatches API route requests.\n * API routes are matched by URL pattern and HTTP method.\n */\nexport function createApiMiddleware(routes: ApiRouteEntry[]): Middleware {\n return async (ctx: MiddlewareContext) => {\n for (const route of routes) {\n const params = matchApiRoute(route.pattern, ctx.path)\n if (!params) continue\n\n const method = ctx.req.method.toUpperCase() as HttpMethod\n const handler = route.module[method]\n\n if (!handler) {\n // Route matched but method not supported\n const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(', ')\n return new Response(null, {\n status: 405,\n headers: {\n Allow: allowed,\n 'Content-Type': 'application/json',\n },\n })\n }\n\n return handler({\n request: ctx.req,\n url: ctx.url,\n path: ctx.path,\n params,\n headers: ctx.req.headers,\n })\n }\n }\n}\n\n// ─── Virtual module generation ───────────────────────────────────────────────\n\n/**\n * Detect whether a route file is an API route.\n * API routes are `.ts` or `.js` files inside an `api/` directory.\n */\nexport function isApiRoute(filePath: string): boolean {\n const normalized = filePath.replace(/\\\\/g, '/')\n return (\n normalized.startsWith('api/') &&\n (normalized.endsWith('.ts') || normalized.endsWith('.js')) &&\n !normalized.endsWith('.tsx') &&\n !normalized.endsWith('.jsx')\n )\n}\n\n/**\n * Convert an API route file path to a URL pattern.\n *\n * Examples:\n * \"api/posts.ts\" → \"/api/posts\"\n * \"api/posts/index.ts\" → \"/api/posts\"\n * \"api/posts/[id].ts\" → \"/api/posts/:id\"\n * \"api/[...path].ts\" → \"/api/:path*\"\n */\nexport function apiFilePathToPattern(filePath: string): string {\n let route = filePath\n // Remove extension\n for (const ext of ['.ts', '.js']) {\n if (route.endsWith(ext)) {\n route = route.slice(0, -ext.length)\n break\n }\n }\n\n const segments = route.split('/')\n const urlSegments: string[] = []\n\n for (const seg of segments) {\n if (seg === 'index') continue\n\n // Catch-all: [...param]\n const catchAll = seg.match(/^\\[\\.\\.\\.(\\w+)\\]$/)\n if (catchAll) {\n urlSegments.push(`:${catchAll[1]}*`)\n continue\n }\n\n // Dynamic: [param]\n const dynamic = seg.match(/^\\[(\\w+)\\]$/)\n if (dynamic) {\n urlSegments.push(`:${dynamic[1]}`)\n continue\n }\n\n urlSegments.push(seg)\n }\n\n return `/${urlSegments.join('/')}`\n}\n\n/**\n * Generate a virtual module that exports API route entries.\n * Each entry maps a URL pattern to a module with HTTP method handlers.\n */\nexport function generateApiRouteModule(files: string[], routesDir: string): string {\n const apiFiles = files.filter(isApiRoute)\n\n if (apiFiles.length === 0) {\n return 'export const apiRoutes = []\\n'\n }\n\n const imports: string[] = []\n const entries: string[] = []\n\n for (let i = 0; i < apiFiles.length; i++) {\n const name = `_api${i}`\n const file = apiFiles[i]\n if (!file) continue\n const fullPath = `${routesDir}/${file}`\n const pattern = apiFilePathToPattern(file)\n\n imports.push(`import * as ${name} from \"${fullPath}\"`)\n entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`)\n }\n\n return [...imports, '', 'export const apiRoutes = [', entries.join(',\\n'), ']'].join('\\n')\n}\n"],"mappings":"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"}
package/lib/cache.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"cache.js","names":[],"sources":["../src/cache.ts"],"sourcesContent":["import type { Middleware, MiddlewareContext } from '@pyreon/server'\n\n// ─── Cache control middleware ───────────────────────────────────────────────\n//\n// Smart caching middleware that sets appropriate cache headers based on\n// asset type, URL patterns, and build hashes.\n//\n// Strategies:\n// - Immutable: hashed assets (JS/CSS bundles) — cached forever\n// - Static: images, fonts, media — long cache with revalidation\n// - Dynamic: HTML pages — short or no cache, stale-while-revalidate\n// - API: JSON responses — no cache by default\n\nexport interface CacheConfig {\n /** Cache duration for immutable hashed assets (seconds). Default: 31536000 (1 year) */\n immutable?: number\n /** Cache duration for static assets like images/fonts (seconds). Default: 86400 (1 day) */\n static?: number\n /** Cache duration for pages (seconds). Default: 0 (no cache) */\n pages?: number\n /** Stale-while-revalidate window for pages (seconds). Default: 60 */\n staleWhileRevalidate?: number\n /** Custom rules by URL pattern. */\n rules?: CacheRule[]\n}\n\nexport interface CacheRule {\n /** URL pattern to match (glob-style). e.g. \"/api/*\" */\n match: string\n /** Cache-Control header value. */\n control: string\n}\n\nconst HASHED_ASSET = /\\.[a-f0-9]{8,}\\.\\w+$/\nconst STATIC_EXT = /\\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm|ogg|mp3|wav)$/i\nconst SCRIPT_EXT = /\\.(js|css|mjs)$/i\n\n/** @internal Exported for testing */\nexport function matchGlob(pattern: string, path: string): boolean {\n // Escape regex special chars, then convert glob wildcards\n const escaped = pattern.replace(/[.+^${}()|[\\]\\\\]/g, '\\\\$&')\n const regex = escaped.replace(/\\*/g, '.*').replace(/\\?/g, '.')\n return new RegExp(`^${regex}$`).test(path)\n}\n\nfunction resolveControl(\n path: string,\n immutableDuration: number,\n staticDuration: number,\n pageDuration: number,\n swr: number,\n): string {\n if (HASHED_ASSET.test(path)) {\n return `public, max-age=${immutableDuration}, immutable`\n }\n if (SCRIPT_EXT.test(path)) {\n return `public, max-age=3600, stale-while-revalidate=${swr}`\n }\n if (STATIC_EXT.test(path)) {\n return `public, max-age=${staticDuration}, stale-while-revalidate=${swr}`\n }\n if (pageDuration > 0) {\n return `public, max-age=${pageDuration}, stale-while-revalidate=${swr}`\n }\n return 'no-cache'\n}\n\n/**\n * Cache control middleware for Zero.\n * Sets Cache-Control headers on the response based on asset type.\n *\n * @example\n * import { cacheMiddleware } from \"@pyreon/zero/cache\"\n *\n * export default createHandler({\n * routes,\n * middleware: [\n * cacheMiddleware({\n * pages: 60,\n * staleWhileRevalidate: 300,\n * rules: [\n * { match: \"/api/*\", control: \"no-store\" },\n * ],\n * }),\n * ],\n * })\n */\nexport function cacheMiddleware(config: CacheConfig = {}): Middleware {\n const immutableDuration = config.immutable ?? 31536000\n const staticDuration = config.static ?? 86400\n const pageDuration = config.pages ?? 0\n const swr = config.staleWhileRevalidate ?? 60\n const rules = config.rules ?? []\n\n return (ctx: MiddlewareContext) => {\n const path = ctx.url.pathname\n\n for (const rule of rules) {\n if (matchGlob(rule.match, path)) {\n ctx.headers.set('Cache-Control', rule.control)\n return\n }\n }\n\n const control = resolveControl(path, immutableDuration, staticDuration, pageDuration, swr)\n ctx.headers.set('Cache-Control', control)\n }\n}\n\n/**\n * Security headers middleware.\n * Adds common security headers to all responses.\n */\nexport function securityHeaders(): Middleware {\n return (ctx: MiddlewareContext) => {\n ctx.headers.set('X-Content-Type-Options', 'nosniff')\n ctx.headers.set('X-Frame-Options', 'DENY')\n ctx.headers.set('X-XSS-Protection', '1; mode=block')\n ctx.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')\n ctx.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')\n }\n}\n\n/**\n * Compression detection middleware.\n * Sets Vary: Accept-Encoding header so caches can serve compressed variants.\n * Actual compression is handled by the runtime (Bun/Node) or reverse proxy.\n */\nexport function varyEncoding(): Middleware {\n return (ctx: MiddlewareContext) => {\n const existing = ctx.headers.get('Vary')\n if (!existing?.includes('Accept-Encoding')) {\n ctx.headers.set('Vary', existing ? `${existing}, Accept-Encoding` : 'Accept-Encoding')\n }\n }\n}\n"],"mappings":";AAiCA,MAAM,eAAe;AACrB,MAAM,aAAa;AACnB,MAAM,aAAa;;AAGnB,SAAgB,UAAU,SAAiB,MAAuB;CAGhE,MAAM,QADU,QAAQ,QAAQ,qBAAqB,OAAO,CACtC,QAAQ,OAAO,KAAK,CAAC,QAAQ,OAAO,IAAI;AAC9D,QAAO,IAAI,OAAO,IAAI,MAAM,GAAG,CAAC,KAAK,KAAK;;AAG5C,SAAS,eACP,MACA,mBACA,gBACA,cACA,KACQ;AACR,KAAI,aAAa,KAAK,KAAK,CACzB,QAAO,mBAAmB,kBAAkB;AAE9C,KAAI,WAAW,KAAK,KAAK,CACvB,QAAO,gDAAgD;AAEzD,KAAI,WAAW,KAAK,KAAK,CACvB,QAAO,mBAAmB,eAAe,2BAA2B;AAEtE,KAAI,eAAe,EACjB,QAAO,mBAAmB,aAAa,2BAA2B;AAEpE,QAAO;;;;;;;;;;;;;;;;;;;;;;AAuBT,SAAgB,gBAAgB,SAAsB,EAAE,EAAc;CACpE,MAAM,oBAAoB,OAAO,aAAa;CAC9C,MAAM,iBAAiB,OAAO,UAAU;CACxC,MAAM,eAAe,OAAO,SAAS;CACrC,MAAM,MAAM,OAAO,wBAAwB;CAC3C,MAAM,QAAQ,OAAO,SAAS,EAAE;AAEhC,SAAQ,QAA2B;EACjC,MAAM,OAAO,IAAI,IAAI;AAErB,OAAK,MAAM,QAAQ,MACjB,KAAI,UAAU,KAAK,OAAO,KAAK,EAAE;AAC/B,OAAI,QAAQ,IAAI,iBAAiB,KAAK,QAAQ;AAC9C;;EAIJ,MAAM,UAAU,eAAe,MAAM,mBAAmB,gBAAgB,cAAc,IAAI;AAC1F,MAAI,QAAQ,IAAI,iBAAiB,QAAQ;;;;;;;AAQ7C,SAAgB,kBAA8B;AAC5C,SAAQ,QAA2B;AACjC,MAAI,QAAQ,IAAI,0BAA0B,UAAU;AACpD,MAAI,QAAQ,IAAI,mBAAmB,OAAO;AAC1C,MAAI,QAAQ,IAAI,oBAAoB,gBAAgB;AACpD,MAAI,QAAQ,IAAI,mBAAmB,kCAAkC;AACrE,MAAI,QAAQ,IAAI,sBAAsB,2CAA2C;;;;;;;;AASrF,SAAgB,eAA2B;AACzC,SAAQ,QAA2B;EACjC,MAAM,WAAW,IAAI,QAAQ,IAAI,OAAO;AACxC,MAAI,CAAC,UAAU,SAAS,kBAAkB,CACxC,KAAI,QAAQ,IAAI,QAAQ,WAAW,GAAG,SAAS,qBAAqB,kBAAkB"}
package/lib/client.js.map DELETED
@@ -1 +0,0 @@
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 // Ignore comment nodes (Vite injects <!--app-html-->) — only real DOM\n // elements or text nodes count as SSR content worth hydrating.\n const hasSSRContent = Array.from(container.childNodes).some(\n (n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent!.trim().length > 0),\n )\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;CAQ1B,MAAM,UAHgB,MAAM,KAAK,UAAU,WAAW,CAAC,MACpD,MAAM,EAAE,aAAa,KAAM,EAAE,aAAa,KAAK,EAAE,YAAa,MAAM,CAAC,SAAS,EAChF,GAC+B,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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"compression.js","names":[],"sources":["../src/compression.ts"],"sourcesContent":["import type { Middleware, MiddlewareContext } from '@pyreon/server'\n\n// ─── Compression middleware ─────────────────────────────────────────────────\n\nexport interface CompressionConfig {\n /** Minimum response size in bytes to compress. Default: `1024` (1KB) */\n threshold?: number\n /** Encoding preference order. Default: `[\"gzip\", \"deflate\"]` */\n encodings?: ('gzip' | 'deflate')[]\n}\n\n/**\n * Compression middleware — compresses responses using gzip or deflate\n * based on the client's Accept-Encoding header.\n *\n * Only compresses text-based content types (HTML, JSON, JS, CSS, XML, SVG).\n * Skips responses below the size threshold and already-encoded responses.\n *\n * @example\n * import { compressionMiddleware } from \"@pyreon/zero/compression\"\n *\n * compressionMiddleware() // gzip with 1KB threshold\n * compressionMiddleware({ threshold: 512, encodings: [\"gzip\"] })\n */\nexport function compressionMiddleware(config: CompressionConfig = {}): Middleware {\n const { threshold = 1024, encodings = ['gzip', 'deflate'] } = config\n\n return (ctx: MiddlewareContext) => {\n const acceptEncoding = ctx.req.headers.get('accept-encoding') ?? ''\n\n // Find the best supported encoding\n const encoding = encodings.find((enc) => acceptEncoding.includes(enc))\n if (!encoding) return\n\n // Store the encoding choice for post-processing\n ctx.locals.__compressionEncoding = encoding\n ctx.locals.__compressionThreshold = threshold\n ctx.headers.append('Vary', 'Accept-Encoding')\n }\n}\n\n/**\n * Compress a Response body if it meets the criteria.\n * Use this to post-process responses after the handler runs.\n *\n * @example\n * const response = await handler(request)\n * const compressed = await compressResponse(response, 'gzip', 1024)\n */\nexport async function compressResponse(\n response: Response,\n encoding: 'gzip' | 'deflate',\n threshold: number,\n): Promise<Response> {\n const contentType = response.headers.get('content-type') ?? ''\n\n // Only compress text-based content\n if (!isCompressible(contentType)) return response\n\n // Skip if already encoded\n if (response.headers.get('content-encoding')) return response\n\n const body = await response.arrayBuffer()\n\n // Skip below threshold\n if (body.byteLength < threshold) return response\n\n const compressed = await compress(body, encoding)\n\n const headers = new Headers(response.headers)\n headers.set('Content-Encoding', encoding)\n headers.delete('Content-Length')\n headers.append('Vary', 'Accept-Encoding')\n\n return new Response(compressed, {\n status: response.status,\n statusText: response.statusText,\n headers,\n })\n}\n\nconst COMPRESSIBLE_TYPES = [\n 'text/',\n 'application/json',\n 'application/javascript',\n 'application/xml',\n 'application/xhtml+xml',\n 'image/svg+xml',\n]\n\n/** Check if a content type is compressible. Exported for testing. */\nexport function isCompressible(contentType: string): boolean {\n return COMPRESSIBLE_TYPES.some((t) => contentType.includes(t))\n}\n\nasync function compress(data: ArrayBuffer, encoding: 'gzip' | 'deflate'): Promise<ArrayBuffer> {\n // CompressionStream is available in modern browsers and Node 18+/Bun.\n // Fallback: try node:zlib for older runtimes.\n if (typeof CompressionStream !== 'undefined') {\n const format = encoding === 'gzip' ? 'gzip' : 'deflate'\n const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(format))\n return new Response(stream).arrayBuffer()\n }\n\n // Node.js fallback via zlib\n try {\n const zlib = await import('node:zlib')\n const { promisify } = await import('node:util')\n const fn = encoding === 'gzip' ? promisify(zlib.gzip) : promisify(zlib.deflate)\n const result = await fn(Buffer.from(data))\n return result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength)\n } catch {\n // No compression available — return uncompressed\n return data\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAwBA,SAAgB,sBAAsB,SAA4B,EAAE,EAAc;CAChF,MAAM,EAAE,YAAY,MAAM,YAAY,CAAC,QAAQ,UAAU,KAAK;AAE9D,SAAQ,QAA2B;EACjC,MAAM,iBAAiB,IAAI,IAAI,QAAQ,IAAI,kBAAkB,IAAI;EAGjE,MAAM,WAAW,UAAU,MAAM,QAAQ,eAAe,SAAS,IAAI,CAAC;AACtE,MAAI,CAAC,SAAU;AAGf,MAAI,OAAO,wBAAwB;AACnC,MAAI,OAAO,yBAAyB;AACpC,MAAI,QAAQ,OAAO,QAAQ,kBAAkB;;;;;;;;;;;AAYjD,eAAsB,iBACpB,UACA,UACA,WACmB;AAInB,KAAI,CAAC,eAHe,SAAS,QAAQ,IAAI,eAAe,IAAI,GAG5B,CAAE,QAAO;AAGzC,KAAI,SAAS,QAAQ,IAAI,mBAAmB,CAAE,QAAO;CAErD,MAAM,OAAO,MAAM,SAAS,aAAa;AAGzC,KAAI,KAAK,aAAa,UAAW,QAAO;CAExC,MAAM,aAAa,MAAM,SAAS,MAAM,SAAS;CAEjD,MAAM,UAAU,IAAI,QAAQ,SAAS,QAAQ;AAC7C,SAAQ,IAAI,oBAAoB,SAAS;AACzC,SAAQ,OAAO,iBAAiB;AAChC,SAAQ,OAAO,QAAQ,kBAAkB;AAEzC,QAAO,IAAI,SAAS,YAAY;EAC9B,QAAQ,SAAS;EACjB,YAAY,SAAS;EACrB;EACD,CAAC;;AAGJ,MAAM,qBAAqB;CACzB;CACA;CACA;CACA;CACA;CACA;CACD;;AAGD,SAAgB,eAAe,aAA8B;AAC3D,QAAO,mBAAmB,MAAM,MAAM,YAAY,SAAS,EAAE,CAAC;;AAGhE,eAAe,SAAS,MAAmB,UAAoD;AAG7F,KAAI,OAAO,sBAAsB,aAAa;EAC5C,MAAM,SAAS,aAAa,SAAS,SAAS;EAC9C,MAAM,SAAS,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,YAAY,IAAI,kBAAkB,OAAO,CAAC;AACnF,SAAO,IAAI,SAAS,OAAO,CAAC,aAAa;;AAI3C,KAAI;EACF,MAAM,OAAO,MAAM,OAAO;EAC1B,MAAM,EAAE,cAAc,MAAM,OAAO;EAEnC,MAAM,SAAS,OADJ,aAAa,SAAS,UAAU,KAAK,KAAK,GAAG,UAAU,KAAK,QAAQ,EACvD,OAAO,KAAK,KAAK,CAAC;AAC1C,SAAO,OAAO,OAAO,MAAM,OAAO,YAAY,OAAO,aAAa,OAAO,WAAW;SAC9E;AAEN,SAAO"}
package/lib/config.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"config.js","names":[],"sources":["../src/config.ts"],"sourcesContent":["import type { ZeroConfig } from './types'\n\n/**\n * Define a Zero configuration.\n * Used in `zero.config.ts` at the project root.\n *\n * @example\n * import { defineConfig } from \"@pyreon/zero/config\"\n *\n * export default defineConfig({\n * mode: \"ssr\",\n * ssr: { mode: \"stream\" },\n * port: 3000,\n * })\n */\nexport function defineConfig(config: ZeroConfig): ZeroConfig {\n return config\n}\n\n/** Merge user config with defaults. */\nexport function resolveConfig(\n userConfig: ZeroConfig = {},\n): Required<Pick<ZeroConfig, 'mode' | 'base' | 'port' | 'adapter'>> & ZeroConfig {\n return {\n mode: 'ssr',\n base: '/',\n port: 3000,\n adapter: 'node',\n ...userConfig,\n ssr: {\n mode: 'string',\n ...userConfig.ssr,\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,aAAa,QAAgC;AAC3D,QAAO;;;AAIT,SAAgB,cACd,aAAyB,EAAE,EACoD;AAC/E,QAAO;EACL,MAAM;EACN,MAAM;EACN,MAAM;EACN,SAAS;EACT,GAAG;EACH,KAAK;GACH,MAAM;GACN,GAAG,WAAW;GACf;EACF"}
package/lib/cors.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"cors.js","names":[],"sources":["../src/cors.ts"],"sourcesContent":["import type { Middleware, MiddlewareContext } from '@pyreon/server'\n\n// ─── CORS middleware ────────────────────────────────────────────────────────\n\nexport interface CorsConfig {\n /** Allowed origins. Use `\"*\"` for any origin. Default: `\"*\"` */\n origin?: string | string[] | ((origin: string) => boolean)\n /** Allowed HTTP methods. Default: `[\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\", \"OPTIONS\"]` */\n methods?: string[]\n /** Allowed request headers. Default: `[\"Content-Type\", \"Authorization\"]` */\n allowedHeaders?: string[]\n /** Headers exposed to the client. Default: `[]` */\n exposedHeaders?: string[]\n /** Allow credentials (cookies, auth headers). Default: `false` */\n credentials?: boolean\n /** Preflight cache duration in seconds. Default: `86400` (24 hours) */\n maxAge?: number\n}\n\nconst DEFAULT_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']\nconst DEFAULT_HEADERS = ['Content-Type', 'Authorization']\n\n/**\n * CORS middleware — handles preflight requests and sets appropriate\n * Access-Control headers on all responses.\n *\n * @example\n * import { corsMiddleware } from \"@pyreon/zero/cors\"\n *\n * corsMiddleware({ origin: \"https://example.com\", credentials: true })\n *\n * // Allow any origin\n * corsMiddleware({ origin: \"*\" })\n *\n * // Multiple origins\n * corsMiddleware({ origin: [\"https://app.com\", \"https://admin.com\"] })\n */\nexport function corsMiddleware(config: CorsConfig = {}): Middleware {\n const {\n origin = '*',\n methods = DEFAULT_METHODS,\n allowedHeaders = DEFAULT_HEADERS,\n exposedHeaders = [],\n credentials = false,\n maxAge = 86400,\n } = config\n\n return (ctx: MiddlewareContext) => {\n const requestOrigin = ctx.req.headers.get('origin') ?? ''\n const resolvedOrigin = resolveOrigin(origin, requestOrigin)\n\n if (!resolvedOrigin) return\n\n // Set CORS headers on all responses\n ctx.headers.set('Access-Control-Allow-Origin', resolvedOrigin)\n if (credentials) {\n ctx.headers.set('Access-Control-Allow-Credentials', 'true')\n }\n if (exposedHeaders.length > 0) {\n ctx.headers.set('Access-Control-Expose-Headers', exposedHeaders.join(', '))\n }\n if (resolvedOrigin !== '*') {\n ctx.headers.append('Vary', 'Origin')\n }\n\n // Handle preflight\n if (ctx.req.method === 'OPTIONS') {\n return new Response(null, {\n status: 204,\n headers: {\n 'Access-Control-Allow-Origin': resolvedOrigin,\n 'Access-Control-Allow-Methods': methods.join(', '),\n 'Access-Control-Allow-Headers': allowedHeaders.join(', '),\n 'Access-Control-Max-Age': String(maxAge),\n ...(credentials ? { 'Access-Control-Allow-Credentials': 'true' } : {}),\n },\n })\n }\n }\n}\n\nfunction resolveOrigin(config: CorsConfig['origin'], requestOrigin: string): string | null {\n if (config === '*') return '*'\n if (typeof config === 'string') {\n return config === requestOrigin ? config : null\n }\n if (typeof config === 'function') {\n return config(requestOrigin) ? requestOrigin : null\n }\n if (Array.isArray(config)) {\n return config.includes(requestOrigin) ? requestOrigin : null\n }\n return null\n}\n"],"mappings":";AAmBA,MAAM,kBAAkB;CAAC;CAAO;CAAQ;CAAO;CAAS;CAAU;CAAU;AAC5E,MAAM,kBAAkB,CAAC,gBAAgB,gBAAgB;;;;;;;;;;;;;;;;AAiBzD,SAAgB,eAAe,SAAqB,EAAE,EAAc;CAClE,MAAM,EACJ,SAAS,KACT,UAAU,iBACV,iBAAiB,iBACjB,iBAAiB,EAAE,EACnB,cAAc,OACd,SAAS,UACP;AAEJ,SAAQ,QAA2B;EAEjC,MAAM,iBAAiB,cAAc,QADf,IAAI,IAAI,QAAQ,IAAI,SAAS,IAAI,GACI;AAE3D,MAAI,CAAC,eAAgB;AAGrB,MAAI,QAAQ,IAAI,+BAA+B,eAAe;AAC9D,MAAI,YACF,KAAI,QAAQ,IAAI,oCAAoC,OAAO;AAE7D,MAAI,eAAe,SAAS,EAC1B,KAAI,QAAQ,IAAI,iCAAiC,eAAe,KAAK,KAAK,CAAC;AAE7E,MAAI,mBAAmB,IACrB,KAAI,QAAQ,OAAO,QAAQ,SAAS;AAItC,MAAI,IAAI,IAAI,WAAW,UACrB,QAAO,IAAI,SAAS,MAAM;GACxB,QAAQ;GACR,SAAS;IACP,+BAA+B;IAC/B,gCAAgC,QAAQ,KAAK,KAAK;IAClD,gCAAgC,eAAe,KAAK,KAAK;IACzD,0BAA0B,OAAO,OAAO;IACxC,GAAI,cAAc,EAAE,oCAAoC,QAAQ,GAAG,EAAE;IACtE;GACF,CAAC;;;AAKR,SAAS,cAAc,QAA8B,eAAsC;AACzF,KAAI,WAAW,IAAK,QAAO;AAC3B,KAAI,OAAO,WAAW,SACpB,QAAO,WAAW,gBAAgB,SAAS;AAE7C,KAAI,OAAO,WAAW,WACpB,QAAO,OAAO,cAAc,GAAG,gBAAgB;AAEjD,KAAI,MAAM,QAAQ,OAAO,CACvB,QAAO,OAAO,SAAS,cAAc,GAAG,gBAAgB;AAE1D,QAAO"}
package/lib/csp.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"csp.js","names":[],"sources":["../src/csp.ts"],"sourcesContent":["/**\n * Content Security Policy middleware.\n *\n * Generates a CSP header from a typed configuration object.\n * Supports all CSP directives, nonces for inline scripts,\n * and report-only mode for testing.\n *\n * @example\n * ```ts\n * import { cspMiddleware } from \"@pyreon/zero\"\n *\n * const csp = cspMiddleware({\n * directives: {\n * defaultSrc: [\"'self'\"],\n * scriptSrc: [\"'self'\", \"'nonce'\"],\n * styleSrc: [\"'self'\", \"'unsafe-inline'\"],\n * imgSrc: [\"'self'\", \"data:\", \"https:\"],\n * connectSrc: [\"'self'\", \"https://api.example.com\"],\n * },\n * reportOnly: false,\n * })\n * ```\n */\nimport type { Middleware, MiddlewareContext } from '@pyreon/server'\nimport { useRequestLocals } from '@pyreon/server'\n\n/** Client-side fallback nonce (dev server, SPA). */\nlet _clientNonce = ''\n\n/**\n * Read the current CSP nonce in a component.\n *\n * SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context\n * system — fully isolated between concurrent requests via AsyncLocalStorage.\n * Client/dev: falls back to module-level variable set by middleware.\n *\n * @example\n * ```tsx\n * import { useNonce } from \"@pyreon/zero/csp\"\n *\n * function InlineScript() {\n * const nonce = useNonce()\n * return <script nonce={nonce}>console.log(\"safe\")</script>\n * }\n * ```\n */\nexport function useNonce(): string {\n const locals = useRequestLocals()\n if (locals.cspNonce) return locals.cspNonce as string\n return _clientNonce\n}\n\nexport interface CspDirectives {\n defaultSrc?: string[]\n scriptSrc?: string[]\n styleSrc?: string[]\n imgSrc?: string[]\n fontSrc?: string[]\n connectSrc?: string[]\n mediaSrc?: string[]\n objectSrc?: string[]\n frameSrc?: string[]\n childSrc?: string[]\n workerSrc?: string[]\n frameAncestors?: string[]\n formAction?: string[]\n baseUri?: string[]\n manifestSrc?: string[]\n /** Reporting endpoint URL. */\n reportUri?: string\n /** Reporting endpoint name (CSP Level 3). */\n reportTo?: string\n /** Upgrade insecure requests. */\n upgradeInsecureRequests?: boolean\n /** Block all mixed content. */\n blockAllMixedContent?: boolean\n}\n\nexport interface CspConfig {\n /** CSP directives. */\n directives: CspDirectives\n /**\n * Report-only mode — logs violations without blocking.\n * Uses Content-Security-Policy-Report-Only header instead.\n * Default: false\n */\n reportOnly?: boolean\n}\n\nconst DIRECTIVE_MAP: Record<string, string> = {\n defaultSrc: 'default-src',\n scriptSrc: 'script-src',\n styleSrc: 'style-src',\n imgSrc: 'img-src',\n fontSrc: 'font-src',\n connectSrc: 'connect-src',\n mediaSrc: 'media-src',\n objectSrc: 'object-src',\n frameSrc: 'frame-src',\n childSrc: 'child-src',\n workerSrc: 'worker-src',\n frameAncestors: 'frame-ancestors',\n formAction: 'form-action',\n baseUri: 'base-uri',\n manifestSrc: 'manifest-src',\n reportUri: 'report-uri',\n reportTo: 'report-to',\n}\n\n/**\n * Build a CSP header string from directives.\n * Exported for testing.\n */\nexport function buildCspHeader(directives: CspDirectives, nonce?: string): string {\n const parts: string[] = []\n\n for (const [key, cssProp] of Object.entries(DIRECTIVE_MAP)) {\n const value = (directives as Record<string, unknown>)[key]\n if (!value) continue\n\n if (Array.isArray(value)) {\n // Replace \"'nonce'\" placeholder with actual nonce\n const resolved = nonce\n ? value.map((v: string) => (v === \"'nonce'\" ? `'nonce-${nonce}'` : v))\n : value.filter((v: string) => v !== \"'nonce'\")\n parts.push(`${cssProp} ${resolved.join(' ')}`)\n } else if (typeof value === 'string') {\n parts.push(`${cssProp} ${value}`)\n }\n }\n\n if (directives.upgradeInsecureRequests) {\n parts.push('upgrade-insecure-requests')\n }\n if (directives.blockAllMixedContent) {\n parts.push('block-all-mixed-content')\n }\n\n return parts.join('; ')\n}\n\n/**\n * Generate a random nonce string (base64, 16 bytes).\n */\nfunction generateNonce(): string {\n if (typeof crypto !== 'undefined' && crypto.getRandomValues) {\n const bytes = new Uint8Array(16)\n crypto.getRandomValues(bytes)\n // Convert to base64 using btoa\n let binary = ''\n for (const byte of bytes) binary += String.fromCharCode(byte)\n return typeof btoa === 'function'\n ? btoa(binary)\n : Buffer.from(bytes).toString('base64')\n }\n // Fallback for environments without crypto\n return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)\n}\n\n/**\n * CSP middleware — sets Content-Security-Policy header.\n *\n * When directives contain `\"'nonce'\"`, a fresh nonce is generated per-request\n * and attached to `ctx.locals.cspNonce` for use in inline script tags.\n *\n * @example\n * ```ts\n * // Apply to all routes\n * export default defineConfig({\n * middleware: [\n * cspMiddleware({\n * directives: {\n * defaultSrc: [\"'self'\"],\n * scriptSrc: [\"'self'\", \"'nonce'\"],\n * styleSrc: [\"'self'\", \"'unsafe-inline'\"],\n * imgSrc: [\"'self'\", \"data:\", \"https:\"],\n * },\n * }),\n * ],\n * })\n * ```\n */\nexport function cspMiddleware(config: CspConfig): Middleware {\n const headerName = config.reportOnly\n ? 'Content-Security-Policy-Report-Only'\n : 'Content-Security-Policy'\n\n // Check if nonce is needed\n const needsNonce = Object.values(config.directives).some(\n (v) => Array.isArray(v) && v.includes(\"'nonce'\"),\n )\n\n // Pre-build header for static case (no nonce)\n const staticHeader = needsNonce ? null : buildCspHeader(config.directives)\n\n return (ctx: MiddlewareContext) => {\n if (staticHeader) {\n _clientNonce = ''\n ctx.headers.set(headerName, staticHeader)\n } else {\n const nonce = generateNonce()\n _clientNonce = nonce\n ;(ctx.locals as Record<string, unknown>).cspNonce = nonce\n ctx.headers.set(headerName, buildCspHeader(config.directives, nonce))\n }\n }\n}\n"],"mappings":";;;;AA2BA,IAAI,eAAe;;;;;;;;;;;;;;;;;;AAmBnB,SAAgB,WAAmB;CACjC,MAAM,SAAS,kBAAkB;AACjC,KAAI,OAAO,SAAU,QAAO,OAAO;AACnC,QAAO;;AAwCT,MAAM,gBAAwC;CAC5C,YAAY;CACZ,WAAW;CACX,UAAU;CACV,QAAQ;CACR,SAAS;CACT,YAAY;CACZ,UAAU;CACV,WAAW;CACX,UAAU;CACV,UAAU;CACV,WAAW;CACX,gBAAgB;CAChB,YAAY;CACZ,SAAS;CACT,aAAa;CACb,WAAW;CACX,UAAU;CACX;;;;;AAMD,SAAgB,eAAe,YAA2B,OAAwB;CAChF,MAAM,QAAkB,EAAE;AAE1B,MAAK,MAAM,CAAC,KAAK,YAAY,OAAO,QAAQ,cAAc,EAAE;EAC1D,MAAM,QAAS,WAAuC;AACtD,MAAI,CAAC,MAAO;AAEZ,MAAI,MAAM,QAAQ,MAAM,EAAE;GAExB,MAAM,WAAW,QACb,MAAM,KAAK,MAAe,MAAM,YAAY,UAAU,MAAM,KAAK,EAAG,GACpE,MAAM,QAAQ,MAAc,MAAM,UAAU;AAChD,SAAM,KAAK,GAAG,QAAQ,GAAG,SAAS,KAAK,IAAI,GAAG;aACrC,OAAO,UAAU,SAC1B,OAAM,KAAK,GAAG,QAAQ,GAAG,QAAQ;;AAIrC,KAAI,WAAW,wBACb,OAAM,KAAK,4BAA4B;AAEzC,KAAI,WAAW,qBACb,OAAM,KAAK,0BAA0B;AAGvC,QAAO,MAAM,KAAK,KAAK;;;;;AAMzB,SAAS,gBAAwB;AAC/B,KAAI,OAAO,WAAW,eAAe,OAAO,iBAAiB;EAC3D,MAAM,QAAQ,IAAI,WAAW,GAAG;AAChC,SAAO,gBAAgB,MAAM;EAE7B,IAAI,SAAS;AACb,OAAK,MAAM,QAAQ,MAAO,WAAU,OAAO,aAAa,KAAK;AAC7D,SAAO,OAAO,SAAS,aACnB,KAAK,OAAO,GACZ,OAAO,KAAK,MAAM,CAAC,SAAS,SAAS;;AAG3C,QAAO,KAAK,QAAQ,CAAC,SAAS,GAAG,CAAC,MAAM,EAAE,GAAG,KAAK,QAAQ,CAAC,SAAS,GAAG,CAAC,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;AA0BlF,SAAgB,cAAc,QAA+B;CAC3D,MAAM,aAAa,OAAO,aACtB,wCACA;CAQJ,MAAM,eALa,OAAO,OAAO,OAAO,WAAW,CAAC,MACjD,MAAM,MAAM,QAAQ,EAAE,IAAI,EAAE,SAAS,UAAU,CACjD,GAGiC,OAAO,eAAe,OAAO,WAAW;AAE1E,SAAQ,QAA2B;AACjC,MAAI,cAAc;AAChB,kBAAe;AACf,OAAI,QAAQ,IAAI,YAAY,aAAa;SACpC;GACL,MAAM,QAAQ,eAAe;AAC7B,kBAAe;AACd,GAAC,IAAI,OAAmC,WAAW;AACpD,OAAI,QAAQ,IAAI,YAAY,eAAe,OAAO,YAAY,MAAM,CAAC"}
package/lib/env.js.map DELETED
@@ -1 +0,0 @@
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":";;;;AA2CA,SAAgB,IAAI,SAA6D;AAE/E,QAAO;EACL,QAAQ;EACR,UAHe,SAAS,YAAY,UAAa,SAAS,aAAa;EAIvE,cAAc,SAAS;EACvB,MAAM,KAAK,KAAK;AACd,OAAI,QAAQ,UAAa,QAAQ,IAAI;AACnC,QAAI,SAAS,YAAY,OAAW,QAAO,QAAQ;AACnD,UAAM,IAAI,SAAS,KAAK,2BAA2B,SAAS,YAAY;;AAE1E,UAAO;;EAEV;;;;;AAMH,SAAgB,IAAI,SAA6D;AAE/E,QAAO;EACL,QAAQ;EACR,UAHe,SAAS,YAAY,UAAa,SAAS,aAAa;EAIvE,cAAc,SAAS;EACvB,MAAM,KAAK,KAAK;AACd,OAAI,QAAQ,UAAa,QAAQ,IAAI;AACnC,QAAI,SAAS,YAAY,OAAW,QAAO,QAAQ;AACnD,UAAM,IAAI,SAAS,KAAK,2BAA2B,SAAS,YAAY;;GAE1E,MAAM,IAAI,OAAO,IAAI;AACrB,OAAI,OAAO,MAAM,EAAE,CACjB,OAAM,IAAI,SAAS,KAAK,0BAA0B,IAAI,IAAI,SAAS,YAAY;AAEjF,UAAO;;EAEV;;;;;AAMH,SAAgB,KAAK,SAA+D;AAElF,QAAO;EACL,QAAQ;EACR,UAHe,SAAS,YAAY,UAAa,SAAS,aAAa;EAIvE,cAAc,SAAS;EACvB,MAAM,KAAK,KAAK;AACd,OAAI,QAAQ,UAAa,QAAQ,IAAI;AACnC,QAAI,SAAS,YAAY,OAAW,QAAO,QAAQ;AACnD,UAAM,IAAI,SAAS,KAAK,2BAA2B,SAAS,YAAY;;GAE1E,MAAM,QAAQ,IAAI,aAAa;AAC/B,OAAI,UAAU,UAAU,UAAU,IAAK,QAAO;AAC9C,OAAI,UAAU,WAAW,UAAU,IAAK,QAAO;AAC/C,SAAM,IAAI,SAAS,KAAK,mCAAmC,IAAI,IAAI,SAAS,YAAY;;EAE3F;;;;;AAMH,SAAgB,IAAI,SAA6D;AAE/E,QAAO;EACL,QAAQ;EACR,UAHe,SAAS,YAAY,UAAa,SAAS,aAAa;EAIvE,cAAc,SAAS;EACvB,MAAM,KAAK,KAAK;AACd,OAAI,QAAQ,UAAa,QAAQ,IAAI;AACnC,QAAI,SAAS,YAAY,OAAW,QAAO,QAAQ;AACnD,UAAM,IAAI,SAAS,KAAK,2BAA2B,SAAS,YAAY;;AAE1E,OAAI;AACF,QAAI,IAAI,IAAI;AACZ,WAAO;WACD;AACN,UAAM,IAAI,SAAS,KAAK,6BAA6B,IAAI,IAAI,SAAS,YAAY;;;EAGvF;;;;;AAMH,SAAgB,MACd,QACA,SACiB;AAEjB,QAAO;EACL,QAAQ;EACR,UAHe,SAAS,YAAY,UAAa,SAAS,aAAa;EAIvE,cAAc,SAAS;EACvB,MAAM,KAAK,KAAK;AACd,OAAI,QAAQ,UAAa,QAAQ,IAAI;AACnC,QAAI,SAAS,YAAY,OAAW,QAAO,QAAQ;AACnD,UAAM,IAAI,SAAS,KAAK,2BAA2B,SAAS,YAAY;;AAE1E,OAAI,CAAC,OAAO,SAAS,IAAS,CAC5B,OAAM,IAAI,SACR,KACA,mBAAmB,OAAO,KAAK,KAAK,CAAC,UAAU,IAAI,IACnD,SAAS,YACV;AAEH,UAAO;;EAEV;;AAKH,IAAM,WAAN,cAAuB,MAAM;CAC3B,YAAY,KAAa,SAAiB,aAAsB;EAC9D,MAAM,OAAO,cAAc,KAAK,YAAY,KAAK;AACjD,QAAM,YAAY,MAAM,KAAK,IAAI,UAAU;AAC3C,OAAK,OAAO;;;AAIhB,SAAS,eAAe,GAAwC;AAC9D,QAAO,OAAO,MAAM,YAAY,MAAM,QAAS,EAAU,WAAW;;;;;;;;;;;;;AActE,SAAS,YAAY,OAAuC;AAC1D,KAAI,eAAe,MAAM,CAAE,QAAO;AAGlC,KAAI,UAAU,OAAQ,QAAO,KAAK;AAClC,KAAI,UAAU,OAAQ,QAAO,KAAK;AAClC,KAAI,UAAU,QAAS,QAAO,MAAM;AAGpC,KAAI,OAAO,UAAU,SAAU,QAAO,IAAI,EAAE,SAAS,OAAO,CAAC;AAC7D,KAAI,OAAO,UAAU,UAAW,QAAO,KAAK,EAAE,SAAS,OAAO,CAAC;AAC/D,KAAI,OAAO,UAAU,SAAU,QAAO,IAAI,EAAE,SAAS,OAAO,CAAC;AAE7D,OAAM,IAAI,MAAM,kCAAkC,OAAO,MAAM,CAAC,0EAA0E;;;;;;;;;;;;;;;;;;;;;;;;AAkD5I,SAAgB,YACd,QACA,QACmB;CACnB,MAAM,MAAM,WAAW,OAAO,YAAY,cAAc,QAAQ,MAAM,EAAE;CACxE,MAAM,SAAkC,EAAE;CAC1C,MAAM,SAAmB,EAAE;AAE3B,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,EAAE;EACjD,MAAM,YAAY,YAAY,MAAM;AACpC,MAAI;AACF,UAAO,OAAO,UAAU,MAAM,IAAI,MAAM,IAAI;WACrC,GAAG;AACV,UAAO,KAAM,EAAY,QAAQ;;;AAIrC,KAAI,OAAO,SAAS,GAAG;EACrB,MAAM,SAAS,6CAA6C,OAAO,OAAO,QAAQ,OAAO,SAAS,IAAI,MAAM,GAAG;EAC/G,MAAM,OAAO,OAAO,KAAK,MAAM,OAAO,EAAE,QAAQ,aAAa,GAAG,GAAG,CAAC,KAAK,KAAK;AAC9E,QAAM,IAAI,MAAM,SAAS,OAAO,KAAK;;AAGvC,QAAO;;AAmBT,SAAgB,UAAU,QAA+D;CACvF,MAAM,SAAS;CACf,MAAM,MAAM,OAAO,YAAY,cAAc,QAAQ,MAAM,EAAE;AAE7D,KAAI,CAAC,QAAQ;EACX,MAAM,SAAiC,EAAE;AACzC,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAI,CAC5C,KAAI,IAAI,WAAW,OAAO,IAAI,UAAU,OACtC,QAAO,IAAI,MAAM,GAAc,IAAI;AAGvC,SAAO;;CAGT,MAAM,iBAAqD,EAAE;AAC7D,MAAK,MAAM,OAAO,OAAO,KAAK,OAAO,CACnC,gBAAe,OAAO,IAAI,GAAG,SAAS;AAExC,QAAO,YAAY,QAAQ,eAAe;;;;;;;;;;;;;;;;;;AAqB5C,SAAgB,OAAU,OAA4C;AACpE,QAAO;EACL,QAAQ;EACR,UAAU;EACV,cAAc;EACd,MAAM,KAAyB,KAAa;AAC1C,OAAI,QAAQ,UAAa,QAAQ,GAC/B,OAAM,IAAI,MAAM,YAAY,IAAI,2BAA2B;AAE7D,OAAI;AACF,WAAO,MAAM,IAAI;YACV,GAAG;IACV,MAAM,MAAM,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE;AACtD,UAAM,IAAI,MAAM,YAAY,IAAI,IAAI,MAAM;;;EAG/C"}
@@ -1 +0,0 @@
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":";;;;;AAKA,IAAI,cAAc;AAClB,SAAS,mBAAmB;AAC1B,KAAI,YAAa;AACjB,eAAc;AAEd,SAAQ,KACN,gHACD;;AAqFH,MAAM,QAAuB;CAC3B;EAAE,MAAM;EAAI,MAAM;EAAqB;CACvC;EAAE,MAAM;EAAI,MAAM;EAAqB;CACvC;EAAE,MAAM;EAAK,MAAM;EAAwB;CAC3C;EAAE,MAAM;EAAK,MAAM;EAAgB;CACnC;EAAE,MAAM;EAAK,MAAM;EAAgB;CACpC;;;;;;;;;;;;;;;;;AAkBD,SAAgB,cAAc,QAAqC;CACjE,MAAM,aAAa,OAAO,cAAc;CACxC,MAAM,kBAAkB,OAAO,mBAAmB;CAClD,MAAM,mBAAmB,OAAO,aAAa;CAE7C,IAAI,OAAO;CACX,IAAI,UAAU;AAEd,QAAO;EACL,MAAM;EACN,SAAS;EAET,eAAe,gBAAgB;AAC7B,UAAO,eAAe;AACtB,aAAU,eAAe,YAAY;;EAIvC,gBAAgB,QAAQ;GACtB,MAAM,aAAa,KAAK,MAAM,OAAO,OAAO;GAC5C,MAAM,WAAW,OAAO,aAAa,KAAK,MAAM,OAAO,WAAW,GAAG;GACrE,MAAM,gBAAgB,OAAO,OAAO,cAAc,WAC9C,KAAK,MAAM,OAAO,UAAU,GAC5B;GACJ,MAAM,eAAe,OAAO,cAAc;GAC1C,MAAM,2BAAW,IAAI,KAAyB;;GAG9C,SAAS,oBAAoB,UAAkB,eAA+B;AAE5E,QAAI,YAAY,SAAS,SAAS,SAAS,CAAE,QAAO;AAEpD,QAAI,SAAS,SAAS,UAAU,CAAE,QAAO;AACzC,WAAO;;AAGT,UAAO,YAAY,IAAI,OAAO,KAAK,KAAK,SAAS;IAC/C,MAAM,MAAM,IAAI,OAAO;IAGvB,MAAM,eAAe,oBAAoB,KAAK,QAAQ,KAAK;IAC3D,MAAM,SAAS,eAAe,aAAa,MAAM;IACjD,MAAM,UAAU,eAAe,aAAa,aAAa;IACzD,MAAM,cAAc,eAAe,aAAa,OAAO,SAAS,OAAO,GAAG,OAAO,OAAO,SAAS,OAAO;AAGxG,QAAI,OAAO,SAAS,eAAe,IAAI,YACrC,KAAI;KACF,IAAI,UAAU,MAAM,SAAS,SAAS,QAAQ;AAC9C,SAAI,aAAc,WAAU,iBAAiB,QAAQ;cAC5C,iBAAiB,WAAW,cAAc,CACjD,WAAU,MAAM,SAAS,eAAe,QAAQ;AAElD,SAAI,UAAU,gBAAgB,gBAAgB;AAC9C,SAAI,IAAI,QAAQ;AAChB;YACM;IAIV,MAAM,WAAW,OAAO,MAAM,IAAI,CAAC,KAAK,IAAI;IAE5C,MAAM,YAAY,SAAS,QAAQ,mBAAmB,IAAI;IAC1D,MAAM,YAAY,MAAM,MAAM,MAAM,EAAE,SAAS,aAAa,aAAa,EAAE,KAAK;AAChF,QAAI,WAAW;KACb,MAAM,iBAAiB,oBAAoB,UAAU,QAAQ;KAC7D,MAAM,WAAW,GAAG,eAAe,GAAG,UAAU,KAAK,GAAG;KACxD,IAAI,MAAM,SAAS,IAAI,SAAS;AAChC,SAAI,CAAC,KAAK;MACR,IAAI,SAAS,MAAM,YAAY,gBAAgB,UAAU,KAAK;AAC9D,UAAI,UAAU,aACZ,UAAS,MAAM,iBAAiB,QAAQ,UAAU,KAAK;AAEzD,UAAI,QAAQ;AACV,aAAM;AACN,gBAAS,IAAI,UAAU,OAAO;;;AAGlC,SAAI,KAAK;AACP,UAAI,UAAU,gBAAgB,YAAY;AAC1C,UAAI,UAAU,iBAAiB,WAAW;AAC1C,UAAI,IAAI,OAAO,KAAK,IAAI,CAAC;AACzB;;;AAKJ,QAAI,aAAa,eAAe;KAC9B,MAAM,WAAW,OAAO;KACxB,IAAI,MAA8B,SAAS,IAAI,SAAS;AACxD,SAAI,CAAC,KAAK;MACR,MAAM,SAAS,MAAM,YAAY,QAAQ;AACzC,UAAI,QAAQ;AACV,aAAM;AACN,gBAAS,IAAI,UAAU,OAAO;;;AAGlC,SAAI,KAAK;AACP,UAAI,UAAU,gBAAgB,eAAe;AAC7C,UAAI,UAAU,iBAAiB,WAAW;AAC1C,UAAI,IAAI,OAAO,KAAK,IAAI,CAAC;AACzB;;;AAKJ,QAAI,aAAa,sBAAsB,kBAAkB;KACvD,MAAM,SAAS,eAAe,IAAI,aAAa,WAAW;KAC1D,MAAM,WAAW;MACf,MAAM,OAAO,QAAQ;MACrB,YAAY,OAAO,QAAQ;MAC3B,OAAO,CACL;OAAE,KAAK,GAAG,OAAO;OAAgB,OAAO;OAAW,MAAM;OAAa,EACtE;OAAE,KAAK,GAAG,OAAO;OAAgB,OAAO;OAAW,MAAM;OAAa,CACvE;MACD,aAAa;MACb,kBAAkB;MAClB,SAAS;MACV;AACD,SAAI,UAAU,gBAAgB,4BAA4B;AAC1D,SAAI,IAAI,KAAK,UAAU,UAAU,MAAM,EAAE,CAAC;AAC1C;;AAGF,UAAM;KACN;;EAIJ,qBAAqB;GACnB,MAAM,QAAQ,OAAO,OAAO,SAAS,OAAO;GAC5C,MAAM,UAAU,CAAC,CAAC,OAAO;GACzB,MAAM,OAID,EAAE;AAGP,OAAI,MACF,MAAK,KAAK;IACR,KAAK;IACL,OAAO;KAAE,KAAK;KAAQ,MAAM;KAAiB,MAAM;KAAgB;IACnE,UAAU;IACX,CAAC;AAGJ,OAAI,SAAS;IAGX,MAAM,aAAa,EAAE,sBAAsB,SAAS;IACpD,MAAM,YAAY;KAAE,sBAAsB;KAAQ,OAAO;KAAW;AAEpE,SAAK,KACH;KAAE,KAAK;KAAQ,OAAO;MAAE,KAAK;MAAQ,MAAM;MAAa,OAAO;MAAS,MAAM;MAA4B,GAAG;MAAY;KAAE,UAAU;KAAQ,EAC7I;KAAE,KAAK;KAAQ,OAAO;MAAE,KAAK;MAAQ,MAAM;MAAa,OAAO;MAAS,MAAM;MAA2B,GAAG;MAAW;KAAE,UAAU;KAAQ,EAC3I;KAAE,KAAK;KAAQ,OAAO;MAAE,KAAK;MAAQ,MAAM;MAAa,OAAO;MAAS,MAAM;MAA4B,GAAG;MAAY;KAAE,UAAU;KAAQ,EAC7I;KAAE,KAAK;KAAQ,OAAO;MAAE,KAAK;MAAQ,MAAM;MAAa,OAAO;MAAS,MAAM;MAA2B,GAAG;MAAW;KAAE,UAAU;KAAQ,EAC3I;KAAE,KAAK;KAAQ,OAAO;MAAE,KAAK;MAAoB,OAAO;MAAW,MAAM;MAA+B,GAAG;MAAY;KAAE,UAAU;KAAQ,EAC3I;KAAE,KAAK;KAAQ,OAAO;MAAE,KAAK;MAAoB,OAAO;MAAW,MAAM;MAA8B,GAAG;MAAW;KAAE,UAAU;KAAQ,CAC1I;SAGD,MAAK,KACH;IAAE,KAAK;IAAQ,OAAO;KAAE,KAAK;KAAQ,MAAM;KAAa,OAAO;KAAS,MAAM;KAAsB;IAAE,UAAU;IAAQ,EACxH;IAAE,KAAK;IAAQ,OAAO;KAAE,KAAK;KAAQ,MAAM;KAAa,OAAO;KAAS,MAAM;KAAsB;IAAE,UAAU;IAAQ,EACxH;IAAE,KAAK;IAAQ,OAAO;KAAE,KAAK;KAAoB,OAAO;KAAW,MAAM;KAAyB;IAAE,UAAU;IAAQ,CACvH;AAGH,OAAI,iBACF,MAAK,KAAK;IACR,KAAK;IACL,OAAO;KAAE,KAAK;KAAY,MAAM;KAAqB;IACrD,UAAU;IACX,CAAC;AAGJ,QAAK,KAAK;IACR,KAAK;IACL,OAAO;KAAE,MAAM;KAAe,SAAS;KAAY;IACnD,UAAU;IACX,CAAC;AAMF,OAAI,QACF,MAAK,KAAK;IACR,KAAK;IACL,OAAO,EAAE;IACT,UAAU;IACV,UAAU;IACX,CAAQ;AAGX,UAAO;;EAGT,MAAM,iBAAiB;AACrB,OAAI,CAAC,QAAS;AAGd,SAAM,mBAAmB,KAAK,MAAM,MAAM,OAAO,QAAQ,OAAO,YAAY,IAAI,QAAQ,YAAY,iBAAiB,iBAAiB;AAGtI,OAAI,OAAO,QACT,MAAK,MAAM,CAAC,QAAQ,iBAAiB,OAAO,QAAQ,OAAO,QAAQ,CACjE,OAAM,mBAAmB,KAAK,MAAM,MAAM,aAAa,QAAQ,aAAa,YAAY,GAAG,OAAO,IAAI,QAAQ,YAAY,iBAAiB,iBAAiB;;EAInK;;;;;AAMH,SAAS,oBAAoB,UAAkB,SAAyB;AAKtE,QAAO,oDAHc,SAAS,MAAM,oBAAoB,GACzB,MAAM,YAE8B;;;;;;qBAMhD,gBAAgB,SAAS,CAAC;oBAC3B,gBAAgB,QAAQ,CAAC;;;AAI7C,SAAS,gBAAgB,KAAqB;AAC5C,QAAO,IACJ,QAAQ,cAAc,GAAG,CACzB,QAAQ,eAAe,GAAG,CAC1B,MAAM;;;;;;AAOX,SAAS,oBACP,KACA,QACA,SAC4E;AAC5E,KAAI,CAAC,OAAO,QAAS,QAAO;AAE5B,MAAK,MAAM,CAAC,QAAQ,iBAAiB,OAAO,QAAQ,OAAO,QAAQ,EAAE;EACnE,MAAM,SAAS,IAAI,OAAO;AAC1B,MAAI,IAAI,WAAW,OAAO,CACxB,QAAO;GACL;GACA;GACA,QAAQ,aAAa;GACrB,YAAY,KAAK,SAAS,aAAa,OAAO;GAC/C;;AAGL,QAAO;;;;;;AAOT,eAAe,mBAEb,SACA,QACA,YACA,QACA,QACA,YACA,iBACA,kBACe;CACf,MAAM,aAAa,KAAK,SAAS,OAAO;AACxC,KAAI,CAAC,WAAW,WAAW,EAAE;AAE3B,UAAQ,KAAK,8BAA8B,aAAa;AACxD;;AAMF,KAHc,OAAO,SAAS,OAAO,EAG1B;EACT,MAAM,aAAa,MAAM,SAAS,YAAY,QAAQ;EACtD,IAAI,WAAW;AAEf,MAAI,YAAY;GACd,MAAM,WAAW,KAAK,SAAS,WAAW;AAC1C,OAAI,WAAW,SAAS,CAEtB,YAAW,oBAAoB,YADf,MAAM,SAAS,UAAU,QAAQ,CACE;;AAIvD,OAAK,SAAS;GACZ,MAAM;GACN,UAAU,GAAG,OAAO;GACpB,QAAQ;GACT,CAAC;;AAIJ,KAAI,YAAY;EAEd,MAAM,WAAW,KAAK,SAAS,WAAW;EAC1C,MAAM,aAAa,WAAW,SAAS;AAEvC,OAAK,MAAM,EAAE,MAAM,UAAU,OAAO;GAElC,MAAM,YAAY,KAAK,QAAQ,eAAe,WAAW,CAAC,QAAQ,uBAAuB,WAAW,CAAC,QAAQ,YAAY,WAAW;GACpI,MAAM,WAAW,MAAM,YAAY,YAAY,KAAK;AACpD,OAAI,SACF,MAAK,SAAS;IAAE,MAAM;IAAS,UAAU,GAAG,SAAS;IAAa,QAAQ;IAAU,CAAC;AAIvF,OAAI,YAAY;IACd,MAAM,WAAW,KAAK,QAAQ,eAAe,UAAU,CAAC,QAAQ,uBAAuB,UAAU,CAAC,QAAQ,YAAY,UAAU;IAChI,MAAM,UAAU,MAAM,YAAY,UAAU,KAAK;AACjD,QAAI,QACF,MAAK,SAAS;KAAE,MAAM;KAAS,UAAU,GAAG,SAAS;KAAY,QAAQ;KAAS,CAAC;;;AAMzF,OAAK,MAAM,EAAE,MAAM,UAAU,OAAO;GAClC,MAAM,YAAY,MAAM,YAAY,YAAY,KAAK;AACrD,OAAI,UACF,MAAK,SAAS;IAAE,MAAM;IAAS,UAAU,GAAG,SAAS;IAAQ,QAAQ;IAAW,CAAC;;OAKrF,MAAK,MAAM,EAAE,MAAM,UAAU,OAAO;EAClC,MAAM,YAAY,MAAM,YAAY,YAAY,KAAK;AACrD,MAAI,UACF,MAAK,SAAS;GAAE,MAAM;GAAS,UAAU,GAAG,SAAS;GAAQ,QAAQ;GAAW,CAAC;;CAMvF,MAAM,MAAM,MAAM,YAAY,WAAW;AACzC,KAAI,IACF,MAAK,SAAS;EACZ,MAAM;EACN,UAAU,GAAG,OAAO;EACpB,QAAQ;EACT,CAAC;AAIJ,KAAI,kBAAkB;EACpB,MAAM,iBAAiB,SAAS,IAAI,OAAO,MAAM,GAAG,GAAG,KAAK;EAC5D,MAAM,WAAW;GACf,MAAM,OAAO,QAAQ;GACrB,YAAY,OAAO,QAAQ;GAC3B,OAAO,CACL;IAAE,KAAK,GAAG,eAAe;IAAgB,OAAO;IAAW,MAAM;IAAa,EAC9E;IAAE,KAAK,GAAG,eAAe;IAAgB,OAAO;IAAW,MAAM;IAAa,CAC/E;GACD,aAAa;GACb,kBAAkB;GAClB,SAAS;GACV;AAED,OAAK,SAAS;GACZ,MAAM;GACN,UAAU,GAAG,OAAO;GACpB,QAAQ,KAAK,UAAU,UAAU,MAAM,EAAE;GAC1C,CAAC;;;;;;;;;;;;;AAcN,SAAgB,aACd,QACA,QACqE;CACrE,MAAM,oBAAoB,UAAU,OAAO,UAAU;CACrD,MAAM,SAAS,oBAAoB,IAAI,WAAW;CAClD,MAAM,SAAS,oBAAoB,OAAO,QAAS,QAAS,SAAS,OAAO,QAAQ,SAAS,OAAO;CAEpG,MAAM,QAA6E,EAAE;AAErF,KAAI,MACF,OAAM,KAAK;EAAE,KAAK;EAAQ,MAAM;EAAiB,MAAM,GAAG,OAAO;EAAe,CAAC;AAGnF,OAAM,KACJ;EAAE,KAAK;EAAQ,MAAM;EAAa,OAAO;EAAS,MAAM,GAAG,OAAO;EAAqB,EACvF;EAAE,KAAK;EAAQ,MAAM;EAAa,OAAO;EAAS,MAAM,GAAG,OAAO;EAAqB,EACvF;EAAE,KAAK;EAAoB,OAAO;EAAW,MAAM,GAAG,OAAO;EAAwB,CACtF;AAED,KAAI,OAAO,aAAa,MACtB,OAAM,KAAK;EAAE,KAAK;EAAY,MAAM,GAAG,OAAO;EAAoB,CAAC;AAGrE,QAAO;;AAGT,eAAe,YAAY,OAAe,MAA0C;AAClF,KAAI;AAEF,SAAO,OADO,MAAM,OAAO,SAAS,MAAM,MAAM,EAAE,WAAW,EAAE,EAC5C,MAAM,CAAC,OAAO,MAAM,MAAM;GAAE,KAAK;GAAW,YAAY;IAAE,GAAG;IAAG,GAAG;IAAG,GAAG;IAAG,OAAO;IAAG;GAAE,CAAQ,CAAC,KAAK,CAAC,UAAU;SAC9H;AACN,oBAAkB;AAClB,SAAO;;;AAIX,eAAe,YAAY,OAA2C;AACpE,KAAI;EACF,MAAM,QAAQ,MAAM,OAAO,SAAS,MAAM,MAAM,EAAE,WAAW,EAAE;EAC/D,MAAM,QAAQ,MAAM,MAAM,MAAM,CAAC,OAAO,IAAI,IAAI;GAAE,KAAK;GAAW,YAAY;IAAE,GAAG;IAAG,GAAG;IAAG,GAAG;IAAG,OAAO;IAAG;GAAE,CAAQ,CAAC,KAAK,CAAC,UAAU;EACvI,MAAM,QAAQ,MAAM,MAAM,MAAM,CAAC,OAAO,IAAI,IAAI;GAAE,KAAK;GAAW,YAAY;IAAE,GAAG;IAAG,GAAG;IAAG,GAAG;IAAG,OAAO;IAAG;GAAE,CAAQ,CAAC,KAAK,CAAC,UAAU;AAGvI,SAAO,kBAAkB,CACvB;GAAE,QAAQ;GAAO,MAAM;GAAI,EAC3B;GAAE,QAAQ;GAAO,MAAM;GAAI,CAC5B,CAAC;SACI;AACN,oBAAkB;AAClB,SAAO;;;;AAUX,SAAgB,kBAAkB,SAAiC;CACjE,MAAM,aAAa;CACnB,MAAM,eAAe;CACrB,MAAM,UAAU,eAAe,QAAQ;CACvC,IAAI,aAAa,aAAa;CAG9B,MAAM,SAAS,OAAO,MAAM,WAAW;AACvC,QAAO,cAAc,GAAG,EAAE;AAC1B,QAAO,cAAc,GAAG,EAAE;AAC1B,QAAO,cAAc,QAAQ,QAAQ,EAAE;CAGvC,MAAM,aAAa,OAAO,MAAM,QAAQ;CACxC,MAAM,cAAwB,EAAE;AAEhC,MAAK,IAAI,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;EACvC,MAAM,QAAQ,QAAQ;EACtB,MAAM,SAAS,IAAI;AACnB,aAAW,WAAW,MAAM,SAAS,MAAM,IAAI,MAAM,MAAM,OAAO;AAClE,aAAW,WAAW,MAAM,SAAS,MAAM,IAAI,MAAM,MAAM,SAAS,EAAE;AACtE,aAAW,WAAW,GAAG,SAAS,EAAE;AACpC,aAAW,WAAW,GAAG,SAAS,EAAE;AACpC,aAAW,cAAc,GAAG,SAAS,EAAE;AACvC,aAAW,cAAc,IAAI,SAAS,EAAE;AACxC,aAAW,cAAc,MAAM,OAAO,QAAQ,SAAS,EAAE;AACzD,aAAW,cAAc,YAAY,SAAS,GAAG;AAEjD,gBAAc,MAAM,OAAO;AAC3B,cAAY,KAAK,MAAM,OAAO;;AAGhC,QAAO,OAAO,OAAO;EAAC;EAAQ;EAAY,GAAG;EAAY,CAAC;;;;;;AAS5D,SAAS,iBAAiB,KAAqB;CAG7C,MAAM,KAAK,GAAG,MAFO,IAAI,MAAM,oBAAoB,GACpB,MAAM,aACV,MAAM,IAAI,CAAC,IAAI,OAAO;CACjD,MAAM,OAAO,KAAK,IAAI,KAAK,IAAI,KAAK,GAAG;CACvC,MAAM,IAAI,OAAO;CACjB,MAAM,MAAM,KAAK,MAAM;CACvB,MAAM,MAAM,KAAK,MAAM;CACvB,MAAM,WAAW,IAAI;CAErB,MAAM,QAAQ,eAAe,GAAG,QAAQ,GAAG,OAAO,EAAE,gDAAgD,OAAO,IAAK,cAClG,GAAG,OAAO,GAAG,eAAe,SAAS;AAGnD,QAAO,IAAI,QAAQ,eAAe,GAAG,MAAM,QAAQ;;;;;;AAOrD,eAAe,iBAAiB,WAAuB,MAAmC;AACxF,KAAI;EACF,MAAM,QAAQ,MAAM,OAAO,SAAS,MAAM,MAAM,EAAE,WAAW,EAAE;EAC/D,MAAM,IAAI,KAAK,MAAM,OAAO,IAAK;EACjC,MAAM,IAAI,IAAI;EAGd,MAAM,WAAW,eAAe,EAAE,YAAY,EAAE;oBAChC,EAAE,QAAQ,EAAE,OAAO,EAAE;iBACxB,EAAE,OAAO,EAAE,eAJP,KAAK,MAAM,IAAI,IAAK,CAIW;;EAGhD,MAAM,WAAW,MAAM,MAAM,OAAO,KAAK,SAAS,CAAC,CAAC,KAAK,CAAC,UAAU;AAEpE,SAAO,MAAO,MAAM,OAAO,KAAK,UAAU,CAAC,CACxC,UAAU,CAAC;GACV,OAAO;GACP,SAAS;GACV,CAAC,CAAC,CACF,KAAK,CACL,UAAU;SACP;AAEN,SAAO"}
package/lib/font.js.map DELETED
@@ -1 +0,0 @@
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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"}