@pyreon/zero 0.12.2 → 0.12.3

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 (138) hide show
  1. package/lib/actions.js +97 -0
  2. package/lib/actions.js.map +1 -0
  3. package/lib/ai.js +503 -0
  4. package/lib/ai.js.map +1 -0
  5. package/lib/api-routes.js +137 -0
  6. package/lib/api-routes.js.map +1 -0
  7. package/lib/compression.js +80 -0
  8. package/lib/compression.js.map +1 -0
  9. package/lib/cors.js +57 -0
  10. package/lib/cors.js.map +1 -0
  11. package/lib/csp.js +119 -0
  12. package/lib/csp.js.map +1 -0
  13. package/lib/env.js +217 -0
  14. package/lib/env.js.map +1 -0
  15. package/lib/favicon.js +424 -0
  16. package/lib/favicon.js.map +1 -0
  17. package/lib/i18n-routing.js +167 -0
  18. package/lib/i18n-routing.js.map +1 -0
  19. package/lib/index.js +80 -22
  20. package/lib/index.js.map +1 -1
  21. package/lib/link.js +5 -0
  22. package/lib/link.js.map +1 -1
  23. package/lib/logger.js +78 -0
  24. package/lib/logger.js.map +1 -0
  25. package/lib/meta.js +336 -0
  26. package/lib/meta.js.map +1 -0
  27. package/lib/middleware.js +53 -0
  28. package/lib/middleware.js.map +1 -0
  29. package/lib/og-image.js +233 -0
  30. package/lib/og-image.js.map +1 -0
  31. package/lib/rate-limit.js +76 -0
  32. package/lib/rate-limit.js.map +1 -0
  33. package/lib/testing.js +179 -0
  34. package/lib/testing.js.map +1 -0
  35. package/lib/theme.js +11 -2
  36. package/lib/theme.js.map +1 -1
  37. package/lib/types/actions.d.ts +27 -24
  38. package/lib/types/actions.d.ts.map +1 -1
  39. package/lib/types/ai.d.ts +76 -95
  40. package/lib/types/ai.d.ts.map +1 -1
  41. package/lib/types/api-routes.d.ts +37 -33
  42. package/lib/types/api-routes.d.ts.map +1 -1
  43. package/lib/types/cache.d.ts +26 -22
  44. package/lib/types/cache.d.ts.map +1 -1
  45. package/lib/types/client.d.ts +13 -9
  46. package/lib/types/client.d.ts.map +1 -1
  47. package/lib/types/compression.d.ts +14 -10
  48. package/lib/types/compression.d.ts.map +1 -1
  49. package/lib/types/config.d.ts +39 -4
  50. package/lib/types/config.d.ts.map +1 -1
  51. package/lib/types/cors.d.ts +20 -16
  52. package/lib/types/cors.d.ts.map +1 -1
  53. package/lib/types/csp.d.ts +42 -61
  54. package/lib/types/csp.d.ts.map +1 -1
  55. package/lib/types/env.d.ts +26 -26
  56. package/lib/types/env.d.ts.map +1 -1
  57. package/lib/types/favicon.d.ts +58 -54
  58. package/lib/types/favicon.d.ts.map +1 -1
  59. package/lib/types/font.d.ts +68 -65
  60. package/lib/types/font.d.ts.map +1 -1
  61. package/lib/types/i18n-routing.d.ts +43 -37
  62. package/lib/types/i18n-routing.d.ts.map +1 -1
  63. package/lib/types/image-plugin.d.ts +49 -45
  64. package/lib/types/image-plugin.d.ts.map +1 -1
  65. package/lib/types/image.d.ts +47 -36
  66. package/lib/types/image.d.ts.map +1 -1
  67. package/lib/types/index.d.ts +1961 -56
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/link.d.ts +61 -56
  70. package/lib/types/link.d.ts.map +1 -1
  71. package/lib/types/logger.d.ts +37 -48
  72. package/lib/types/logger.d.ts.map +1 -1
  73. package/lib/types/meta.d.ts +180 -105
  74. package/lib/types/meta.d.ts.map +1 -1
  75. package/lib/types/middleware.d.ts +8 -4
  76. package/lib/types/middleware.d.ts.map +1 -1
  77. package/lib/types/og-image.d.ts +63 -59
  78. package/lib/types/og-image.d.ts.map +1 -1
  79. package/lib/types/rate-limit.d.ts +20 -16
  80. package/lib/types/rate-limit.d.ts.map +1 -1
  81. package/lib/types/script.d.ts +23 -19
  82. package/lib/types/script.d.ts.map +1 -1
  83. package/lib/types/seo.d.ts +47 -43
  84. package/lib/types/seo.d.ts.map +1 -1
  85. package/lib/types/testing.d.ts +64 -27
  86. package/lib/types/testing.d.ts.map +1 -1
  87. package/lib/types/theme.d.ts +22 -12
  88. package/lib/types/theme.d.ts.map +1 -1
  89. package/package.json +12 -12
  90. package/src/actions.ts +1 -3
  91. package/src/adapters/bun.ts +2 -0
  92. package/src/adapters/cloudflare.ts +2 -0
  93. package/src/adapters/netlify.ts +2 -0
  94. package/src/adapters/node.ts +2 -0
  95. package/src/adapters/validate.ts +16 -0
  96. package/src/adapters/vercel.ts +2 -0
  97. package/src/compression.ts +19 -3
  98. package/src/entry-server.ts +28 -5
  99. package/src/index.ts +1 -0
  100. package/src/link.tsx +6 -0
  101. package/src/meta.tsx +41 -13
  102. package/src/rate-limit.ts +11 -9
  103. package/src/theme.tsx +12 -1
  104. package/src/vite-plugin.ts +5 -1
  105. package/lib/types/adapters/bun.d.ts +0 -6
  106. package/lib/types/adapters/bun.d.ts.map +0 -1
  107. package/lib/types/adapters/cloudflare.d.ts +0 -26
  108. package/lib/types/adapters/cloudflare.d.ts.map +0 -1
  109. package/lib/types/adapters/index.d.ts +0 -13
  110. package/lib/types/adapters/index.d.ts.map +0 -1
  111. package/lib/types/adapters/netlify.d.ts +0 -21
  112. package/lib/types/adapters/netlify.d.ts.map +0 -1
  113. package/lib/types/adapters/node.d.ts +0 -6
  114. package/lib/types/adapters/node.d.ts.map +0 -1
  115. package/lib/types/adapters/static.d.ts +0 -7
  116. package/lib/types/adapters/static.d.ts.map +0 -1
  117. package/lib/types/adapters/vercel.d.ts +0 -21
  118. package/lib/types/adapters/vercel.d.ts.map +0 -1
  119. package/lib/types/app.d.ts +0 -24
  120. package/lib/types/app.d.ts.map +0 -1
  121. package/lib/types/entry-server.d.ts +0 -37
  122. package/lib/types/entry-server.d.ts.map +0 -1
  123. package/lib/types/error-overlay.d.ts +0 -6
  124. package/lib/types/error-overlay.d.ts.map +0 -1
  125. package/lib/types/fs-router.d.ts +0 -47
  126. package/lib/types/fs-router.d.ts.map +0 -1
  127. package/lib/types/isr.d.ts +0 -9
  128. package/lib/types/isr.d.ts.map +0 -1
  129. package/lib/types/not-found.d.ts +0 -7
  130. package/lib/types/not-found.d.ts.map +0 -1
  131. package/lib/types/types.d.ts +0 -111
  132. package/lib/types/types.d.ts.map +0 -1
  133. package/lib/types/utils/use-intersection-observer.d.ts +0 -10
  134. package/lib/types/utils/use-intersection-observer.d.ts.map +0 -1
  135. package/lib/types/utils/with-headers.d.ts +0 -6
  136. package/lib/types/utils/with-headers.d.ts.map +0 -1
  137. package/lib/types/vite-plugin.d.ts +0 -17
  138. package/lib/types/vite-plugin.d.ts.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"favicon.js","names":[],"sources":["../src/favicon.ts"],"sourcesContent":["import { existsSync } from 'node:fs'\nimport { readFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport type { Plugin } from 'vite'\n\nlet sharpWarned = false\nfunction warnSharpMissing() {\n if (sharpWarned) return\n sharpWarned = true\n // oxlint-disable-next-line no-console\n console.warn(\n '\\n[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\\n',\n )\n}\n\n// ─── Favicon generation plugin ──────────────────────────────────────────────\n//\n// Generates all favicon formats from a single source file (SVG or PNG):\n// - favicon.ico (16x16 + 32x32 combined)\n// - favicon.svg (copied if source is SVG)\n// - apple-touch-icon.png (180x180)\n// - icon-192.png (for web manifest)\n// - icon-512.png (for web manifest)\n// - site.webmanifest\n//\n// Usage:\n// import { faviconPlugin } from \"@pyreon/zero\"\n// export default { plugins: [zero(), faviconPlugin({ source: \"./icon.svg\" })] }\n\nexport interface FaviconLocaleConfig {\n /** Locale-specific source icon (SVG or PNG). */\n source: string\n /** Optional dark mode variant for this locale. */\n darkSource?: string\n}\n\nexport interface FaviconPluginConfig {\n /** Path to the source icon (SVG or PNG, at least 512x512 for PNG). */\n source: string\n /** Theme color for web manifest. Default: \"#ffffff\" */\n themeColor?: string\n /** Background color for web manifest. Default: \"#ffffff\" */\n backgroundColor?: string\n /** App name for web manifest. Uses package.json name if not set. */\n name?: string\n /** Generate web manifest. Default: true */\n manifest?: boolean\n /**\n * Dark mode favicon (SVG only).\n * When provided, the SVG favicon uses prefers-color-scheme media query\n * to switch between light and dark variants.\n */\n darkSource?: string\n /**\n * Locale-specific icon overrides. Each key is a locale code,\n * value is a source icon (and optional dark variant).\n * Locales not in this map use the base `source`.\n *\n * Generated files are placed under `/{locale}/` prefix:\n * /de/favicon.svg, /de/favicon-32x32.png, etc.\n *\n * @example\n * ```ts\n * faviconPlugin({\n * source: \"./icon.svg\",\n * locales: {\n * de: { source: \"./icon-de.svg\" },\n * cs: { source: \"./icon-cs.svg\" },\n * },\n * })\n * ```\n */\n locales?: Record<string, FaviconLocaleConfig>\n}\n\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 devCache = new Map<string, Uint8Array>()\n\n server.middlewares.use(async (req, res, next) => {\n const url = req.url ?? ''\n\n // Resolve locale-specific source: /{locale}/favicon.svg → locale source\n const localeSource = resolveLocaleSource(url, config, root)\n\n // Serve source as favicon.svg in dev\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 if (svgUrl.endsWith('/favicon.svg') && isSvgSource) {\n try {\n const content = await readFile(svgPath, 'utf-8')\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 /{locale}/favicon-32x32.png)\n const baseName = svgUrl.split('/').pop() ?? ''\n const sizeMatch = SIZES.find((s) => s.name === baseName)\n if (sizeMatch) {\n const cacheKey = `${svgPath}:${sizeMatch.size}`\n let png = devCache.get(cacheKey)\n if (!png) {\n const result = await resizeToPng(svgPath, sizeMatch.size)\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 tags: Array<{\n tag: string\n attrs: Record<string, string>\n injectTo: 'head'\n }> = []\n\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 tags.push(\n {\n tag: 'link',\n attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },\n injectTo: 'head',\n },\n {\n tag: 'link',\n attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' },\n injectTo: 'head',\n },\n {\n tag: 'link',\n attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },\n 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 return tags\n },\n\n async generateBundle() {\n if (!isBuild) return\n\n // Generate favicons for the base (default) source\n await generateFaviconSet.call(this, root, config.source, config.darkSource, '', config, themeColor, backgroundColor, generateManifest)\n\n // Generate locale-specific favicon sets\n if (config.locales) {\n for (const [locale, localeConfig] of Object.entries(config.locales)) {\n await generateFaviconSet.call(this, root, localeConfig.source, localeConfig.darkSource, `${locale}/`, config, themeColor, backgroundColor, generateManifest)\n }\n }\n },\n }\n}\n\n/**\n * Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.\n */\nfunction wrapSvgWithDarkMode(lightSvg: string, darkSvg: string): string {\n // Extract viewBox from light SVG\n const viewBoxMatch = lightSvg.match(/viewBox=\"([^\"]*)\"/)\n const viewBox = viewBoxMatch?.[1] ?? '0 0 32 32'\n\n return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${viewBox}\">\n <style>\n :root { color-scheme: light dark; }\n @media (prefers-color-scheme: dark) { .light { display: none; } }\n @media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }\n </style>\n <g class=\"light\">${stripSvgWrapper(lightSvg)}</g>\n <g class=\"dark\">${stripSvgWrapper(darkSvg)}</g>\n</svg>`\n}\n\nfunction stripSvgWrapper(svg: string): string {\n return svg\n .replace(/<svg[^>]*>/, '')\n .replace(/<\\/svg>\\s*$/, '')\n .trim()\n}\n\n/**\n * Resolve the source path for a locale-prefixed favicon URL.\n * Returns null if the URL is not locale-prefixed or locale has no override.\n */\nfunction resolveLocaleSource(\n url: string,\n config: FaviconPluginConfig,\n rootDir: string,\n): { locale: string; url: string; source: string; sourcePath: string } | null {\n if (!config.locales) return null\n\n for (const [locale, localeConfig] of Object.entries(config.locales)) {\n const prefix = `/${locale}/`\n if (url.startsWith(prefix)) {\n return {\n locale,\n url,\n source: localeConfig.source,\n sourcePath: join(rootDir, localeConfig.source),\n }\n }\n }\n return null\n}\n\n/**\n * Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.\n * Called once for base (prefix = '') and once per locale (prefix = '{locale}/').\n */\nasync function generateFaviconSet(\n this: any,\n rootDir: string,\n source: string,\n darkSource: string | undefined,\n prefix: string,\n config: FaviconPluginConfig,\n themeColor: string,\n backgroundColor: string,\n generateManifest: boolean,\n): Promise<void> {\n const sourcePath = join(rootDir, source)\n if (!existsSync(sourcePath)) {\n // oxlint-disable-next-line no-console\n console.warn(`[zero:favicon] Source not found: ${sourcePath}`)\n return\n }\n\n const isSvg = source.endsWith('.svg')\n\n // Copy SVG as favicon.svg\n if (isSvg) {\n const svgContent = await readFile(sourcePath, 'utf-8')\n let finalSvg = svgContent\n\n if (darkSource) {\n const darkPath = join(rootDir, darkSource)\n if (existsSync(darkPath)) {\n const darkSvg = await readFile(darkPath, 'utf-8')\n finalSvg = wrapSvgWithDarkMode(svgContent, darkSvg)\n }\n }\n\n this.emitFile({\n type: 'asset',\n fileName: `${prefix}favicon.svg`,\n source: finalSvg,\n })\n }\n\n // Generate PNG sizes via sharp\n for (const { size, name } of SIZES) {\n const pngBuffer = await resizeToPng(sourcePath, size)\n if (pngBuffer) {\n this.emitFile({\n type: 'asset',\n fileName: `${prefix}${name}`,\n 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"],"mappings":"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"}
