@pyreon/zero 0.12.2 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/actions.js +97 -0
- package/lib/actions.js.map +1 -0
- package/lib/ai.js +503 -0
- package/lib/ai.js.map +1 -0
- package/lib/api-routes.js +137 -0
- package/lib/api-routes.js.map +1 -0
- package/lib/compression.js +80 -0
- package/lib/compression.js.map +1 -0
- package/lib/cors.js +57 -0
- package/lib/cors.js.map +1 -0
- package/lib/csp.js +119 -0
- package/lib/csp.js.map +1 -0
- package/lib/env.js +217 -0
- package/lib/env.js.map +1 -0
- package/lib/favicon.js +424 -0
- package/lib/favicon.js.map +1 -0
- package/lib/i18n-routing.js +167 -0
- package/lib/i18n-routing.js.map +1 -0
- package/lib/index.js +340 -4015
- package/lib/index.js.map +1 -1
- package/lib/link.js +5 -0
- package/lib/link.js.map +1 -1
- package/lib/logger.js +78 -0
- package/lib/logger.js.map +1 -0
- package/lib/meta.js +310 -0
- package/lib/meta.js.map +1 -0
- package/lib/middleware.js +53 -0
- package/lib/middleware.js.map +1 -0
- package/lib/og-image.js +233 -0
- package/lib/og-image.js.map +1 -0
- package/lib/rate-limit.js +76 -0
- package/lib/rate-limit.js.map +1 -0
- package/lib/server.js +1534 -0
- package/lib/server.js.map +1 -0
- package/lib/testing.js +179 -0
- package/lib/testing.js.map +1 -0
- package/lib/theme.js +11 -2
- package/lib/theme.js.map +1 -1
- package/lib/types/actions.d.ts +27 -24
- package/lib/types/actions.d.ts.map +1 -1
- package/lib/types/ai.d.ts +76 -95
- package/lib/types/ai.d.ts.map +1 -1
- package/lib/types/api-routes.d.ts +37 -33
- package/lib/types/api-routes.d.ts.map +1 -1
- package/lib/types/cache.d.ts +26 -22
- package/lib/types/cache.d.ts.map +1 -1
- package/lib/types/client.d.ts +13 -9
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/compression.d.ts +14 -10
- package/lib/types/compression.d.ts.map +1 -1
- package/lib/types/config.d.ts +39 -4
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/cors.d.ts +20 -16
- package/lib/types/cors.d.ts.map +1 -1
- package/lib/types/csp.d.ts +42 -61
- package/lib/types/csp.d.ts.map +1 -1
- package/lib/types/env.d.ts +26 -26
- package/lib/types/env.d.ts.map +1 -1
- package/lib/types/favicon.d.ts +58 -54
- package/lib/types/favicon.d.ts.map +1 -1
- package/lib/types/font.d.ts +68 -65
- package/lib/types/font.d.ts.map +1 -1
- package/lib/types/i18n-routing.d.ts +43 -37
- package/lib/types/i18n-routing.d.ts.map +1 -1
- package/lib/types/image-plugin.d.ts +49 -45
- package/lib/types/image-plugin.d.ts.map +1 -1
- package/lib/types/image.d.ts +47 -36
- package/lib/types/image.d.ts.map +1 -1
- package/lib/types/index.d.ts +594 -56
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/link.d.ts +61 -56
- package/lib/types/link.d.ts.map +1 -1
- package/lib/types/logger.d.ts +37 -48
- package/lib/types/logger.d.ts.map +1 -1
- package/lib/types/meta.d.ts +145 -105
- package/lib/types/meta.d.ts.map +1 -1
- package/lib/types/middleware.d.ts +8 -4
- package/lib/types/middleware.d.ts.map +1 -1
- package/lib/types/og-image.d.ts +63 -59
- package/lib/types/og-image.d.ts.map +1 -1
- package/lib/types/rate-limit.d.ts +20 -16
- package/lib/types/rate-limit.d.ts.map +1 -1
- package/lib/types/script.d.ts +23 -19
- package/lib/types/script.d.ts.map +1 -1
- package/lib/types/seo.d.ts +47 -43
- package/lib/types/seo.d.ts.map +1 -1
- package/lib/types/server.d.ts +455 -0
- package/lib/types/server.d.ts.map +1 -0
- package/lib/types/testing.d.ts +64 -27
- package/lib/types/testing.d.ts.map +1 -1
- package/lib/types/theme.d.ts +22 -12
- package/lib/types/theme.d.ts.map +1 -1
- package/package.json +17 -12
- package/src/actions.ts +1 -3
- package/src/adapters/bun.ts +2 -0
- package/src/adapters/cloudflare.ts +2 -0
- package/src/adapters/netlify.ts +2 -0
- package/src/adapters/node.ts +2 -0
- package/src/adapters/validate.ts +16 -0
- package/src/adapters/vercel.ts +2 -0
- package/src/compression.ts +19 -3
- package/src/entry-server.ts +28 -5
- package/src/index.ts +20 -182
- package/src/link.tsx +6 -0
- package/src/meta.tsx +78 -16
- package/src/rate-limit.ts +11 -9
- package/src/server.ts +70 -0
- package/src/theme.tsx +12 -1
- package/src/vite-plugin.ts +5 -1
- package/lib/fs-router-Dil4IKZR.js +0 -290
- package/lib/fs-router-Dil4IKZR.js.map +0 -1
- package/lib/types/adapters/bun.d.ts +0 -6
- package/lib/types/adapters/bun.d.ts.map +0 -1
- package/lib/types/adapters/cloudflare.d.ts +0 -26
- package/lib/types/adapters/cloudflare.d.ts.map +0 -1
- package/lib/types/adapters/index.d.ts +0 -13
- package/lib/types/adapters/index.d.ts.map +0 -1
- package/lib/types/adapters/netlify.d.ts +0 -21
- package/lib/types/adapters/netlify.d.ts.map +0 -1
- package/lib/types/adapters/node.d.ts +0 -6
- package/lib/types/adapters/node.d.ts.map +0 -1
- package/lib/types/adapters/static.d.ts +0 -7
- package/lib/types/adapters/static.d.ts.map +0 -1
- package/lib/types/adapters/vercel.d.ts +0 -21
- package/lib/types/adapters/vercel.d.ts.map +0 -1
- package/lib/types/app.d.ts +0 -24
- package/lib/types/app.d.ts.map +0 -1
- package/lib/types/entry-server.d.ts +0 -37
- package/lib/types/entry-server.d.ts.map +0 -1
- package/lib/types/error-overlay.d.ts +0 -6
- package/lib/types/error-overlay.d.ts.map +0 -1
- package/lib/types/fs-router.d.ts +0 -47
- package/lib/types/fs-router.d.ts.map +0 -1
- package/lib/types/isr.d.ts +0 -9
- package/lib/types/isr.d.ts.map +0 -1
- package/lib/types/not-found.d.ts +0 -7
- package/lib/types/not-found.d.ts.map +0 -1
- package/lib/types/types.d.ts +0 -111
- package/lib/types/types.d.ts.map +0 -1
- package/lib/types/utils/use-intersection-observer.d.ts +0 -10
- package/lib/types/utils/use-intersection-observer.d.ts.map +0 -1
- package/lib/types/utils/with-headers.d.ts +0 -6
- package/lib/types/utils/with-headers.d.ts.map +0 -1
- package/lib/types/vite-plugin.d.ts +0 -17
- 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":";;;;;AAKA,IAAI,cAAc;AAClB,SAAS,mBAAmB;AAC1B,KAAI,YAAa;AACjB,eAAc;AAEd,SAAQ,KACN,sHACD;;AAoEH,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,2BAAW,IAAI,KAAyB;AAE9C,UAAO,YAAY,IAAI,OAAO,KAAK,KAAK,SAAS;IAC/C,MAAM,MAAM,IAAI,OAAO;IAGvB,MAAM,eAAe,oBAAoB,KAAK,QAAQ,KAAK;IAG3D,MAAM,SAAS,eAAe,aAAa,MAAM;IACjD,MAAM,UAAU,eAAe,aAAa,aAAa;IACzD,MAAM,cAAc,eAAe,aAAa,OAAO,SAAS,OAAO,GAAG,OAAO,OAAO,SAAS,OAAO;AAExG,QAAI,OAAO,SAAS,eAAe,IAAI,YACrC,KAAI;KACF,MAAM,UAAU,MAAM,SAAS,SAAS,QAAQ;AAChD,SAAI,UAAU,gBAAgB,gBAAgB;AAC9C,SAAI,IAAI,QAAQ;AAChB;YACM;IAIV,MAAM,WAAW,OAAO,MAAM,IAAI,CAAC,KAAK,IAAI;IAC5C,MAAM,YAAY,MAAM,MAAM,MAAM,EAAE,SAAS,SAAS;AACxD,QAAI,WAAW;KACb,MAAM,WAAW,GAAG,QAAQ,GAAG,UAAU;KACzC,IAAI,MAAM,SAAS,IAAI,SAAS;AAChC,SAAI,CAAC,KAAK;MACR,MAAM,SAAS,MAAM,YAAY,SAAS,UAAU,KAAK;AACzD,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,OAID,EAAE;AAEP,OAAI,MACF,MAAK,KAAK;IACR,KAAK;IACL,OAAO;KAAE,KAAK;KAAQ,MAAM;KAAiB,MAAM;KAAgB;IACnE,UAAU;IACX,CAAC;AAGJ,QAAK,KACH;IACE,KAAK;IACL,OAAO;KAAE,KAAK;KAAQ,MAAM;KAAa,OAAO;KAAS,MAAM;KAAsB;IACrF,UAAU;IACX,EACD;IACE,KAAK;IACL,OAAO;KAAE,KAAK;KAAQ,MAAM;KAAa,OAAO;KAAS,MAAM;KAAsB;IACrF,UAAU;IACX,EACD;IACE,KAAK;IACL,OAAO;KAAE,KAAK;KAAoB,OAAO;KAAW,MAAM;KAAyB;IACnF,UAAU;IACX,CACF;AAED,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;AAEF,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,oCAAoC,aAAa;AAC9D;;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,MAAK,MAAM,EAAE,MAAM,UAAU,OAAO;EAClC,MAAM,YAAY,MAAM,YAAY,YAAY,KAAK;AACrD,MAAI,UACF,MAAK,SAAS;GACZ,MAAM;GACN,UAAU,GAAG,SAAS;GACtB,QAAQ;GACT,CAAC;;CAKN,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"}
|
|
@@ -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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"}
|