@pyreon/zero 0.11.8 → 0.11.9

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/index.js CHANGED
@@ -1,12 +1,13 @@
1
- import { a as parseFileRoutes, i as generateRouteModule, o as scanRouteFiles, r as generateMiddlewareModule, t as filePathToUrlPath } from "./fs-router-n4VA4lxu.js";
2
- import { Fragment, createRef, h, onMount, onUnmount } from "@pyreon/core";
3
- import { HeadProvider } from "@pyreon/head";
1
+ import { a as parseFileRoutes, i as generateRouteModule, o as scanRouteFiles, r as generateMiddlewareModule, t as filePathToUrlPath } from "./fs-router-Dil4IKZR.js";
2
+ import { Fragment, createContext, createRef, h, onMount, onUnmount } from "@pyreon/core";
3
+ import { HeadProvider, useHead } from "@pyreon/head";
4
4
  import { RouterProvider, RouterView, createRouter, useRouter } from "@pyreon/router";
5
5
  import { createHandler } from "@pyreon/server";
6
+ import { renderToString } from "@pyreon/runtime-server";
7
+ import { existsSync, readdirSync } from "node:fs";
8
+ import { basename, extname, join } from "node:path";
6
9
  import { effect, signal } from "@pyreon/reactivity";
7
- import { existsSync } from "node:fs";
8
10
  import { mkdir, readFile, writeFile } from "node:fs/promises";
9
- import { basename, extname, join } from "node:path";
10
11
 
11
12
  //#region src/app.ts
12
13
  /**
@@ -169,6 +170,31 @@ function generateApiRouteModule(files, routesDir) {
169
170
  ].join("\n");
170
171
  }
171
172
 
173
+ //#endregion
174
+ //#region src/not-found.ts
175
+ const DEFAULT_404_BODY = "<h1>404 — Not Found</h1><p>The page you requested does not exist.</p>";
176
+ /**
177
+ * Render a 404 component to a full HTML string.
178
+ * If no component is provided, returns a default 404 page.
179
+ */
180
+ async function render404Page(component, template) {
181
+ let body;
182
+ if (component) body = await renderToString(h(component, null));
183
+ else body = DEFAULT_404_BODY;
184
+ if (template?.includes("<!--pyreon-app-->")) return template.replace("<!--pyreon-app-->", body);
185
+ return `<!DOCTYPE html>
186
+ <html lang="en">
187
+ <head>
188
+ <meta charset="UTF-8">
189
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
190
+ <title>404 — Not Found</title>
191
+ </head>
192
+ <body>
193
+ ${body}
194
+ </body>
195
+ </html>`;
196
+ }
197
+
172
198
  //#endregion
173
199
  //#region src/entry-server.ts
174
200
  /**
@@ -219,7 +245,7 @@ function createServer(options) {
219
245
  routes: options.routes,
220
246
  routerMode: "history"
221
247
  });
222
- return createHandler({
248
+ const handler = createHandler({
223
249
  App,
224
250
  routes: options.routes,
225
251
  middleware: allMiddleware,
@@ -227,6 +253,30 @@ function createServer(options) {
227
253
  ...options.template ? { template: options.template } : {},
228
254
  ...options.clientEntry ? { clientEntry: options.clientEntry } : {}
229
255
  });
256
+ if (!options.notFoundComponent) return handler;
257
+ const NotFound = options.notFoundComponent;
258
+ const routePatterns = flattenRoutePatterns$1(options.routes);
259
+ return async (req) => {
260
+ const pathname = new URL(req.url).pathname;
261
+ if (!routePatterns.some((pattern) => matchPattern(pattern, pathname))) {
262
+ const fullHtml = await render404Page(NotFound, options.template);
263
+ return new Response(fullHtml, {
264
+ status: 404,
265
+ headers: { "Content-Type": "text/html; charset=utf-8" }
266
+ });
267
+ }
268
+ return handler(req);
269
+ };
270
+ }
271
+ /** Extract all URL patterns from a nested route tree. */
272
+ function flattenRoutePatterns$1(routes, prefix = "") {
273
+ const patterns = [];
274
+ for (const route of routes) {
275
+ const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
276
+ patterns.push(fullPath);
277
+ if (route.children) patterns.push(...flattenRoutePatterns$1(route.children, fullPath));
278
+ }
279
+ return patterns;
230
280
  }
231
281
 
232
282
  //#endregion
@@ -371,6 +421,19 @@ function formatStack(stack) {
371
421
 
372
422
  //#endregion
373
423
  //#region src/vite-plugin.ts
424
+ /**
425
+ * Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
426
+ * Returns package names to exclude from Vite's dep optimizer.
427
+ */
428
+ function scanPyreonPackages(root) {
429
+ const pyreonDir = join(root, "node_modules", "@pyreon");
430
+ if (!existsSync(pyreonDir)) return [];
431
+ try {
432
+ return readdirSync(pyreonDir).filter((name) => !name.startsWith(".")).map((name) => `@pyreon/${name}`);
433
+ } catch {
434
+ return [];
435
+ }
436
+ }
374
437
  const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
375
438
  const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
376
439
  const VIRTUAL_MIDDLEWARE_ID = "virtual:zero/route-middleware";
@@ -409,7 +472,7 @@ function zeroPlugin(userConfig = {}) {
409
472
  },
410
473
  async load(id) {
411
474
  if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
412
- return generateRouteModule(await scanRouteFiles(routesDir), routesDir);
475
+ return generateRouteModule(await scanRouteFiles(routesDir), routesDir, { staticImports: config.mode === "ssg" });
413
476
  } catch (_err) {
414
477
  return `export const routes = []`;
415
478
  }
@@ -425,6 +488,16 @@ function zeroPlugin(userConfig = {}) {
425
488
  }
426
489
  },