@@ -0,0 +1,167 @@
1
+ import { createContext } from "@pyreon/core";
2
+ import { signal } from "@pyreon/reactivity";
3
+
4
+ //#region src/i18n-routing.ts
5
+ /**
6
+ * Detect preferred locale from Accept-Language header.
7
+ */
8
+ function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
9
+ if (!acceptLanguage) return defaultLocale;
10
+ const preferred = acceptLanguage.split(",").map((part) => {
11
+ const [lang, q] = part.trim().split(";q=");
12
+ return {
13
+ lang: lang?.split("-")[0]?.toLowerCase() ?? "",
14
+ quality: q ? Number.parseFloat(q) : 1
15
+ };
16
+ }).sort((a, b) => b.quality - a.quality);
17
+ for (const { lang } of preferred) if (locales.includes(lang)) return lang;
18
+ return defaultLocale;
19
+ }
20
+ /**
21
+ * Extract locale from a URL path.
22
+ * Returns { locale, pathWithoutLocale }.
23
+ */
24
+ function extractLocaleFromPath(path, locales, defaultLocale) {
25
+ const segments = path.split("/").filter(Boolean);
26
+ const firstSegment = segments[0]?.toLowerCase();
27
+ if (firstSegment && locales.includes(firstSegment)) return {
28
+ locale: firstSegment,
29
+ pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
30
+ };
31
+ return {
32
+ locale: defaultLocale,
33
+ pathWithoutLocale: path
34
+ };
35
+ }
36
+ /**
37
+ * Build a localized path.
38
+ */
39
+ function buildLocalePath(path, locale, defaultLocale, strategy) {
40
+ const clean = path === "/" ? "" : path;
41
+ if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
42
+ return `/${locale}${clean}`;
43
+ }
44
+ /**
45
+ * Create a LocaleContext for use in components and loaders.
46
+ */
47
+ function createLocaleContext(locale, path, config) {
48
+ const strategy = config.strategy ?? "prefix-except-default";
49
+ return {
50
+ locale,
51
+ locales: config.locales,
52
+ defaultLocale: config.defaultLocale,
53
+ localePath(targetPath, targetLocale) {
54
+ return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
55
+ },
56
+ alternates() {
57
+ const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
58
+ return config.locales.map((loc) => ({
59
+ locale: loc,
60
+ url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
61
+ }));
62
+ }
63
+ };
64
+ }
65
+ /**
66
+ * I18n routing middleware for Zero's server.
67
+ *
68
+ * - Detects locale from URL prefix or Accept-Language header
69
+ * - Redirects root to preferred locale (when detectLocale is true)
70
+ * - Sets locale context for loaders and components
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * // zero.config.ts
75
+ * import { i18nRouting } from "@pyreon/zero"
76
+ *
77
+ * export default defineConfig({
78
+ * plugins: [
79
+ * i18nRouting({
80
+ * locales: ["en", "de", "cs"],
81
+ * defaultLocale: "en",
82
+ * }),
83
+ * ],
84
+ * })
85
+ * ```
86
+ */
87
+ function i18nRouting(config) {
88
+ const strategy = config.strategy ?? "prefix-except-default";
89
+ const detectEnabled = config.detectLocale !== false;
90
+ const cookieName = config.cookieName ?? "locale";
91
+ return {
92
+ name: "pyreon-zero-i18n-routing",
93
+ configResolved() {},
94
+ configureServer(server) {
95
+ server.middlewares.use((req, res, next) => {
96
+ const url = req.url ?? "/";
97
+ if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
98
+ const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
99
+ if (detectEnabled && url === "/") {
100
+ const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
101
+ const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
102
+ const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
103
+ if (strategy === "prefix" || preferred !== config.defaultLocale) {
104
+ res.writeHead(302, { Location: `/${preferred}/` });
105
+ res.end();
106
+ return;
107
+ }
108
+ }
109
+ req.__locale = locale;
110
+ req.__localeContext = createLocaleContext(locale, url, config);
111
+ localeSignal.set(locale);
112
+ next();
113
+ });
114
+ }
115
+ };
116
+ }
117
+ function parseCookies(header) {
118
+ if (!header) return {};
119
+ const result = {};
120
+ for (const pair of header.split(";")) {
121
+ const [key, value] = pair.trim().split("=");
122
+ if (key && value) result[key] = decodeURIComponent(value);
123
+ }
124
+ return result;
125
+ }
126
+ /** @internal Context for the current locale. */
127
+ const LocaleCtx = createContext("en");
128
+ /** Current locale signal — set by the server middleware or client-side detection. */
129
+ const localeSignal = signal("en");
130
+ /**
131
+ * Read the current locale reactively.
132
+ *
133
+ * Returns the locale signal value directly — reactive in both SSR and CSR.
134
+ * The server middleware sets `localeSignal` per-request, and client-side
135
+ * `setLocale()` updates it as well.
136
+ *
137
+ * @example
138
+ * ```tsx
139
+ * const locale = useLocale() // "en", "de", etc.
140
+ * ```
141
+ */
142
+ function useLocale() {
143
+ return localeSignal();
144
+ }
145
+ /**
146
+ * Set the locale client-side and update the URL.
147
+ *
148
+ * @example
149
+ * ```tsx
150
+ * <button onClick={() => setLocale('de')}>Deutsch</button>
151
+ * ```
152
+ */
153
+ function setLocale(locale, config) {
154
+ localeSignal.set(locale);
155
+ if (typeof document !== "undefined") document.cookie = `${config.cookieName ?? "locale"}=${locale}; path=/; max-age=31536000`;
156
+ if (typeof window !== "undefined") {
157
+ const strategy = config.strategy ?? "prefix-except-default";
158
+ const { pathWithoutLocale } = extractLocaleFromPath(window.location.pathname, config.locales, config.defaultLocale);
159
+ const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy);
160
+ window.history.pushState(null, "", newPath);
161
+ window.dispatchEvent(new PopStateEvent("popstate"));
162
+ }
163
+ }
164
+
165
+ //#endregion
166
+ export { LocaleCtx, buildLocalePath, createLocaleContext, detectLocaleFromHeader, extractLocaleFromPath, i18nRouting, localeSignal, setLocale, useLocale };
167
+ //# sourceMappingURL=i18n-routing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"i18n-routing.js","names":[],"sources":["../src/i18n-routing.ts"],"sourcesContent":["import { createContext } from '@pyreon/core'\nimport { signal } from '@pyreon/reactivity'\nimport type { Plugin } from 'vite'\n\n// ─── Localized routing ─────────────────────────────────────────────────────\n//\n// Adds locale-prefixed routes to Zero's file-system router:\n// - /about → /en/about, /de/about, /cs/about\n// - / → /en, /de, /cs (or default locale without prefix)\n// - Automatic locale detection from Accept-Language header\n// - Redirect to preferred locale\n// - hreflang link generation\n//\n// Usage:\n// import { i18nRouting } from \"@pyreon/zero\"\n// export default { plugins: [zero(), i18nRouting({ locales: [\"en\", \"de\"], defaultLocale: \"en\" })] }\n\nexport interface I18nRoutingConfig {\n /** Supported locales. e.g. [\"en\", \"de\", \"cs\"] */\n locales: string[]\n /** Default locale — served without prefix (/ instead of /en/). */\n defaultLocale: string\n /** Redirect root to detected locale. Default: true */\n detectLocale?: boolean\n /** Cookie name to persist locale preference. Default: \"locale\" */\n cookieName?: string\n /** URL strategy. Default: \"prefix-except-default\" */\n strategy?: 'prefix' | 'prefix-except-default'\n}\n\nexport interface LocaleContext {\n /** Current locale code. e.g. \"en\", \"de\" */\n locale: string\n /** All supported locales. */\n locales: string[]\n /** Default locale. */\n defaultLocale: string\n /** Build a localized path. e.g. localePath(\"/about\", \"de\") → \"/de/about\" */\n localePath: (path: string, locale?: string) => string\n /** Get hreflang alternates for the current path. */\n alternates: () => Array<{ locale: string; url: string }>\n}\n\n/**\n * Detect preferred locale from Accept-Language header.\n */\nexport function detectLocaleFromHeader(\n acceptLanguage: string | null | undefined,\n locales: string[],\n defaultLocale: string,\n): string {\n if (!acceptLanguage) return defaultLocale\n\n // Parse Accept-Language: en-US,en;q=0.9,de;q=0.8\n const preferred = acceptLanguage\n .split(',')\n .map((part) => {\n const [lang, q] = part.trim().split(';q=')\n return {\n lang: lang?.split('-')[0]?.toLowerCase() ?? '',\n quality: q ? Number.parseFloat(q) : 1,\n }\n })\n .sort((a, b) => b.quality - a.quality)\n\n for (const { lang } of preferred) {\n if (locales.includes(lang)) return lang\n }\n\n return defaultLocale\n}\n\n/**\n * Extract locale from a URL path.\n * Returns { locale, pathWithoutLocale }.\n */\nexport function extractLocaleFromPath(\n path: string,\n locales: string[],\n defaultLocale: string,\n): { locale: string; pathWithoutLocale: string } {\n const segments = path.split('/').filter(Boolean)\n const firstSegment = segments[0]?.toLowerCase()\n\n if (firstSegment && locales.includes(firstSegment)) {\n return {\n locale: firstSegment,\n pathWithoutLocale: '/' + segments.slice(1).join('/') || '/',\n }\n }\n\n return { locale: defaultLocale, pathWithoutLocale: path }\n}\n\n/**\n * Build a localized path.\n */\nexport function buildLocalePath(\n path: string,\n locale: string,\n defaultLocale: string,\n strategy: 'prefix' | 'prefix-except-default',\n): string {\n const clean = path === '/' ? '' : path\n if (strategy === 'prefix-except-default' && locale === defaultLocale) {\n return path\n }\n return `/${locale}${clean}`\n}\n\n/**\n * Create a LocaleContext for use in components and loaders.\n */\nexport function createLocaleContext(\n locale: string,\n path: string,\n config: I18nRoutingConfig,\n): LocaleContext {\n const strategy = config.strategy ?? 'prefix-except-default'\n\n return {\n locale,\n locales: config.locales,\n defaultLocale: config.defaultLocale,\n\n localePath(targetPath: string, targetLocale?: string) {\n return buildLocalePath(\n targetPath,\n targetLocale ?? locale,\n config.defaultLocale,\n strategy,\n )\n },\n\n alternates() {\n const { pathWithoutLocale } = extractLocaleFromPath(\n path,\n config.locales,\n config.defaultLocale,\n )\n return config.locales.map((loc) => ({\n locale: loc,\n url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy),\n }))\n },\n }\n}\n\n/**\n * I18n routing middleware for Zero's server.\n *\n * - Detects locale from URL prefix or Accept-Language header\n * - Redirects root to preferred locale (when detectLocale is true)\n * - Sets locale context for loaders and components\n *\n * @example\n * ```ts\n * // zero.config.ts\n * import { i18nRouting } from \"@pyreon/zero\"\n *\n * export default defineConfig({\n * plugins: [\n * i18nRouting({\n * locales: [\"en\", \"de\", \"cs\"],\n * defaultLocale: \"en\",\n * }),\n * ],\n * })\n * ```\n */\nexport function i18nRouting(config: I18nRoutingConfig): Plugin {\n const strategy = config.strategy ?? 'prefix-except-default'\n const detectEnabled = config.detectLocale !== false\n const cookieName = config.cookieName ?? 'locale'\n\n return {\n name: 'pyreon-zero-i18n-routing',\n\n // Route duplication is NOT handled here. The fs-router's `scanRouteFiles`\n // consumes the i18n config to duplicate routes per locale at build time.\n // This plugin only provides: (1) the server middleware for locale detection\n // and (2) the runtime hooks (useLocale, setLocale) for client-side use.\n configResolved() {},\n\n configureServer(server) {\n server.middlewares.use((req, res, next) => {\n const url = req.url ?? '/'\n\n // Skip static assets\n if (url.startsWith('/@') || url.startsWith('/__') || url.includes('.')) {\n return next()\n }\n\n const { locale } = extractLocaleFromPath(\n url,\n config.locales,\n config.defaultLocale,\n )\n\n // Redirect root to detected locale\n if (detectEnabled && url === '/') {\n const cookies = parseCookies(req.headers.cookie)\n const preferredFromCookie = cookies[cookieName]\n const preferredFromHeader = detectLocaleFromHeader(\n req.headers['accept-language'],\n config.locales,\n config.defaultLocale,\n )\n const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie)\n ? preferredFromCookie\n : preferredFromHeader\n\n if (strategy === 'prefix' || preferred !== config.defaultLocale) {\n res.writeHead(302, { Location: `/${preferred}/` })\n res.end()\n return\n }\n }\n\n // Attach locale context to request for loaders\n ;(req as any).__locale = locale\n ;(req as any).__localeContext = createLocaleContext(locale, url, config)\n\n // Update the module-level signal so useLocale() returns the correct value\n localeSignal.set(locale)\n\n next()\n })\n },\n }\n}\n\nfunction parseCookies(header: string | undefined): Record<string, string> {\n if (!header) return {}\n const result: Record<string, string> = {}\n for (const pair of header.split(';')) {\n const [key, value] = pair.trim().split('=')\n if (key && value) result[key] = decodeURIComponent(value)\n }\n return result\n}\n\n// ─── Reactive locale hook ───────────────────────────────────────────────────\n\n/** @internal Context for the current locale. */\nexport const LocaleCtx = createContext<string>('en')\n\n/** Current locale signal — set by the server middleware or client-side detection. */\nexport const localeSignal = signal('en')\n\n/**\n * Read the current locale reactively.\n *\n * Returns the locale signal value directly — reactive in both SSR and CSR.\n * The server middleware sets `localeSignal` per-request, and client-side\n * `setLocale()` updates it as well.\n *\n * @example\n * ```tsx\n * const locale = useLocale() // \"en\", \"de\", etc.\n * ```\n */\nexport function useLocale(): string {\n return localeSignal()\n}\n\n/**\n * Set the locale client-side and update the URL.\n *\n * @example\n * ```tsx\n * <button onClick={() => setLocale('de')}>Deutsch</button>\n * ```\n */\nexport function setLocale(\n locale: string,\n config: I18nRoutingConfig,\n): void {\n localeSignal.set(locale)\n\n // Persist to cookie\n if (typeof document !== 'undefined') {\n document.cookie = `${config.cookieName ?? 'locale'}=${locale}; path=/; max-age=31536000`\n }\n\n // Navigate to localized URL — use pushState to avoid full page reload\n if (typeof window !== 'undefined') {\n const strategy = config.strategy ?? 'prefix-except-default'\n const { pathWithoutLocale } = extractLocaleFromPath(\n window.location.pathname,\n config.locales,\n config.defaultLocale,\n )\n const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy)\n window.history.pushState(null, '', newPath)\n // Dispatch popstate so @pyreon/router picks up the URL change\n window.dispatchEvent(new PopStateEvent('popstate'))\n }\n}\n"],"mappings":"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"}
package/lib/index.js CHANGED
@@ -211,14 +211,23 @@ function createRouteMiddlewareDispatcher(entries) {
211
211
  }
212
212
  };
213
213
  }
214
- /** Simple URL pattern matcher supporting :param and :param* segments. */
214
+ /**
215
+ * URL pattern matcher supporting :param and :param* segments.
216
+ *
217
+ * Rules:
218
+ * - Static segments must match exactly
219
+ * - `:param` matches a single path segment
220
+ * - `:param*` matches all remaining segments (must be last, and path must
221
+ * have matched all preceding segments)
222
+ * - Path length must match pattern length (unless catch-all)
223
+ */
215
224
  function matchPattern(pattern, path) {
216
225
  const patternParts = pattern.split("/").filter(Boolean);
217
226
  const pathParts = path.split("/").filter(Boolean);
218
227
  for (let i = 0; i < patternParts.length; i++) {
219
228
  const pp = patternParts[i];
220
- if (!pp) continue;
221
- if (pp.endsWith("*")) return true;
229
+ if (pp.endsWith("*")) return i <= pathParts.length;
230
+ if (i >= pathParts.length) return false;
222
231
  if (pp.startsWith(":")) continue;
223
232
  if (pp !== pathParts[i]) return false;
224
233
  }
@@ -496,7 +505,10 @@ function zeroPlugin(userConfig = {}) {
496
505
  if (/\.\w+$/.test(pathname)) return next();
497
506
  handle404(server, routesDir, pathname, res).then((handled) => {
498
507
  if (!handled) next();
499
- }, () => next());
508
+ }, (err) => {
509
+ console.error("[zero] Error in 404 handler:", err);
510
+ next();
511
+ });
500
512
  });