427
490
  configureServer(server) {
491
+ server.middlewares.use((req, res, next) => {
492
+ const accept = req.headers.accept ?? "";
493
+ if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
494
+ const pathname = req.url?.split("?")[0] ?? "/";
495
+ if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
496
+ if (/\.\w+$/.test(pathname)) return next();
497
+ handle404(server, routesDir, pathname, res).then((handled) => {
498
+ if (!handled) next();
499
+ }, () => next());
500
+ });
428
501
  server.middlewares.use((req, res, next) => {
429
502
  if (!(req.headers.accept ?? "").includes("text/html")) return next();
430
503
  const originalEnd = res.end.bind(res);
@@ -442,7 +515,8 @@ function zeroPlugin(userConfig = {}) {
442
515
  };
443
516
  res.on("error", handleError);
444
517
  try {
445
- next();
518
+ const result = next();
519
+ if (result && typeof result.catch === "function") result.catch(handleError);
446
520
  } catch (err) {
447
521
  handleError(err);
448
522
  }
@@ -462,9 +536,10 @@ function zeroPlugin(userConfig = {}) {
462
536
  }
463
537
  });
464
538
  },
465
- config() {
539
+ config(userConfig) {
466
540
  return {
467
541
  resolve: { conditions: ["bun"] },
542
+ optimizeDeps: { exclude: scanPyreonPackages(userConfig.root ?? process.cwd()) },
468
543
  server: { port: config.port },
469
544
  define: {
470
545
  __ZERO_MODE__: JSON.stringify(config.mode),
@@ -474,6 +549,36 @@ function zeroPlugin(userConfig = {}) {
474
549
  }
475
550
  };
476
551
  }
552
+ /**
553
+ * Check if the requested path matches any route. If not, render a 404 page.
554
+ * Returns true if the 404 was handled (response sent), false otherwise.
555
+ *
556
+ * In dev mode, the _404.tsx component cannot be SSR-rendered because
557
+ * the compiler emits _tpl() calls that require `document`. Instead,
558
+ * we return a static 404 page. The actual component rendering happens
559
+ * on the client side when the SPA loads.
560
+ */
561
+ async function handle404(server, _routesDir, pathname, res) {
562
+ const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
563
+ if (flattenRoutePatterns(routes).some((pattern) => matchPattern(pattern, pathname))) return false;
564
+ const html = await render404Page(void 0);
565
+ res.statusCode = 404;
566
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
567
+ res.setHeader("Content-Length", Buffer.byteLength(html));
568
+ res.end(html);
569
+ return true;
570
+ }
571
+ /** Extract all URL patterns from a nested route tree. */
572
+ function flattenRoutePatterns(routes, prefix = "") {
573
+ const patterns = [];
574
+ for (const route of routes) {
575
+ if (!route.path) continue;
576
+ const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
577
+ patterns.push(fullPath);
578
+ if (route.children) patterns.push(...flattenRoutePatterns(route.children, fullPath));
579
+ }
580
+ return patterns;
581
+ }
477
582
 
478
583
  //#endregion
479
584
  //#region src/isr.ts
@@ -914,6 +1019,17 @@ function doPrefetch(href) {
914
1019
  } catch {}
915
1020
  }
916
1021
  /**
1022
+ * Prefetch a route's JS chunk by injecting `<link rel="prefetch">` into the
1023
+ * document head. Deduplicates — calling with the same href twice is a no-op.
1024
+ *
1025
+ * @example
1026
+ * prefetchRoute('/about')
1027
+ * prefetchRoute('/dashboard')
1028
+ */
1029
+ function prefetchRoute(href) {
1030
+ doPrefetch(href);
1031
+ }
1032
+ /**
917
1033
  * Composable that provides all link behavior — navigation, prefetching,
918
1034
  * active state, and viewport observation.
919
1035
  *
@@ -1202,6 +1318,57 @@ function varyEncoding() {
1202
1318
  };
1203
1319
  }
1204
1320
 
1321
+ //#endregion
1322
+ //#region src/middleware.ts
1323
+ /**
1324
+ * Compose multiple middleware into a single middleware function.
1325
+ * Middleware runs sequentially — if any returns a Response, the chain stops.
1326
+ *
1327
+ * @example
1328
+ * import { compose } from "@pyreon/zero/middleware"
1329
+ * import { corsMiddleware } from "@pyreon/zero/cors"
1330
+ * import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
1331
+ *
1332
+ * const combined = compose(
1333
+ * corsMiddleware({ origin: "*" }),
1334
+ * rateLimitMiddleware({ max: 100 }),
1335
+ * cacheMiddleware(),
1336
+ * )
1337
+ */
1338
+ function compose(...middlewares) {
1339
+ return async (ctx) => {
1340
+ for (const mw of middlewares) {
1341
+ const result = await mw(ctx);
1342
+ if (result instanceof Response) return result;
1343
+ }
1344
+ };
1345
+ }
1346
+ const ZERO_CTX_KEY = "__zeroCtx";
1347
+ /**
1348
+ * Get the shared Zero context from a middleware context.
1349
+ * Creates one if it doesn't exist. Middleware can use this to
1350
+ * pass data to downstream middleware without polluting `ctx.locals`.
1351
+ *
1352
+ * @example
1353
+ * const authMiddleware: Middleware = (ctx) => {
1354
+ * const zctx = getContext(ctx)
1355
+ * zctx.userId = "user_123"
1356
+ * }
1357
+ *
1358
+ * const loggingMiddleware: Middleware = (ctx) => {
1359
+ * const zctx = getContext(ctx)
1360
+ * console.log("User:", zctx.userId)
1361
+ * }
1362
+ */
1363
+ function getContext(ctx) {
1364
+ let zctx = ctx.locals[ZERO_CTX_KEY];
1365
+ if (!zctx) {
1366
+ zctx = {};
1367
+ ctx.locals[ZERO_CTX_KEY] = zctx;
1368
+ }
1369
+ return zctx;
1370
+ }
1371
+
1205
1372
  //#endregion
1206
1373
  //#region src/font.ts
1207
1374
  /**
@@ -1454,10 +1621,10 @@ function fontVariables(families) {
1454
1621
 
1455
1622
  //#endregion
1456
1623
  //#region src/image-plugin.ts
1457
- let sharpWarned = false;
1458
- function warnSharpMissing() {
1459
- if (sharpWarned) return;
1460
- sharpWarned = true;
1624
+ let sharpWarned$1 = false;
1625
+ function warnSharpMissing$1() {
1626
+ if (sharpWarned$1) return;
1627
+ sharpWarned$1 = true;
1461
1628
  console.warn("\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n");
1462
1629
  }
1463
1630
  const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i;
@@ -1720,7 +1887,7 @@ async function resizeImage(input, output, width, format, quality) {
1720
1887
  }
1721
1888
  await pipeline.toFile(output);
1722
1889
  } catch {
1723
- warnSharpMissing();
1890
+ warnSharpMissing$1();
1724
1891
  await writeFile(output, await readFile(input));
1725
1892
  }
1726
1893
  }
@@ -1991,7 +2158,7 @@ function seoPlugin(config = {}) {
1991
2158
  apply: "build",
1992
2159
  async generateBundle(_, _bundle) {
1993
2160
  if (config.sitemap) {
1994
- const { scanRouteFiles } = await import("./fs-router-n4VA4lxu.js").then((n) => n.n);
2161
+ const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
1995
2162
  const routesDir = `${process.cwd()}/src/routes`;
1996
2163
  try {
1997
2164
  const sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
@@ -2021,7 +2188,7 @@ function seoMiddleware(config = {}) {
2021
2188
  return async (ctx) => {
2022
2189
  if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
2023
2190
  if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
2024
- const { scanRouteFiles } = await import("./fs-router-n4VA4lxu.js").then((n) => n.n);
2191
+ const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
2025
2192
  const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
2026
2193
  return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
2027
2194
  } catch {}
@@ -2308,5 +2475,693 @@ async function executeAction(action, req) {
2308
2475
  }
2309
2476
 
2310
2477
  //#endregion
2311
- export { Image, Link, Script, ThemeToggle, bunAdapter, cacheMiddleware, compressResponse, compressionMiddleware, corsMiddleware, createActionMiddleware, createApiMiddleware, createApp, createISRHandler, createLink, createServer, zeroPlugin as default, defineAction, defineConfig, filePathToUrlPath, fontPlugin, fontVariables, generateApiRouteModule, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, imagePlugin, initTheme, isCompressible, jsonLd, nodeAdapter, parseFileRoutes, rateLimitMiddleware, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, securityHeaders, seoMiddleware, seoPlugin, setTheme, staticAdapter, theme, themeScript, toggleTheme, useLink, varyEncoding };
2478
+ //#region src/favicon.ts
2479
+ let sharpWarned = false;
2480
+ function warnSharpMissing() {
2481
+ if (sharpWarned) return;
2482
+ sharpWarned = true;
2483
+ console.warn("\n[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
2484
+ }
2485
+ const SIZES = [
2486
+ {
2487
+ size: 16,
2488
+ name: "favicon-16x16.png"
2489
+ },
2490
+ {
2491
+ size: 32,
2492
+ name: "favicon-32x32.png"
2493
+ },
2494
+ {
2495
+ size: 180,
2496
+ name: "apple-touch-icon.png"
2497
+ },
2498
+ {
2499
+ size: 192,
2500
+ name: "icon-192.png"
2501
+ },
2502
+ {
2503
+ size: 512,
2504
+ name: "icon-512.png"
2505
+ }
2506
+ ];
2507
+ /**
2508
+ * Favicon generation Vite plugin.
2509
+ *
2510
+ * Generates all required favicon formats at build time from a single source.
2511
+ * In dev mode, serves the source directly.
2512
+ *
2513
+ * @example
2514
+ * ```ts
2515
+ * // vite.config.ts
2516
+ * import { faviconPlugin } from "@pyreon/zero"
2517
+ *
2518
+ * export default {
2519
+ * plugins: [faviconPlugin({ source: "./src/assets/icon.svg" })],
2520
+ * }
2521
+ * ```
2522
+ */
2523
+ function faviconPlugin(config) {
2524
+ const themeColor = config.themeColor ?? "#ffffff";
2525
+ const backgroundColor = config.backgroundColor ?? "#ffffff";
2526
+ const generateManifest = config.manifest !== false;
2527
+ let root = "";
2528
+ let isBuild = false;
2529
+ return {
2530
+ name: "pyreon-zero-favicon",
2531
+ enforce: "pre",
2532
+ configResolved(resolvedConfig) {
2533
+ root = resolvedConfig.root;
2534
+ isBuild = resolvedConfig.command === "build";
2535
+ },
2536
+ configureServer(server) {
2537
+ const sourcePath = join(root, config.source);
2538
+ server.middlewares.use(async (req, res, next) => {
2539
+ const url = req.url ?? "";
2540
+ if (url === "/favicon.svg" && config.source.endsWith(".svg")) try {
2541
+ const content = await readFile(sourcePath, "utf-8");
2542
+ res.setHeader("Content-Type", "image/svg+xml");
2543
+ res.end(content);
2544
+ return;
2545
+ } catch {}
2546
+ const sizeMatch = SIZES.find((s) => url === `/${s.name}`);
2547
+ if (sizeMatch) {
2548
+ const png = await resizeToPng(sourcePath, sizeMatch.size);
2549
+ if (png) {
2550
+ res.setHeader("Content-Type", "image/png");
2551
+ res.setHeader("Cache-Control", "no-cache");
2552
+ res.end(Buffer.from(png));
2553
+ return;
2554
+ }
2555
+ }
2556
+ if (url === "/favicon.ico") {
2557
+ const ico = await generateIco(sourcePath);
2558
+ if (ico) {
2559
+ res.setHeader("Content-Type", "image/x-icon");
2560
+ res.setHeader("Cache-Control", "no-cache");
2561
+ res.end(Buffer.from(ico));
2562
+ return;
2563
+ }
2564
+ }
2565
+ if (url === "/site.webmanifest" && generateManifest) {
2566
+ const manifest = {
2567
+ name: config.name ?? "App",
2568
+ short_name: config.name ?? "App",
2569
+ icons: [{
2570
+ src: "/icon-192.png",
2571
+ sizes: "192x192",
2572
+ type: "image/png"
2573
+ }, {
2574
+ src: "/icon-512.png",
2575
+ sizes: "512x512",
2576
+ type: "image/png"
2577
+ }],
2578
+ theme_color: themeColor,
2579
+ background_color: backgroundColor,
2580
+ display: "standalone"
2581
+ };
2582
+ res.setHeader("Content-Type", "application/manifest+json");
2583
+ res.end(JSON.stringify(manifest, null, 2));
2584
+ return;
2585
+ }
2586
+ next();
2587
+ });
2588
+ },
2589
+ transformIndexHtml() {
2590
+ const isSvg = config.source.endsWith(".svg");
2591
+ const tags = [];
2592
+ if (isSvg) tags.push({
2593
+ tag: "link",
2594
+ attrs: {
2595
+ rel: "icon",
2596
+ type: "image/svg+xml",
2597
+ href: "/favicon.svg"
2598
+ },
2599
+ injectTo: "head"
2600
+ });
2601
+ tags.push({
2602
+ tag: "link",
2603
+ attrs: {
2604
+ rel: "icon",
2605
+ type: "image/png",
2606
+ sizes: "32x32",
2607
+ href: "/favicon-32x32.png"
2608
+ },
2609
+ injectTo: "head"
2610
+ }, {
2611
+ tag: "link",
2612
+ attrs: {
2613
+ rel: "icon",
2614
+ type: "image/png",
2615
+ sizes: "16x16",
2616
+ href: "/favicon-16x16.png"
2617
+ },
2618
+ injectTo: "head"
2619
+ }, {
2620
+ tag: "link",
2621
+ attrs: {
2622
+ rel: "apple-touch-icon",
2623
+ sizes: "180x180",
2624
+ href: "/apple-touch-icon.png"
2625
+ },
2626
+ injectTo: "head"
2627
+ });
2628
+ if (generateManifest) tags.push({
2629
+ tag: "link",
2630
+ attrs: {
2631
+ rel: "manifest",
2632
+ href: "/site.webmanifest"
2633
+ },
2634
+ injectTo: "head"
2635
+ });
2636
+ tags.push({
2637
+ tag: "meta",
2638
+ attrs: {
2639
+ name: "theme-color",
2640
+ content: themeColor
2641
+ },
2642
+ injectTo: "head"
2643
+ });
2644
+ return tags;
2645
+ },
2646
+ async generateBundle() {
2647
+ if (!isBuild) return;
2648
+ const sourcePath = join(root, config.source);
2649
+ if (!existsSync(sourcePath)) {
2650
+ console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
2651
+ return;
2652
+ }
2653
+ if (config.source.endsWith(".svg")) {
2654
+ const svgContent = await readFile(sourcePath, "utf-8");
2655
+ let finalSvg = svgContent;
2656
+ if (config.darkSource) {
2657
+ const darkPath = join(root, config.darkSource);
2658
+ if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
2659
+ }
2660
+ this.emitFile({
2661
+ type: "asset",
2662
+ fileName: "favicon.svg",
2663
+ source: finalSvg
2664
+ });
2665
+ }
2666
+ for (const { size, name } of SIZES) {
2667
+ const pngBuffer = await resizeToPng(sourcePath, size);
2668
+ if (pngBuffer) this.emitFile({
2669
+ type: "asset",
2670
+ fileName: name,
2671
+ source: pngBuffer
2672
+ });
2673
+ }
2674
+ const ico = await generateIco(sourcePath);
2675
+ if (ico) this.emitFile({
2676
+ type: "asset",
2677
+ fileName: "favicon.ico",
2678
+ source: ico
2679
+ });
2680
+ if (generateManifest) {
2681
+ const manifest = {
2682
+ name: config.name ?? "App",
2683
+ short_name: config.name ?? "App",
2684
+ icons: [{
2685
+ src: "/icon-192.png",
2686
+ sizes: "192x192",
2687
+ type: "image/png"
2688
+ }, {
2689
+ src: "/icon-512.png",
2690
+ sizes: "512x512",
2691
+ type: "image/png"
2692
+ }],
2693
+ theme_color: themeColor,
2694
+ background_color: backgroundColor,
2695
+ display: "standalone"
2696
+ };
2697
+ this.emitFile({
2698
+ type: "asset",
2699
+ fileName: "site.webmanifest",
2700
+ source: JSON.stringify(manifest, null, 2)
2701
+ });
2702
+ }
2703
+ }
2704
+ };
2705
+ }
2706
+ /**
2707
+ * Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.
2708
+ */
2709
+ function wrapSvgWithDarkMode(lightSvg, darkSvg) {
2710
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${lightSvg.match(/viewBox="([^"]*)"/)?.[1] ?? "0 0 32 32"}">
2711
+ <style>
2712
+ :root { color-scheme: light dark; }
2713
+ @media (prefers-color-scheme: dark) { .light { display: none; } }
2714
+ @media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }
2715
+ </style>
2716
+ <g class="light">${stripSvgWrapper(lightSvg)}</g>
2717
+ <g class="dark">${stripSvgWrapper(darkSvg)}</g>
2718
+ </svg>`;
2719
+ }
2720
+ function stripSvgWrapper(svg) {
2721
+ return svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
2722
+ }
2723
+ async function resizeToPng(input, size) {
2724
+ try {
2725
+ return await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, {
2726
+ fit: "contain",
2727
+ background: {
2728
+ r: 0,
2729
+ g: 0,
2730
+ b: 0,
2731
+ alpha: 0
2732
+ }
2733
+ }).png().toBuffer();
2734
+ } catch {
2735
+ warnSharpMissing();
2736
+ return null;
2737
+ }
2738
+ }
2739
+ async function generateIco(input) {
2740
+ try {
2741
+ const sharp = await import("sharp").then((m) => m.default ?? m);
2742
+ const png16 = await sharp(input).resize(16, 16, {
2743
+ fit: "contain",
2744
+ background: {
2745
+ r: 0,
2746
+ g: 0,
2747
+ b: 0,
2748
+ alpha: 0
2749
+ }
2750
+ }).png().toBuffer();
2751
+ const png32 = await sharp(input).resize(32, 32, {
2752
+ fit: "contain",
2753
+ background: {
2754
+ r: 0,
2755
+ g: 0,
2756
+ b: 0,
2757
+ alpha: 0
2758
+ }
2759
+ }).png().toBuffer();
2760
+ return createIcoFromPngs([{
2761
+ buffer: png16,
2762
+ size: 16
2763
+ }, {
2764
+ buffer: png32,
2765
+ size: 32
2766
+ }]);
2767
+ } catch {
2768
+ warnSharpMissing();
2769
+ return null;
2770
+ }
2771
+ }
2772
+ /** @internal Exported for testing */
2773
+ function createIcoFromPngs(entries) {
2774
+ const headerSize = 6;
2775
+ const dirEntrySize = 16;
2776
+ const dirSize = dirEntrySize * entries.length;
2777
+ let dataOffset = headerSize + dirSize;
2778
+ const header = Buffer.alloc(headerSize);
2779
+ header.writeUInt16LE(0, 0);
2780
+ header.writeUInt16LE(1, 2);
2781
+ header.writeUInt16LE(entries.length, 4);
2782
+ const dirEntries = Buffer.alloc(dirSize);
2783
+ const dataBuffers = [];
2784
+ for (let i = 0; i < entries.length; i++) {
2785
+ const entry = entries[i];
2786
+ const offset = i * dirEntrySize;
2787
+ dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset);
2788
+ dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1);
2789
+ dirEntries.writeUInt8(0, offset + 2);
2790
+ dirEntries.writeUInt8(0, offset + 3);
2791
+ dirEntries.writeUInt16LE(1, offset + 4);
2792
+ dirEntries.writeUInt16LE(32, offset + 6);
2793
+ dirEntries.writeUInt32LE(entry.buffer.length, offset + 8);
2794
+ dirEntries.writeUInt32LE(dataOffset, offset + 12);
2795
+ dataOffset += entry.buffer.length;
2796
+ dataBuffers.push(entry.buffer);
2797
+ }
2798
+ return Buffer.concat([
2799
+ header,
2800
+ dirEntries,
2801
+ ...dataBuffers
2802
+ ]);
2803
+ }
2804
+
2805
+ //#endregion
2806
+ //#region src/i18n-routing.ts
2807
+ /**
2808
+ * Detect preferred locale from Accept-Language header.
2809
+ */
2810
+ function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
2811
+ if (!acceptLanguage) return defaultLocale;
2812
+ const preferred = acceptLanguage.split(",").map((part) => {
2813
+ const [lang, q] = part.trim().split(";q=");
2814
+ return {
2815
+ lang: lang?.split("-")[0]?.toLowerCase() ?? "",
2816
+ quality: q ? Number.parseFloat(q) : 1
2817
+ };
2818
+ }).sort((a, b) => b.quality - a.quality);
2819
+ for (const { lang } of preferred) if (locales.includes(lang)) return lang;
2820
+ return defaultLocale;
2821
+ }
2822
+ /**
2823
+ * Extract locale from a URL path.
2824
+ * Returns { locale, pathWithoutLocale }.
2825
+ */
2826
+ function extractLocaleFromPath(path, locales, defaultLocale) {
2827
+ const segments = path.split("/").filter(Boolean);
2828
+ const firstSegment = segments[0]?.toLowerCase();
2829
+ if (firstSegment && locales.includes(firstSegment)) return {
2830
+ locale: firstSegment,
2831
+ pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
2832
+ };
2833
+ return {
2834
+ locale: defaultLocale,
2835
+ pathWithoutLocale: path
2836
+ };
2837
+ }
2838
+ /**
2839
+ * Build a localized path.
2840
+ */
2841
+ function buildLocalePath(path, locale, defaultLocale, strategy) {
2842
+ const clean = path === "/" ? "" : path;
2843
+ if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
2844
+ return `/${locale}${clean}`;
2845
+ }
2846
+ /**
2847
+ * Create a LocaleContext for use in components and loaders.
2848
+ */
2849
+ function createLocaleContext(locale, path, config) {
2850
+ const strategy = config.strategy ?? "prefix-except-default";
2851
+ return {
2852
+ locale,
2853
+ locales: config.locales,
2854
+ defaultLocale: config.defaultLocale,
2855
+ localePath(targetPath, targetLocale) {
2856
+ return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
2857
+ },
2858
+ alternates() {
2859
+ const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
2860
+ return config.locales.map((loc) => ({
2861
+ locale: loc,
2862
+ url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
2863
+ }));
2864
+ }
2865
+ };
2866
+ }
2867
+ /**
2868
+ * I18n routing middleware for Zero's server.
2869
+ *
2870
+ * - Detects locale from URL prefix or Accept-Language header
2871
+ * - Redirects root to preferred locale (when detectLocale is true)
2872
+ * - Sets locale context for loaders and components
2873
+ *
2874
+ * @example
2875
+ * ```ts
2876
+ * // zero.config.ts
2877
+ * import { i18nRouting } from "@pyreon/zero"
2878
+ *
2879
+ * export default defineConfig({
2880
+ * plugins: [
2881
+ * i18nRouting({
2882
+ * locales: ["en", "de", "cs"],
2883
+ * defaultLocale: "en",
2884
+ * }),
2885
+ * ],
2886
+ * })
2887
+ * ```
2888
+ */
2889
+ function i18nRouting(config) {
2890
+ const strategy = config.strategy ?? "prefix-except-default";
2891
+ const detectEnabled = config.detectLocale !== false;
2892
+ const cookieName = config.cookieName ?? "locale";
2893
+ return {
2894
+ name: "pyreon-zero-i18n-routing",
2895
+ configResolved() {},
2896
+ configureServer(server) {
2897
+ server.middlewares.use((req, res, next) => {
2898
+ const url = req.url ?? "/";
2899
+ if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
2900
+ const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
2901
+ if (detectEnabled && url === "/") {
2902
+ const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
2903
+ const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
2904
+ const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
2905
+ if (strategy === "prefix" || preferred !== config.defaultLocale) {
2906
+ res.writeHead(302, { Location: `/${preferred}/` });
2907
+ res.end();
2908
+ return;
2909
+ }
2910
+ }
2911
+ req.__locale = locale;
2912
+ req.__localeContext = createLocaleContext(locale, url, config);
2913
+ localeSignal.set(locale);
2914
+ next();
2915
+ });
2916
+ }
2917
+ };
2918
+ }
2919
+ function parseCookies(header) {
2920
+ if (!header) return {};
2921
+ const result = {};
2922
+ for (const pair of header.split(";")) {
2923
+ const [key, value] = pair.trim().split("=");
2924
+ if (key && value) result[key] = decodeURIComponent(value);
2925
+ }
2926
+ return result;
2927
+ }
2928
+ /** @internal Context for the current locale. */
2929
+ const LocaleCtx = createContext("en");
2930
+ /** Current locale signal — set by the server middleware or client-side detection. */
2931
+ const localeSignal = signal("en");
2932
+ /**
2933
+ * Read the current locale reactively.
2934
+ *
2935
+ * Returns the locale signal value directly — reactive in both SSR and CSR.
2936
+ * The server middleware sets `localeSignal` per-request, and client-side
2937
+ * `setLocale()` updates it as well.
2938
+ *
2939
+ * @example
2940
+ * ```tsx
2941
+ * const locale = useLocale() // "en", "de", etc.
2942
+ * ```
2943
+ */
2944
+ function useLocale() {
2945
+ return localeSignal();
2946
+ }
2947
+ /**
2948
+ * Set the locale client-side and update the URL.
2949
+ *
2950
+ * @example
2951
+ * ```tsx
2952
+ * <button onClick={() => setLocale('de')}>Deutsch</button>
2953
+ * ```
2954
+ */
2955
+ function setLocale(locale, config) {
2956
+ localeSignal.set(locale);
2957
+ if (typeof document !== "undefined") document.cookie = `${config.cookieName ?? "locale"}=${locale}; path=/; max-age=31536000`;
2958
+ if (typeof window !== "undefined") {
2959
+ const strategy = config.strategy ?? "prefix-except-default";
2960
+ const { pathWithoutLocale } = extractLocaleFromPath(window.location.pathname, config.locales, config.defaultLocale);
2961
+ const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy);
2962
+ window.history.pushState(null, "", newPath);
2963
+ window.dispatchEvent(new PopStateEvent("popstate"));
2964
+ }
2965
+ }
2966
+
2967
+ //#endregion
2968
+ //#region src/meta.tsx
2969
+ const resolveStr = (v) => typeof v === "function" ? v() : v;
2970
+ /**
2971
+ * Declarative meta component for SSR-compatible page metadata.
2972
+ *
2973
+ * Supports reactive title/description — when passed as `() => string` accessors,
2974
+ * they are forwarded to `useHead()` as a reactive getter so updates propagate
2975
+ * automatically via signal tracking.
2976
+ *
2977
+ * @example
2978
+ * ```tsx
2979
+ * <Meta title="My Page" description="..." image="/og.jpg" canonical="https://..." />
2980
+ * ```
2981
+ *
2982
+ * @example Reactive title
2983
+ * ```tsx
2984
+ * const count = signal(0)
2985
+ * <Meta title={() => `${count()} items`} />
2986
+ * ```
2987
+ */
2988
+ function Meta(props) {
2989
+ const hasReactiveTitle = typeof props.title === "function";
2990
+ const hasReactiveDescription = typeof props.description === "function";
2991
+ if (hasReactiveTitle || hasReactiveDescription) useHead((() => {
2992
+ const title = resolveStr(props.title);
2993
+ const description = resolveStr(props.description);
2994
+ const tags = buildMetaTags({
2995
+ ...props,
2996
+ title,
2997
+ description
2998
+ });
2999
+ return {
3000
+ title,
3001
+ meta: tags.meta,
3002
+ link: tags.link,
3003
+ script: tags.script
3004
+ };
3005
+ }));
3006
+ else {
3007
+ const title = resolveStr(props.title);
3008
+ const description = resolveStr(props.description);
3009
+ const tags = buildMetaTags({
3010
+ ...props,
3011
+ title,
3012
+ description
3013
+ });
3014
+ useHead({
3015
+ title,
3016
+ meta: tags.meta,
3017
+ link: tags.link,
3018
+ script: tags.script
3019
+ });
3020
+ }
3021
+ return props.children ?? null;
3022
+ }
3023
+ function buildMetaTags(props) {
3024
+ const meta = [];
3025
+ const link = [];
3026
+ const script = [];
3027
+ const { title, description, canonical, image, imageAlt, type = "website", siteName, twitterCard = "summary_large_image", twitterSite, twitterCreator, locale = "en_US", alternateLocales, robots = "index, follow", publishedTime, modifiedTime, author, tags, jsonLd, extra } = props;
3028
+ if (description) meta.push({
3029
+ name: "description",
3030
+ content: description
3031
+ });
3032
+ if (robots) meta.push({
3033
+ name: "robots",
3034
+ content: robots
3035
+ });
3036
+ if (author) meta.push({
3037
+ name: "author",
3038
+ content: author
3039
+ });
3040
+ if (title) meta.push({
3041
+ property: "og:title",
3042
+ content: title
3043
+ });
3044
+ if (description) meta.push({
3045
+ property: "og:description",
3046
+ content: description
3047
+ });
3048
+ if (canonical) meta.push({
3049
+ property: "og:url",
3050
+ content: canonical
3051
+ });
3052
+ if (image) meta.push({
3053
+ property: "og:image",
3054
+ content: image
3055
+ });
3056
+ if (imageAlt) meta.push({
3057
+ property: "og:image:alt",
3058
+ content: imageAlt
3059
+ });
3060
+ meta.push({
3061
+ property: "og:type",
3062
+ content: type
3063
+ });
3064
+ if (siteName) meta.push({
3065
+ property: "og:site_name",
3066
+ content: siteName
3067
+ });
3068
+ meta.push({
3069
+ property: "og:locale",
3070
+ content: locale
3071
+ });
3072
+ if (type === "article") {
3073
+ if (publishedTime) meta.push({
3074
+ property: "article:published_time",
3075
+ content: publishedTime
3076
+ });
3077
+ if (modifiedTime) meta.push({
3078
+ property: "article:modified_time",
3079
+ content: modifiedTime
3080
+ });
3081
+ if (author) meta.push({
3082
+ property: "article:author",
3083
+ content: author
3084
+ });
3085
+ if (tags) for (const tag of tags) meta.push({
3086
+ property: "article:tag",
3087
+ content: tag
3088
+ });
3089
+ }
3090
+ meta.push({
3091
+ name: "twitter:card",
3092
+ content: twitterCard
3093
+ });
3094
+ if (title) meta.push({
3095
+ name: "twitter:title",
3096
+ content: title
3097
+ });
3098
+ if (description) meta.push({
3099
+ name: "twitter:description",
3100
+ content: description
3101
+ });
3102
+ if (image) meta.push({
3103
+ name: "twitter:image",
3104
+ content: image
3105
+ });
3106
+ if (imageAlt) meta.push({
3107
+ name: "twitter:image:alt",
3108
+ content: imageAlt
3109
+ });
3110
+ if (twitterSite) meta.push({
3111
+ name: "twitter:site",
3112
+ content: twitterSite
3113
+ });
3114
+ if (twitterCreator) meta.push({
3115
+ name: "twitter:creator",
3116
+ content: twitterCreator
3117
+ });
3118
+ if (canonical) link.push({
3119
+ rel: "canonical",
3120
+ href: canonical
3121
+ });
3122
+ if (alternateLocales) for (const alt of alternateLocales) link.push({
3123
+ rel: "alternate",
3124
+ hreflang: alt.locale,
3125
+ href: alt.url
3126
+ });
3127
+ if (jsonLd) script.push({
3128
+ type: "application/ld+json",
3129
+ children: JSON.stringify({
3130
+ "@context": "https://schema.org",
3131
+ ...jsonLd
3132
+ })
3133
+ });
3134
+ if (extra) for (const tag of extra) meta.push(tag);
3135
+ if (props.i18n) {
3136
+ const i18nConfig = props.i18n;
3137
+ const origin = props.origin ?? "";
3138
+ const { pathWithoutLocale } = extractLocaleFromPath(canonical?.replace(origin, "") ?? "/", i18nConfig.locales, i18nConfig.defaultLocale);
3139
+ const strategy = i18nConfig.strategy ?? "prefix-except-default";
3140
+ for (const loc of i18nConfig.locales) {
3141
+ const localizedPath = strategy === "prefix-except-default" && loc === i18nConfig.defaultLocale ? pathWithoutLocale : `/${loc}${pathWithoutLocale === "/" ? "" : pathWithoutLocale}`;
3142
+ link.push({
3143
+ rel: "alternate",
3144
+ hreflang: loc,
3145
+ href: `${origin}${localizedPath}`
3146
+ });
3147
+ if (loc !== locale) meta.push({
3148
+ property: "og:locale:alternate",
3149
+ content: loc
3150
+ });
3151
+ }
3152
+ link.push({
3153
+ rel: "alternate",
3154
+ hreflang: "x-default",
3155
+ href: `${origin}${pathWithoutLocale}`
3156
+ });
3157
+ }
3158
+ return {
3159
+ meta,
3160
+ link,
3161
+ script
3162
+ };
3163
+ }
3164
+
3165
+ //#endregion
3166
+ export { Image, Link, Meta, Script, ThemeToggle, buildLocalePath, buildMetaTags, bunAdapter, cacheMiddleware, compose, compressResponse, compressionMiddleware, corsMiddleware, createActionMiddleware, createApiMiddleware, createApp, createISRHandler, createLink, createLocaleContext, createServer, zeroPlugin as default, defineAction, defineConfig, detectLocaleFromHeader, extractLocaleFromPath, faviconPlugin, filePathToUrlPath, fontPlugin, fontVariables, generateApiRouteModule, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, imagePlugin, initTheme, isCompressible, jsonLd, nodeAdapter, parseFileRoutes, prefetchRoute, rateLimitMiddleware, render404Page, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, securityHeaders, seoMiddleware, seoPlugin, setLocale, setTheme, staticAdapter, theme, themeScript, toggleTheme, useLink, useLocale, varyEncoding };
2312
3167
  //# sourceMappingURL=index.js.map