501
513
  server.middlewares.use((req, res, next) => {
502
514
  if (!(req.headers.accept ?? "").includes("text/html")) return next();
@@ -652,6 +664,19 @@ function createISRHandler(handler, config) {
652
664
  };
653
665
  }
654
666
 
667
+ //#endregion
668
+ //#region src/adapters/validate.ts
669
+ /**
670
+ * Validate that adapter build inputs exist before copying.
671
+ * Throws with a clear error message if directories are missing.
672
+ * @internal
673
+ */
674
+ async function validateBuildInputs(options) {
675
+ const { existsSync } = await import("node:fs");
676
+ if (!existsSync(options.clientOutDir)) throw new Error(`[zero:adapter] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`);
677
+ if (!existsSync(options.serverEntry)) throw new Error(`[zero:adapter] Server entry not found: ${options.serverEntry}. Run "vite build --ssr" first.`);
678
+ }
679
+
655
680
  //#endregion
656
681
  //#region src/adapters/bun.ts
657
682
  /**
@@ -661,6 +686,7 @@ function bunAdapter() {
661
686
  return {
662
687
  name: "bun",
663
688
  async build(options) {
689
+ await validateBuildInputs(options);
664
690
  const { writeFile, cp, mkdir } = await import("node:fs/promises");
665
691
  const { join } = await import("node:path");
666
692
  const outDir = options.outDir;
@@ -738,6 +764,7 @@ function cloudflareAdapter() {
738
764
  return {
739
765
  name: "cloudflare",
740
766
  async build(options) {
767
+ await validateBuildInputs(options);
741
768
  const { writeFile, cp, mkdir } = await import("node:fs/promises");
742
769
  const { join } = await import("node:path");
743
770
  const outDir = options.outDir;
@@ -808,6 +835,7 @@ function netlifyAdapter() {
808
835
  return {
809
836
  name: "netlify",
810
837
  async build(options) {
838
+ await validateBuildInputs(options);
811
839
  const { writeFile, cp, mkdir } = await import("node:fs/promises");
812
840
  const { join } = await import("node:path");
813
841
  const outDir = options.outDir;
@@ -864,6 +892,7 @@ function nodeAdapter() {
864
892
  return {
865
893
  name: "node",
866
894
  async build(options) {
895
+ await validateBuildInputs(options);
867
896
  const { writeFile, cp, mkdir } = await import("node:fs/promises");
868
897
  const { join } = await import("node:path");
869
898
  const outDir = options.outDir;
@@ -997,6 +1026,7 @@ function vercelAdapter() {
997
1026
  return {
998
1027
  name: "vercel",
999
1028
  async build(options) {
1029
+ await validateBuildInputs(options);
1000
1030
  const { writeFile, cp, mkdir } = await import("node:fs/promises");
1001
1031
  const { join } = await import("node:path");
1002
1032
  const vercelDir = join(options.outDir, ".vercel", "output");
@@ -1217,9 +1247,14 @@ function Image(props) {
1217
1247
 
1218
1248
  //#endregion
1219
1249
  //#region src/link.tsx
1250
+ const MAX_PREFETCH_CACHE = 200;
1220
1251
  const prefetched = /* @__PURE__ */ new Set();
1221
1252
  function doPrefetch(href) {
1222
1253
  if (prefetched.has(href)) return;
1254
+ if (prefetched.size >= MAX_PREFETCH_CACHE) {
1255
+ const first = prefetched.values().next().value;
1256
+ if (first) prefetched.delete(first);
1257
+ }
1223
1258
  prefetched.add(href);
1224
1259
  const docLink = document.createElement("link");
1225
1260
  docLink.rel = "prefetch";
@@ -2160,11 +2195,20 @@ async function generateBlurPlaceholder(input, size) {
2160
2195
  const STORAGE_KEY = "zero-theme";
2161
2196
  /** Reactive theme signal. */
2162
2197
  const theme = signal("system");
2198
+ /** SSR fallback when system preference can't be detected. Default: 'light'. */
2199
+ let _ssrDefault = "light";
2200
+ /**
2201
+ * Set the default theme for SSR (when `matchMedia` is unavailable).
2202
+ * Call once at server startup before rendering.
2203
+ */
2204
+ function setSSRThemeDefault(value) {
2205
+ _ssrDefault = value;
2206
+ }
2163
2207
  /** Computed resolved theme (what's actually applied). */
2164
2208
  function resolvedTheme() {
2165
2209
  const t = theme();
2166
2210
  if (t === "system") {
2167
- if (typeof window === "undefined") return "dark";
2211
+ if (typeof window === "undefined") return _ssrDefault;
2168
2212
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
2169
2213
  }
2170
2214
  return t;
@@ -2526,16 +2570,19 @@ function rateLimitMiddleware(config = {}) {
2526
2570
  const { max = 100, window: windowSec = 60, keyFn = defaultKeyFn, onLimit, include, exclude } = config;
2527
2571
  const windowMs = windowSec * 1e3;
2528
2572
  const store = /* @__PURE__ */ new Map();
2529
- const cleanupInterval = setInterval(() => {
2530
- const now = Date.now();
2573
+ const MAX_STORE_SIZE = 1e4;
2574
+ let lastCleanup = Date.now();
2575
+ function cleanupIfNeeded(now) {
2576
+ if (store.size < MAX_STORE_SIZE / 2 && now - lastCleanup < windowMs) return;
2577
+ lastCleanup = now;
2531
2578
  for (const [key, entry] of store) if (entry.resetAt <= now) store.delete(key);
2532
- }, windowMs);
2533
- if (typeof cleanupInterval === "object" && "unref" in cleanupInterval) cleanupInterval.unref();
2579
+ }
2534
2580
  return (ctx) => {
2535
2581
  if (include && !include.some((p) => matchSimpleGlob(p, ctx.path))) return;
2536
2582
  if (exclude?.some((p) => matchSimpleGlob(p, ctx.path))) return;
2537
2583
  const key = keyFn(ctx);
2538
2584
  const now = Date.now();
2585
+ cleanupIfNeeded(now);
2539
2586
  let entry = store.get(key);
2540
2587
  if (!entry || entry.resetAt <= now) {
2541
2588
  entry = {
@@ -2637,15 +2684,24 @@ function isCompressible(contentType) {
2637
2684
  return COMPRESSIBLE_TYPES.some((t) => contentType.includes(t));
2638
2685
  }
2639
2686
  async function compress(data, encoding) {
2640
- const format = encoding === "gzip" ? "gzip" : "deflate";
2641
- const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(format));
2642
- return new Response(stream).arrayBuffer();
2687
+ if (typeof CompressionStream !== "undefined") {
2688
+ const format = encoding === "gzip" ? "gzip" : "deflate";
2689
+ const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(format));
2690
+ return new Response(stream).arrayBuffer();
2691
+ }
2692
+ try {
2693
+ const zlib = await import("node:zlib");
2694
+ const { promisify } = await import("node:util");
2695
+ const result = await (encoding === "gzip" ? promisify(zlib.gzip) : promisify(zlib.deflate))(Buffer.from(data));
2696
+ return result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength);
2697
+ } catch {
2698
+ return data;
2699
+ }
2643
2700
  }
2644
2701
 
2645
2702
  //#endregion
2646
2703
  //#region src/actions.ts
2647
2704
  const actionRegistry = /* @__PURE__ */ new Map();
2648
- let actionCounter = 0;
2649
2705
  /**
2650
2706
  * Define a server action. Returns a callable function that:
2651
2707
  * - On the **client**: sends a POST request to `/_zero/actions/<id>`
@@ -2663,7 +2719,7 @@ let actionCounter = 0;
2663
2719
  * const result = await createPost({ title: 'Hello', body: '...' })
2664
2720
  */
2665
2721
  function defineAction(handler) {
2666
- const id = `action_${actionCounter++}`;
2722
+ const id = `action_${crypto.randomUUID().slice(0, 8)}`;
2667
2723
  actionRegistry.set(id, {
2668
2724
  id,
2669
2725
  handler
@@ -3559,7 +3615,7 @@ const resolveStr = (v) => typeof v === "function" ? v() : v;
3559
3615
  function Meta(props) {
3560
3616
  const hasReactiveTitle = typeof props.title === "function";
3561
3617
  const hasReactiveDescription = typeof props.description === "function";
3562
- if (hasReactiveTitle || hasReactiveDescription) useHead((() => {
3618
+ if (hasReactiveTitle || hasReactiveDescription) useHead(() => {
3563
3619
  const title = resolveStr(props.title);
3564
3620
  const description = resolveStr(props.description);
3565
3621
  const tags = buildMetaTags({
@@ -3567,13 +3623,14 @@ function Meta(props) {
3567
3623
  title,
3568
3624
  description
3569
3625
  });
3570
- return {
3571
- title,
3626
+ const input = {
3572
3627
  meta: tags.meta,
3573
3628
  link: tags.link,
3574
3629
  script: tags.script
3575
3630
  };
3576
- }));
3631
+ if (title) input.title = title;
3632
+ return input;
3633
+ });
3577
3634
  else {
3578
3635
  const title = resolveStr(props.title);
3579
3636
  const description = resolveStr(props.description);
@@ -3582,12 +3639,13 @@ function Meta(props) {
3582
3639
  title,
3583
3640
  description
3584
3641
  });
3585
- useHead({
3586
- title,
3642
+ const input = {
3587
3643
  meta: tags.meta,
3588
3644
  link: tags.link,
3589
3645
  script: tags.script
3590
- });
3646
+ };
3647
+ if (title) input.title = title;
3648
+ useHead(input);
3591
3649
  }
3592
3650
  return props.children ?? null;
3593
3651
  }
@@ -4595,5 +4653,5 @@ function capitalize(s) {
4595
4653
  }
4596
4654
 
4597
4655
  //#endregion
4598
- export { Image, Link, Meta, Script, ThemeToggle, aiPlugin, bool, buildCspHeader, buildLocalePath, buildMetaTags, bunAdapter, cacheMiddleware, cloudflareAdapter, compose, compressResponse, compressionMiddleware, corsMiddleware, createActionMiddleware, createApiMiddleware, createApp, createISRHandler, createLink, createLocaleContext, createServer, cspMiddleware, zeroPlugin as default, defineAction, defineConfig, detectLocaleFromHeader, extractLocaleFromPath, faviconLinks, faviconPlugin, filePathToUrlPath, fontPlugin, fontVariables, generateAiPluginManifest, generateApiRouteModule, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateOpenApiSpec, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, imagePlugin, inferJsonLd, initTheme, isCompressible, jsonLd, loggerMiddleware, netlifyAdapter, nodeAdapter, num, ogImagePath, ogImagePlugin, oneOf, parseFileRoutes, prefetchRoute, publicEnv, rateLimitMiddleware, render404Page, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, schema, securityHeaders, seoMiddleware, seoPlugin, setLocale, setTheme, staticAdapter, str, theme, themeScript, toggleTheme, url, useLink, useLocale, useNonce, validateEnv, varyEncoding, vercelAdapter };
4656
+ export { Image, Link, Meta, Script, ThemeToggle, aiPlugin, bool, buildCspHeader, buildLocalePath, buildMetaTags, bunAdapter, cacheMiddleware, cloudflareAdapter, compose, compressResponse, compressionMiddleware, corsMiddleware, createActionMiddleware, createApiMiddleware, createApp, createISRHandler, createLink, createLocaleContext, createServer, cspMiddleware, zeroPlugin as default, defineAction, defineConfig, detectLocaleFromHeader, extractLocaleFromPath, faviconLinks, faviconPlugin, filePathToUrlPath, fontPlugin, fontVariables, generateAiPluginManifest, generateApiRouteModule, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateOpenApiSpec, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, imagePlugin, inferJsonLd, initTheme, isCompressible, jsonLd, loggerMiddleware, netlifyAdapter, nodeAdapter, num, ogImagePath, ogImagePlugin, oneOf, parseFileRoutes, prefetchRoute, publicEnv, rateLimitMiddleware, render404Page, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, schema, securityHeaders, seoMiddleware, seoPlugin, setLocale, setSSRThemeDefault, setTheme, staticAdapter, str, theme, themeScript, toggleTheme, url, useLink, useLocale, useNonce, validateEnv, varyEncoding, vercelAdapter };
4599
4657
  //# sourceMappingURL=index.js.map