@pyreon/zero 0.12.1 → 0.12.2

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 (42) hide show
  1. package/lib/index.js +1476 -82
  2. package/lib/index.js.map +1 -1
  3. package/lib/types/adapters/cloudflare.d.ts +26 -0
  4. package/lib/types/adapters/cloudflare.d.ts.map +1 -0
  5. package/lib/types/adapters/index.d.ts +3 -0
  6. package/lib/types/adapters/index.d.ts.map +1 -1
  7. package/lib/types/adapters/netlify.d.ts +21 -0
  8. package/lib/types/adapters/netlify.d.ts.map +1 -0
  9. package/lib/types/adapters/vercel.d.ts +21 -0
  10. package/lib/types/adapters/vercel.d.ts.map +1 -0
  11. package/lib/types/ai.d.ts +182 -0
  12. package/lib/types/ai.d.ts.map +1 -0
  13. package/lib/types/csp.d.ts +107 -0
  14. package/lib/types/csp.d.ts.map +1 -0
  15. package/lib/types/env.d.ts +118 -0
  16. package/lib/types/env.d.ts.map +1 -0
  17. package/lib/types/favicon.d.ts +42 -0
  18. package/lib/types/favicon.d.ts.map +1 -1
  19. package/lib/types/index.d.ts +13 -3
  20. package/lib/types/index.d.ts.map +1 -1
  21. package/lib/types/logger.d.ts +68 -0
  22. package/lib/types/logger.d.ts.map +1 -0
  23. package/lib/types/meta.d.ts +36 -0
  24. package/lib/types/meta.d.ts.map +1 -1
  25. package/lib/types/og-image.d.ts +107 -0
  26. package/lib/types/og-image.d.ts.map +1 -0
  27. package/lib/types/types.d.ts +1 -1
  28. package/lib/types/types.d.ts.map +1 -1
  29. package/package.json +35 -10
  30. package/src/adapters/cloudflare.ts +82 -0
  31. package/src/adapters/index.ts +13 -1
  32. package/src/adapters/netlify.ts +84 -0
  33. package/src/adapters/vercel.ts +84 -0
  34. package/src/ai.ts +623 -0
  35. package/src/csp.ts +207 -0
  36. package/src/env.ts +344 -0
  37. package/src/favicon.ts +221 -80
  38. package/src/index.ts +41 -2
  39. package/src/logger.ts +144 -0
  40. package/src/meta.tsx +84 -2
  41. package/src/og-image.ts +378 -0
  42. package/src/types.ts +1 -1
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import { a as parseFileRoutes, i as generateRouteModule, o as scanRouteFiles, r
2
2
  import { Fragment, createContext, createRef, h, onMount, onUnmount } from "@pyreon/core";
3
3
  import { HeadProvider, useHead } from "@pyreon/head";
4
4
  import { RouterProvider, RouterView, createRouter, useRouter } from "@pyreon/router";
5
- import { createHandler } from "@pyreon/server";
5
+ import { createHandler, useRequestLocals } from "@pyreon/server";
6
6
  import { renderToString } from "@pyreon/runtime-server";
7
7
  import { existsSync, readdirSync } from "node:fs";
8
8
  import { basename, extname, join } from "node:path";
@@ -709,6 +709,152 @@ console.log("\\n ⚡ Zero production server running on http://localhost:${port}
709
709
  };
710
710
  }
711
711
 
712
+ //#endregion
713
+ //#region src/adapters/cloudflare.ts
714
+ /**
715
+ * Cloudflare Pages adapter — generates output for Cloudflare Pages with Functions.
716
+ *
717
+ * Produces:
718
+ * - Client assets in the output directory root (served as static)
719
+ * - `_worker.js` — Cloudflare Pages Function for SSR
720
+ *
721
+ * Note: Cloudflare Pages Functions have a ~1MB module size limit.
722
+ * For large apps, configure Vite's SSR build to bundle server code:
723
+ * `ssr: { noExternal: true }` in vite.config.ts.
724
+ *
725
+ * Deploy with: `npx wrangler pages deploy ./dist`
726
+ *
727
+ * @example
728
+ * ```ts
729
+ * // zero.config.ts
730
+ * import { defineConfig } from "@pyreon/zero"
731
+ *
732
+ * export default defineConfig({
733
+ * adapter: "cloudflare",
734
+ * })
735
+ * ```
736
+ */
737
+ function cloudflareAdapter() {
738
+ return {
739
+ name: "cloudflare",
740
+ async build(options) {
741
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
742
+ const { join } = await import("node:path");
743
+ const outDir = options.outDir;
744
+ await mkdir(outDir, { recursive: true });
745
+ await cp(options.clientOutDir, outDir, { recursive: true });
746
+ await cp(join(options.serverEntry, ".."), join(outDir, "_server"), { recursive: true });
747
+ const workerEntry = `
748
+ import handler from "./_server/entry-server.js"
749
+
750
+ export default {
751
+ async fetch(request, env, ctx) {
752
+ const url = new URL(request.url)
753
+
754
+ // Let Cloudflare serve static assets (files with extensions)
755
+ // This check is a fallback — Pages routes static files automatically
756
+ const ext = url.pathname.split(".").pop()
757
+ if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
758
+ // Cloudflare Pages handles static assets automatically via its asset binding
759
+ // Only reach here if the file doesn't exist — fall through to SSR
760
+ }
761
+
762
+ // SSR handler
763
+ try {
764
+ return await handler(request)
765
+ } catch (err) {
766
+ return new Response("Internal Server Error", { status: 500 })
767
+ }
768
+ },
769
+ }
770
+ `.trimStart();
771
+ await writeFile(join(outDir, "_worker.js"), workerEntry);
772
+ await writeFile(join(outDir, "_routes.json"), JSON.stringify({
773
+ version: 1,
774
+ include: ["/*"],
775
+ exclude: [
776
+ "/assets/*",
777
+ "/favicon.*",
778
+ "/site.webmanifest",
779
+ "/robots.txt",
780
+ "/sitemap.xml"
781
+ ]
782
+ }, null, 2));
783
+ }
784
+ };
785
+ }
786
+
787
+ //#endregion
788
+ //#region src/adapters/netlify.ts
789
+ /**
790
+ * Netlify adapter — generates output for Netlify Functions (v2).
791
+ *
792
+ * Produces:
793
+ * - Client assets in `publish/` directory
794
+ * - `netlify/functions/ssr.mjs` — Netlify Function for SSR
795
+ * - `netlify.toml` — routing configuration
796
+ *
797
+ * @example
798
+ * ```ts
799
+ * // zero.config.ts
800
+ * import { defineConfig } from "@pyreon/zero"
801
+ *
802
+ * export default defineConfig({
803
+ * adapter: "netlify",
804
+ * })
805
+ * ```
806
+ */
807
+ function netlifyAdapter() {
808
+ return {
809
+ name: "netlify",
810
+ async build(options) {
811
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
812
+ const { join } = await import("node:path");
813
+ const outDir = options.outDir;
814
+ const publishDir = join(outDir, "publish");
815
+ const functionsDir = join(outDir, "netlify", "functions");
816
+ await mkdir(publishDir, { recursive: true });
817
+ await mkdir(functionsDir, { recursive: true });
818
+ await cp(options.clientOutDir, publishDir, { recursive: true });
819
+ await cp(join(options.serverEntry, ".."), join(functionsDir, "_server"), { recursive: true });
820
+ const funcEntry = `
821
+ import handler from "./_server/entry-server.js"
822
+
823
+ export default async function(req, context) {
824
+ try {
825
+ return await handler(req)
826
+ } catch (err) {
827
+ return new Response("Internal Server Error", { status: 500 })
828
+ }
829
+ }
830
+
831
+ export const config = {
832
+ path: "/*",
833
+ preferStatic: true,
834
+ }
835
+ `.trimStart();
836
+ await writeFile(join(functionsDir, "ssr.mjs"), funcEntry);
837
+ const toml = `
838
+ [build]
839
+ publish = "publish"
840
+ functions = "netlify/functions"
841
+
842
+ [[headers]]
843
+ for = "/assets/*"
844
+ [headers.values]
845
+ Cache-Control = "public, max-age=31536000, immutable"
846
+
847
+ [[redirects]]
848
+ from = "/*"
849
+ to = "/.netlify/functions/ssr"
850
+ status = 200
851
+ conditions = {Role = ["admin", "user", ""]}
852
+ `.trimStart();
853
+ await writeFile(join(outDir, "netlify.toml"), toml);
854
+ }
855
+ };
856
+ }
857
+
712
858
  //#endregion
713
859
  //#region src/adapters/node.ts
714
860
  /**
@@ -827,6 +973,72 @@ function staticAdapter() {
827
973
  };
828
974
  }
829
975
 
976
+ //#endregion
977
+ //#region src/adapters/vercel.ts
978
+ /**
979
+ * Vercel adapter — generates output for Vercel's Build Output API v3.
980
+ *
981
+ * Produces a `.vercel/output` directory with:
982
+ * - `static/` — client-side assets (JS, CSS, images)
983
+ * - `functions/ssr.func/` — serverless function for SSR
984
+ * - `config.json` — routing configuration
985
+ *
986
+ * @example
987
+ * ```ts
988
+ * // zero.config.ts
989
+ * import { defineConfig } from "@pyreon/zero"
990
+ *
991
+ * export default defineConfig({
992
+ * adapter: "vercel",
993
+ * })
994
+ * ```
995
+ */
996
+ function vercelAdapter() {
997
+ return {
998
+ name: "vercel",
999
+ async build(options) {
1000
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
1001
+ const { join } = await import("node:path");
1002
+ const vercelDir = join(options.outDir, ".vercel", "output");
1003
+ const staticDir = join(vercelDir, "static");
1004
+ const funcDir = join(vercelDir, "functions", "ssr.func");
1005
+ await mkdir(staticDir, { recursive: true });
1006
+ await mkdir(funcDir, { recursive: true });
1007
+ await cp(options.clientOutDir, staticDir, { recursive: true });
1008
+ await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
1009
+ const funcEntry = `
1010
+ export default async function handler(req) {
1011
+ const handler = (await import("./entry-server.js")).default
1012
+ return handler(req)
1013
+ }
1014
+ `.trimStart();
1015
+ await writeFile(join(funcDir, "index.js"), funcEntry);
1016
+ await writeFile(join(funcDir, ".vc-config.json"), JSON.stringify({
1017
+ runtime: "nodejs20.x",
1018
+ handler: "index.js",
1019
+ launcherType: "Nodejs"
1020
+ }, null, 2));
1021
+ await writeFile(join(vercelDir, "config.json"), JSON.stringify({
1022
+ version: 3,
1023
+ routes: [
1024
+ {
1025
+ src: "/assets/(.*)",
1026
+ headers: { "Cache-Control": "public, max-age=31536000, immutable" }
1027
+ },
1028
+ {
1029
+ src: "/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)",
1030
+ dest: "/$1"
1031
+ },
1032
+ {
1033
+ src: "/(.*)",
1034
+ dest: "/ssr"
1035
+ }
1036
+ ]
1037
+ }, null, 2));
1038
+ }
1039
+ };
1040
+ }
1041
+
830
1042
  //#endregion
831
1043
  //#region src/adapters/index.ts
832
1044
  /**
@@ -839,7 +1051,10 @@ function resolveAdapter(config) {
839
1051
  case "node": return nodeAdapter();
840
1052
  case "bun": return bunAdapter();
841
1053
  case "static": return staticAdapter();
842
- default: throw new Error(`[zero] Unknown adapter: "${name}". Use "node", "bun", or "static".`);
1054
+ case "vercel": return vercelAdapter();
1055
+ case "cloudflare": return cloudflareAdapter();
1056
+ case "netlify": return netlifyAdapter();
1057
+ default: throw new Error(`[zero] Unknown adapter: "${name}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`);
843
1058
  }
844
1059
  }
845
1060
 
@@ -1659,10 +1874,10 @@ function fontVariables(families) {
1659
1874
 
1660
1875
  //#endregion
1661
1876
  //#region src/image-plugin.ts
1662
- let sharpWarned$1 = false;
1663
- function warnSharpMissing$1() {
1664
- if (sharpWarned$1) return;
1665
- sharpWarned$1 = true;
1877
+ let sharpWarned$2 = false;
1878
+ function warnSharpMissing$2() {
1879
+ if (sharpWarned$2) return;
1880
+ sharpWarned$2 = true;
1666
1881
  console.warn("\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n");
1667
1882
  }
1668
1883
  const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i;
@@ -1925,7 +2140,7 @@ async function resizeImage(input, output, width, format, quality) {
1925
2140
  }
1926
2141
  await pipeline.toFile(output);
1927
2142
  } catch {
1928
- warnSharpMissing$1();
2143
+ warnSharpMissing$2();
1929
2144
  await writeFile(output, await readFile(input));
1930
2145
  }
1931
2146
  }
@@ -2123,14 +2338,14 @@ ${[...routeFiles.filter((f) => {
2123
2338
  priority
2124
2339
  })), ...config.additionalPaths ?? []].map((entry) => {
2125
2340
  return ` <url>
2126
- <loc>${escapeXml(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
2341
+ <loc>${escapeXml$1(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
2127
2342
  <changefreq>${entry.changefreq ?? changefreq}</changefreq>
2128
2343
  <priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
2129
2344
  </url>`;
2130
2345
  }).join("\n")}
2131
2346
  </urlset>`;
2132
2347
  }
2133
- function escapeXml(str) {
2348
+ function escapeXml$1(str) {
2134
2349
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2135
2350
  }
2136
2351
  /**
@@ -2514,10 +2729,10 @@ async function executeAction(action, req) {
2514
2729
 
2515
2730
  //#endregion
2516
2731
  //#region src/favicon.ts
2517
- let sharpWarned = false;
2518
- function warnSharpMissing() {
2519
- if (sharpWarned) return;
2520
- sharpWarned = true;
2732
+ let sharpWarned$1 = false;
2733
+ function warnSharpMissing$1() {
2734
+ if (sharpWarned$1) return;
2735
+ sharpWarned$1 = true;
2521
2736
  console.warn("\n[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
2522
2737
  }
2523
2738
  const SIZES = [
@@ -2573,17 +2788,31 @@ function faviconPlugin(config) {
2573
2788
  },
2574
2789
  configureServer(server) {
2575
2790
  const sourcePath = join(root, config.source);
2791
+ const devCache = /* @__PURE__ */ new Map();
2576
2792
  server.middlewares.use(async (req, res, next) => {
2577
2793
  const url = req.url ?? "";
2578
- if (url === "/favicon.svg" && config.source.endsWith(".svg")) try {
2579
- const content = await readFile(sourcePath, "utf-8");
2794
+ const localeSource = resolveLocaleSource(url, config, root);
2795
+ const svgUrl = localeSource ? localeSource.url : url;
2796
+ const svgPath = localeSource ? localeSource.sourcePath : sourcePath;
2797
+ const isSvgSource = localeSource ? localeSource.source.endsWith(".svg") : config.source.endsWith(".svg");
2798
+ if (svgUrl.endsWith("/favicon.svg") && isSvgSource) try {
2799
+ const content = await readFile(svgPath, "utf-8");
2580
2800
  res.setHeader("Content-Type", "image/svg+xml");
2581
2801
  res.end(content);
2582
2802
  return;
2583
2803
  } catch {}
2584
- const sizeMatch = SIZES.find((s) => url === `/${s.name}`);
2804
+ const baseName = svgUrl.split("/").pop() ?? "";
2805
+ const sizeMatch = SIZES.find((s) => s.name === baseName);
2585
2806
  if (sizeMatch) {
2586
- const png = await resizeToPng(sourcePath, sizeMatch.size);
2807
+ const cacheKey = `${svgPath}:${sizeMatch.size}`;
2808
+ let png = devCache.get(cacheKey);
2809
+ if (!png) {
2810
+ const result = await resizeToPng(svgPath, sizeMatch.size);
2811
+ if (result) {
2812
+ png = result;
2813
+ devCache.set(cacheKey, result);
2814
+ }
2815
+ }
2587
2816
  if (png) {
2588
2817
  res.setHeader("Content-Type", "image/png");
2589
2818
  res.setHeader("Cache-Control", "no-cache");
@@ -2591,8 +2820,16 @@ function faviconPlugin(config) {
2591
2820
  return;
2592
2821
  }
2593
2822
  }
2594
- if (url === "/favicon.ico") {
2595
- const ico = await generateIco(sourcePath);
2823
+ if (baseName === "favicon.ico") {
2824
+ const cacheKey = `ico:${svgPath}`;
2825
+ let ico = devCache.get(cacheKey);
2826
+ if (!ico) {
2827
+ const result = await generateIco(svgPath);
2828
+ if (result) {
2829
+ ico = result;
2830
+ devCache.set(cacheKey, result);
2831
+ }
2832
+ }
2596
2833
  if (ico) {
2597
2834
  res.setHeader("Content-Type", "image/x-icon");
2598
2835
  res.setHeader("Cache-Control", "no-cache");
@@ -2600,16 +2837,17 @@ function faviconPlugin(config) {
2600
2837
  return;
2601
2838
  }
2602
2839
  }
2603
- if (url === "/site.webmanifest" && generateManifest) {
2840
+ if (baseName === "site.webmanifest" && generateManifest) {
2841
+ const prefix = localeSource ? `/${localeSource.locale}` : "";
2604
2842
  const manifest = {
2605
2843
  name: config.name ?? "App",
2606
2844
  short_name: config.name ?? "App",
2607
2845
  icons: [{
2608
- src: "/icon-192.png",
2846
+ src: `${prefix}/icon-192.png`,
2609
2847
  sizes: "192x192",
2610
2848
  type: "image/png"
2611
2849
  }, {
2612
- src: "/icon-512.png",
2850
+ src: `${prefix}/icon-512.png`,
2613
2851
  sizes: "512x512",
2614
2852
  type: "image/png"
2615
2853
  }],
@@ -2683,61 +2921,8 @@ function faviconPlugin(config) {
2683
2921
  },
2684
2922
  async generateBundle() {
2685
2923
  if (!isBuild) return;
2686
- const sourcePath = join(root, config.source);
2687
- if (!existsSync(sourcePath)) {
2688
- console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
2689
- return;
2690
- }
2691
- if (config.source.endsWith(".svg")) {
2692
- const svgContent = await readFile(sourcePath, "utf-8");
2693
- let finalSvg = svgContent;
2694
- if (config.darkSource) {
2695
- const darkPath = join(root, config.darkSource);
2696
- if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
2697
- }
2698
- this.emitFile({
2699
- type: "asset",
2700
- fileName: "favicon.svg",
2701
- source: finalSvg
2702
- });
2703
- }
2704
- for (const { size, name } of SIZES) {
2705
- const pngBuffer = await resizeToPng(sourcePath, size);
2706
- if (pngBuffer) this.emitFile({
2707
- type: "asset",
2708
- fileName: name,
2709
- source: pngBuffer
2710
- });
2711
- }
2712
- const ico = await generateIco(sourcePath);
2713
- if (ico) this.emitFile({
2714
- type: "asset",
2715
- fileName: "favicon.ico",
2716
- source: ico
2717
- });
2718
- if (generateManifest) {
2719
- const manifest = {
2720
- name: config.name ?? "App",
2721
- short_name: config.name ?? "App",
2722
- icons: [{
2723
- src: "/icon-192.png",
2724
- sizes: "192x192",
2725
- type: "image/png"
2726
- }, {
2727
- src: "/icon-512.png",
2728
- sizes: "512x512",
2729
- type: "image/png"
2730
- }],
2731
- theme_color: themeColor,
2732
- background_color: backgroundColor,
2733
- display: "standalone"
2734
- };
2735
- this.emitFile({
2736
- type: "asset",
2737
- fileName: "site.webmanifest",
2738
- source: JSON.stringify(manifest, null, 2)
2739
- });
2740
- }
2924
+ await generateFaviconSet.call(this, root, config.source, config.darkSource, "", config, themeColor, backgroundColor, generateManifest);
2925
+ if (config.locales) for (const [locale, localeConfig] of Object.entries(config.locales)) await generateFaviconSet.call(this, root, localeConfig.source, localeConfig.darkSource, `${locale}/`, config, themeColor, backgroundColor, generateManifest);
2741
2926
  }
2742
2927
  };
2743
2928
  }
@@ -2758,6 +2943,126 @@ function wrapSvgWithDarkMode(lightSvg, darkSvg) {
2758
2943
  function stripSvgWrapper(svg) {
2759
2944
  return svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
2760
2945
  }
2946
+ /**
2947
+ * Resolve the source path for a locale-prefixed favicon URL.
2948
+ * Returns null if the URL is not locale-prefixed or locale has no override.
2949
+ */
2950
+ function resolveLocaleSource(url, config, rootDir) {
2951
+ if (!config.locales) return null;
2952
+ for (const [locale, localeConfig] of Object.entries(config.locales)) {
2953
+ const prefix = `/${locale}/`;
2954
+ if (url.startsWith(prefix)) return {
2955
+ locale,
2956
+ url,
2957
+ source: localeConfig.source,
2958
+ sourcePath: join(rootDir, localeConfig.source)
2959
+ };
2960
+ }
2961
+ return null;
2962
+ }
2963
+ /**
2964
+ * Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.
2965
+ * Called once for base (prefix = '') and once per locale (prefix = '{locale}/').
2966
+ */
2967
+ async function generateFaviconSet(rootDir, source, darkSource, prefix, config, themeColor, backgroundColor, generateManifest) {
2968
+ const sourcePath = join(rootDir, source);
2969
+ if (!existsSync(sourcePath)) {
2970
+ console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
2971
+ return;
2972
+ }
2973
+ if (source.endsWith(".svg")) {
2974
+ const svgContent = await readFile(sourcePath, "utf-8");
2975
+ let finalSvg = svgContent;
2976
+ if (darkSource) {
2977
+ const darkPath = join(rootDir, darkSource);
2978
+ if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
2979
+ }
2980
+ this.emitFile({
2981
+ type: "asset",
2982
+ fileName: `${prefix}favicon.svg`,
2983
+ source: finalSvg
2984
+ });
2985
+ }
2986
+ for (const { size, name } of SIZES) {
2987
+ const pngBuffer = await resizeToPng(sourcePath, size);
2988
+ if (pngBuffer) this.emitFile({
2989
+ type: "asset",
2990
+ fileName: `${prefix}${name}`,
2991
+ source: pngBuffer
2992
+ });
2993
+ }
2994
+ const ico = await generateIco(sourcePath);
2995
+ if (ico) this.emitFile({
2996
+ type: "asset",
2997
+ fileName: `${prefix}favicon.ico`,
2998
+ source: ico
2999
+ });
3000
+ if (generateManifest) {
3001
+ const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : "";
3002
+ const manifest = {
3003
+ name: config.name ?? "App",
3004
+ short_name: config.name ?? "App",
3005
+ icons: [{
3006
+ src: `${manifestPrefix}/icon-192.png`,
3007
+ sizes: "192x192",
3008
+ type: "image/png"
3009
+ }, {
3010
+ src: `${manifestPrefix}/icon-512.png`,
3011
+ sizes: "512x512",
3012
+ type: "image/png"
3013
+ }],
3014
+ theme_color: themeColor,
3015
+ background_color: backgroundColor,
3016
+ display: "standalone"
3017
+ };
3018
+ this.emitFile({
3019
+ type: "asset",
3020
+ fileName: `${prefix}site.webmanifest`,
3021
+ source: JSON.stringify(manifest, null, 2)
3022
+ });
3023
+ }
3024
+ }
3025
+ /**
3026
+ * Get favicon link tags for a specific locale.
3027
+ * Returns link objects suitable for `useHead()` or direct HTML injection.
3028
+ *
3029
+ * @example
3030
+ * ```ts
3031
+ * const links = faviconLinks("de", { source: "./icon.svg", locales: { de: { source: "./icon-de.svg" } } })
3032
+ * // → [{ rel: "icon", type: "image/svg+xml", href: "/de/favicon.svg" }, ...]
3033
+ * ```
3034
+ */
3035
+ function faviconLinks(locale, config) {
3036
+ const hasLocaleOverride = locale && config.locales?.[locale];
3037
+ const prefix = hasLocaleOverride ? `/${locale}` : "";
3038
+ const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
3039
+ const links = [];
3040
+ if (isSvg) links.push({
3041
+ rel: "icon",
3042
+ type: "image/svg+xml",
3043
+ href: `${prefix}/favicon.svg`
3044
+ });
3045
+ links.push({
3046
+ rel: "icon",
3047
+ type: "image/png",
3048
+ sizes: "32x32",
3049
+ href: `${prefix}/favicon-32x32.png`
3050
+ }, {
3051
+ rel: "icon",
3052
+ type: "image/png",
3053
+ sizes: "16x16",
3054
+ href: `${prefix}/favicon-16x16.png`
3055
+ }, {
3056
+ rel: "apple-touch-icon",
3057
+ sizes: "180x180",
3058
+ href: `${prefix}/apple-touch-icon.png`
3059
+ });
3060
+ if (config.manifest !== false) links.push({
3061
+ rel: "manifest",
3062
+ href: `${prefix}/site.webmanifest`
3063
+ });
3064
+ return links;
3065
+ }
2761
3066
  async function resizeToPng(input, size) {
2762
3067
  try {
2763
3068
  return await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, {
@@ -2770,7 +3075,7 @@ async function resizeToPng(input, size) {
2770
3075
  }
2771
3076
  }).png().toBuffer();
2772
3077
  } catch {
2773
- warnSharpMissing();
3078
+ warnSharpMissing$1();
2774
3079
  return null;
2775
3080
  }
2776
3081
  }
@@ -2803,7 +3108,7 @@ async function generateIco(input) {
2803
3108
  size: 32
2804
3109
  }]);
2805
3110
  } catch {
2806
- warnSharpMissing();
3111
+ warnSharpMissing$1();
2807
3112
  return null;
2808
3113
  }
2809
3114
  }
@@ -2840,6 +3145,234 @@ function createIcoFromPngs(entries) {
2840
3145
  ]);
2841
3146
  }
2842
3147
 
3148
+ //#endregion
3149
+ //#region src/og-image.ts
3150
+ /**
3151
+ * OG Image generation plugin.
3152
+ *
3153
+ * Generates Open Graph images at build time from templates with
3154
+ * text overlays. Supports locale-specific text for i18n apps.
3155
+ * Uses sharp for image processing (same optional dep as favicon/image plugins).
3156
+ *
3157
+ * @example
3158
+ * ```ts
3159
+ * // vite.config.ts
3160
+ * import { ogImagePlugin } from "@pyreon/zero/og-image"
3161
+ *
3162
+ * export default {
3163
+ * plugins: [
3164
+ * ogImagePlugin({
3165
+ * locales: ["en", "de", "cs"],
3166
+ * templates: [{
3167
+ * name: "default",
3168
+ * background: "./src/assets/og-bg.jpg",
3169
+ * layers: [{
3170
+ * text: { en: "Build faster", de: "Schneller bauen", cs: "Stavte rychleji" },
3171
+ * y: "40%",
3172
+ * fontSize: 72,
3173
+ * }],
3174
+ * }],
3175
+ * }),
3176
+ * ],
3177
+ * }
3178
+ * ```
3179
+ */
3180
+ let sharpWarned = false;
3181
+ function warnSharpMissing() {
3182
+ if (sharpWarned) return;
3183
+ sharpWarned = true;
3184
+ console.warn("\n[zero:og-image] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n");
3185
+ }
3186
+ function resolvePosition(value, dimension, fallback = "50%") {
3187
+ if (value === void 0) value = fallback;
3188
+ if (typeof value === "number") return value;
3189
+ if (value.endsWith("%")) return Math.round(Number.parseFloat(value) / 100 * dimension);
3190
+ return Number.parseInt(value, 10) || 0;
3191
+ }
3192
+ function resolveLayerText(layer, locale) {
3193
+ if (typeof layer.text === "string") return layer.text;
3194
+ if (typeof layer.text === "function") return layer.text(locale);
3195
+ return layer.text[locale] ?? layer.text[Object.keys(layer.text)[0] ?? ""] ?? "";
3196
+ }
3197
+ function escapeXml(str) {
3198
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3199
+ }
3200
+ /**
3201
+ * Build an SVG overlay with text layers.
3202
+ * @internal Exported for testing.
3203
+ */
3204
+ function buildTextOverlaySvg(layers, width, height, locale) {
3205
+ return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${layers.map((layer) => {
3206
+ const text = resolveLayerText(layer, locale);
3207
+ const x = resolvePosition(layer.x, width, "50%");
3208
+ const y = resolvePosition(layer.y, height, "50%");
3209
+ const fontSize = layer.fontSize ?? 64;
3210
+ const fontFamily = layer.fontFamily ?? "sans-serif";
3211
+ const fontWeight = layer.fontWeight ?? "bold";
3212
+ const color = layer.color ?? "#ffffff";
3213
+ const anchor = layer.textAnchor ?? "middle";
3214
+ const maxWidth = layer.maxWidth ?? Math.round(width * .8);
3215
+ const words = text.split(" ");
3216
+ const lines = [];
3217
+ let currentLine = "";
3218
+ const estimateWidth = (s) => {
3219
+ let width = 0;
3220
+ for (let i = 0; i < s.length; i++) {
3221
+ const code = s.charCodeAt(i);
3222
+ if (code >= 12288 && code <= 40959) width += fontSize * 1;
3223
+ else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) width += fontSize * .35;
3224
+ else width += fontSize * .55;
3225
+ }
3226
+ return width;
3227
+ };
3228
+ for (const word of words) {
3229
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
3230
+ if (estimateWidth(testLine) > maxWidth && currentLine) {
3231
+ lines.push(currentLine);
3232
+ currentLine = word;
3233
+ } else currentLine = testLine;
3234
+ }
3235
+ if (currentLine) lines.push(currentLine);
3236
+ const tspans = lines.map((line, i) => {
3237
+ return `<tspan x="${x}" dy="${i === 0 ? "0" : `${fontSize * 1.2}`}">${escapeXml(line)}</tspan>`;
3238
+ }).join("");
3239
+ return `<text x="${x}" y="${y}" font-size="${fontSize}" font-family="${escapeXml(fontFamily)}" font-weight="${fontWeight}" fill="${color}" text-anchor="${anchor}" dominant-baseline="middle">${tspans}</text>`;
3240
+ }).join("")}</svg>`;
3241
+ }
3242
+ /**
3243
+ * Render an OG image from a template for a specific locale.
3244
+ * @internal Exported for testing.
3245
+ */
3246
+ async function renderOgImage(template, locale, rootDir) {
3247
+ try {
3248
+ const sharp = await import("sharp").then((m) => m.default ?? m);
3249
+ const width = template.width ?? 1200;
3250
+ const height = template.height ?? 630;
3251
+ let pipeline;
3252
+ if (typeof template.background === "string") pipeline = sharp(join(rootDir, template.background)).resize(width, height, { fit: "cover" });
3253
+ else pipeline = sharp({ create: {
3254
+ width,
3255
+ height,
3256
+ channels: 4,
3257
+ background: template.background.color
3258
+ } });
3259
+ if (template.layers && template.layers.length > 0) {
3260
+ const svgOverlay = buildTextOverlaySvg(template.layers, width, height, locale);
3261
+ pipeline = pipeline.composite([{
3262
+ input: Buffer.from(svgOverlay),
3263
+ top: 0,
3264
+ left: 0
3265
+ }]);
3266
+ }
3267
+ if (template.format === "jpeg") return await pipeline.jpeg({ quality: template.quality ?? 90 }).toBuffer();
3268
+ return await pipeline.png().toBuffer();
3269
+ } catch {
3270
+ warnSharpMissing();
3271
+ return null;
3272
+ }
3273
+ }
3274
+ /**
3275
+ * Compute the OG image path for a template and locale.
3276
+ *
3277
+ * @example
3278
+ * ```ts
3279
+ * ogImagePath("default", "de") // → "/og/default-de.png"
3280
+ * ogImagePath("default") // → "/og/default.png"
3281
+ * ogImagePath("hero", "en", "images") // → "/images/hero-en.png"
3282
+ * ```
3283
+ */
3284
+ function ogImagePath(templateName, locale, outDir = "og", format = "png") {
3285
+ const ext = format === "jpeg" ? "jpg" : "png";
3286
+ return `/${outDir}/${templateName}${locale ? `-${locale}` : ""}.${ext}`;
3287
+ }
3288
+ /**
3289
+ * OG image generation Vite plugin.
3290
+ *
3291
+ * Generates Open Graph images at build time. In dev, generates on-demand.
3292
+ * Requires `sharp` as an optional dependency.
3293
+ *
3294
+ * @example
3295
+ * ```ts
3296
+ * // vite.config.ts
3297
+ * import { ogImagePlugin } from "@pyreon/zero/og-image"
3298
+ *
3299
+ * export default {
3300
+ * plugins: [
3301
+ * ogImagePlugin({
3302
+ * locales: ["en", "de"],
3303
+ * templates: [{
3304
+ * name: "default",
3305
+ * background: { color: "#0066ff" },
3306
+ * layers: [{ text: { en: "Hello", de: "Hallo" }, fontSize: 72 }],
3307
+ * }],
3308
+ * }),
3309
+ * ],
3310
+ * }
3311
+ * ```
3312
+ */
3313
+ function ogImagePlugin(config) {
3314
+ const outDir = config.outDir ?? "og";
3315
+ let root = "";
3316
+ let isBuild = false;
3317
+ return {
3318
+ name: "pyreon-zero-og-image",
3319
+ enforce: "pre",
3320
+ configResolved(resolvedConfig) {
3321
+ root = resolvedConfig.root;
3322
+ isBuild = resolvedConfig.command === "build";
3323
+ },
3324
+ configureServer(server) {
3325
+ const devCache = /* @__PURE__ */ new Map();
3326
+ server.middlewares.use(async (req, res, next) => {
3327
+ const url = req.url ?? "";
3328
+ if (!url.startsWith(`/${outDir}/`)) return next();
3329
+ const match = url.slice(outDir.length + 2).match(/^(.+?)(?:-([a-z]{2,5}))?\.(png|jpe?g)$/);
3330
+ if (!match) return next();
3331
+ const [, templateName, locale, ext] = match;
3332
+ const template = config.templates.find((t) => t.name === templateName);
3333
+ if (!template) return next();
3334
+ const resolvedLocale = locale ?? config.locales?.[0] ?? "en";
3335
+ const cacheKey = `${templateName}:${resolvedLocale}`;
3336
+ let buffer = devCache.get(cacheKey);
3337
+ if (!buffer) {
3338
+ const result = await renderOgImage(template, resolvedLocale, root);
3339
+ if (!result) return next();
3340
+ buffer = result;
3341
+ devCache.set(cacheKey, result);
3342
+ }
3343
+ const contentType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
3344
+ res.setHeader("Content-Type", contentType);
3345
+ res.setHeader("Cache-Control", "no-cache");
3346
+ res.end(Buffer.from(buffer));
3347
+ });
3348
+ },
3349
+ async generateBundle() {
3350
+ if (!isBuild) return;
3351
+ for (const template of config.templates) {
3352
+ const locales = config.locales ?? [void 0];
3353
+ const ext = (template.format ?? "png") === "jpeg" ? "jpg" : "png";
3354
+ for (const locale of locales) {
3355
+ if (typeof template.background === "string") {
3356
+ const bgPath = join(root, template.background);
3357
+ if (!existsSync(bgPath)) {
3358
+ console.warn(`[zero:og-image] Background not found: ${bgPath}`);
3359
+ continue;
3360
+ }
3361
+ }
3362
+ const buffer = await renderOgImage(template, locale ?? "en", root);
3363
+ if (!buffer) continue;
3364
+ const suffix = locale ? `-${locale}` : "";
3365
+ this.emitFile({
3366
+ type: "asset",
3367
+ fileName: `${outDir}/${template.name}${suffix}.${ext}`,
3368
+ source: buffer
3369
+ });
3370
+ }
3371
+ }
3372
+ }
3373
+ };
3374
+ }
3375
+
2843
3376
  //#endregion
2844
3377
  //#region src/i18n-routing.ts
2845
3378
  /**
@@ -3062,7 +3595,11 @@ function buildMetaTags(props) {
3062
3595
  const meta = [];
3063
3596
  const link = [];
3064
3597
  const script = [];
3065
- 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;
3598
+ const { title, description, canonical, imageAlt, imageWidth, imageHeight, type = "website", siteName, twitterCard = "summary_large_image", twitterSite, twitterCreator, locale = "en_US", alternateLocales, publishedTime, modifiedTime, author, tags, jsonLd, extra, video, videoWidth, videoHeight, audio, favicon, ogTemplate, ogImageDir, ogImageFormat } = props;
3599
+ const robots = props.noIndex ? "noindex, nofollow" : props.robots ?? "index, follow";
3600
+ const image = props.image ?? (ogTemplate ? ogImagePath(ogTemplate, locale !== "en_US" ? locale : void 0, ogImageDir, ogImageFormat) : void 0);
3601
+ const resolvedImageWidth = imageWidth ?? (ogTemplate && !props.image ? 1200 : void 0);
3602
+ const resolvedImageHeight = imageHeight ?? (ogTemplate && !props.image ? 630 : void 0);
3066
3603
  if (description) meta.push({
3067
3604
  name: "description",
3068
3605
  content: description
@@ -3095,6 +3632,14 @@ function buildMetaTags(props) {
3095
3632
  property: "og:image:alt",
3096
3633
  content: imageAlt
3097
3634
  });
3635
+ if (resolvedImageWidth) meta.push({
3636
+ property: "og:image:width",
3637
+ content: String(resolvedImageWidth)
3638
+ });
3639
+ if (resolvedImageHeight) meta.push({
3640
+ property: "og:image:height",
3641
+ content: String(resolvedImageHeight)
3642
+ });
3098
3643
  meta.push({
3099
3644
  property: "og:type",
3100
3645
  content: type
@@ -3107,6 +3652,32 @@ function buildMetaTags(props) {
3107
3652
  property: "og:locale",
3108
3653
  content: locale
3109
3654
  });
3655
+ if (video) {
3656
+ meta.push({
3657
+ property: "og:video",
3658
+ content: video
3659
+ });
3660
+ if (videoWidth) meta.push({
3661
+ property: "og:video:width",
3662
+ content: String(videoWidth)
3663
+ });
3664
+ if (videoHeight) meta.push({
3665
+ property: "og:video:height",
3666
+ content: String(videoHeight)
3667
+ });
3668
+ if (video.endsWith(".mp4")) meta.push({
3669
+ property: "og:video:type",
3670
+ content: "video/mp4"
3671
+ });
3672
+ else if (video.endsWith(".webm")) meta.push({
3673
+ property: "og:video:type",
3674
+ content: "video/webm"
3675
+ });
3676
+ }
3677
+ if (audio) meta.push({
3678
+ property: "og:audio",
3679
+ content: audio
3680
+ });
3110
3681
  if (type === "article") {
3111
3682
  if (publishedTime) meta.push({
3112
3683
  property: "article:published_time",
@@ -3193,7 +3764,15 @@ function buildMetaTags(props) {
3193
3764
  href: `${origin}${pathWithoutLocale}`
3194
3765
  });
3195
3766
  }
3196
- return {
3767
+ if (favicon) {
3768
+ const faviconLocale = locale !== "en_US" ? locale : void 0;
3769
+ for (const fl of faviconLinks(faviconLocale, favicon)) link.push(fl);
3770
+ if (favicon.themeColor) meta.push({
3771
+ name: "theme-color",
3772
+ content: favicon.themeColor
3773
+ });
3774
+ }
3775
+ return {
3197
3776
  meta,
3198
3777
  link,
3199
3778
  script
@@ -3201,5 +3780,820 @@ function buildMetaTags(props) {
3201
3780
  }
3202
3781
 
3203
3782
  //#endregion
3204
- 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 };
3783
+ //#region src/csp.ts
3784
+ /** Client-side fallback nonce (dev server, SPA). */
3785
+ let _clientNonce = "";
3786
+ /**
3787
+ * Read the current CSP nonce in a component.
3788
+ *
3789
+ * SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
3790
+ * system — fully isolated between concurrent requests via AsyncLocalStorage.
3791
+ * Client/dev: falls back to module-level variable set by middleware.
3792
+ *
3793
+ * @example
3794
+ * ```tsx
3795
+ * import { useNonce } from "@pyreon/zero/csp"
3796
+ *
3797
+ * function InlineScript() {
3798
+ * const nonce = useNonce()
3799
+ * return <script nonce={nonce}>console.log("safe")<\/script>
3800
+ * }
3801
+ * ```
3802
+ */
3803
+ function useNonce() {
3804
+ const locals = useRequestLocals();
3805
+ if (locals.cspNonce) return locals.cspNonce;
3806
+ return _clientNonce;
3807
+ }
3808
+ const DIRECTIVE_MAP = {
3809
+ defaultSrc: "default-src",
3810
+ scriptSrc: "script-src",
3811
+ styleSrc: "style-src",
3812
+ imgSrc: "img-src",
3813
+ fontSrc: "font-src",
3814
+ connectSrc: "connect-src",
3815
+ mediaSrc: "media-src",
3816
+ objectSrc: "object-src",
3817
+ frameSrc: "frame-src",
3818
+ childSrc: "child-src",
3819
+ workerSrc: "worker-src",
3820
+ frameAncestors: "frame-ancestors",
3821
+ formAction: "form-action",
3822
+ baseUri: "base-uri",
3823
+ manifestSrc: "manifest-src",
3824
+ reportUri: "report-uri",
3825
+ reportTo: "report-to"
3826
+ };
3827
+ /**
3828
+ * Build a CSP header string from directives.
3829
+ * Exported for testing.
3830
+ */
3831
+ function buildCspHeader(directives, nonce) {
3832
+ const parts = [];
3833
+ for (const [key, cssProp] of Object.entries(DIRECTIVE_MAP)) {
3834
+ const value = directives[key];
3835
+ if (!value) continue;
3836
+ if (Array.isArray(value)) {
3837
+ const resolved = nonce ? value.map((v) => v === "'nonce'" ? `'nonce-${nonce}'` : v) : value.filter((v) => v !== "'nonce'");
3838
+ parts.push(`${cssProp} ${resolved.join(" ")}`);
3839
+ } else if (typeof value === "string") parts.push(`${cssProp} ${value}`);
3840
+ }
3841
+ if (directives.upgradeInsecureRequests) parts.push("upgrade-insecure-requests");
3842
+ if (directives.blockAllMixedContent) parts.push("block-all-mixed-content");
3843
+ return parts.join("; ");
3844
+ }
3845
+ /**
3846
+ * Generate a random nonce string (base64, 16 bytes).
3847
+ */
3848
+ function generateNonce() {
3849
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
3850
+ const bytes = new Uint8Array(16);
3851
+ crypto.getRandomValues(bytes);
3852
+ let binary = "";
3853
+ for (const byte of bytes) binary += String.fromCharCode(byte);
3854
+ return typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64");
3855
+ }
3856
+ return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
3857
+ }
3858
+ /**
3859
+ * CSP middleware — sets Content-Security-Policy header.
3860
+ *
3861
+ * When directives contain `"'nonce'"`, a fresh nonce is generated per-request
3862
+ * and attached to `ctx.locals.cspNonce` for use in inline script tags.
3863
+ *
3864
+ * @example
3865
+ * ```ts
3866
+ * // Apply to all routes
3867
+ * export default defineConfig({
3868
+ * middleware: [
3869
+ * cspMiddleware({
3870
+ * directives: {
3871
+ * defaultSrc: ["'self'"],
3872
+ * scriptSrc: ["'self'", "'nonce'"],
3873
+ * styleSrc: ["'self'", "'unsafe-inline'"],
3874
+ * imgSrc: ["'self'", "data:", "https:"],
3875
+ * },
3876
+ * }),
3877
+ * ],
3878
+ * })
3879
+ * ```
3880
+ */
3881
+ function cspMiddleware(config) {
3882
+ const headerName = config.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";
3883
+ const staticHeader = Object.values(config.directives).some((v) => Array.isArray(v) && v.includes("'nonce'")) ? null : buildCspHeader(config.directives);
3884
+ return (ctx) => {
3885
+ if (staticHeader) {
3886
+ _clientNonce = "";
3887
+ ctx.headers.set(headerName, staticHeader);
3888
+ } else {
3889
+ const nonce = generateNonce();
3890
+ _clientNonce = nonce;
3891
+ ctx.locals.cspNonce = nonce;
3892
+ ctx.headers.set(headerName, buildCspHeader(config.directives, nonce));
3893
+ }
3894
+ };
3895
+ }
3896
+
3897
+ //#endregion
3898
+ //#region src/logger.ts
3899
+ const COLORS = {
3900
+ reset: "\x1B[0m",
3901
+ dim: "\x1B[2m",
3902
+ green: "\x1B[32m",
3903
+ yellow: "\x1B[33m",
3904
+ red: "\x1B[31m",
3905
+ cyan: "\x1B[36m",
3906
+ magenta: "\x1B[35m"
3907
+ };
3908
+ function methodColor(method, colors) {
3909
+ if (!colors) return method.padEnd(7);
3910
+ const padded = method.padEnd(7);
3911
+ switch (method) {
3912
+ case "GET": return `${COLORS.green}${padded}${COLORS.reset}`;
3913
+ case "POST": return `${COLORS.cyan}${padded}${COLORS.reset}`;
3914
+ case "PUT": return `${COLORS.yellow}${padded}${COLORS.reset}`;
3915
+ case "PATCH": return `${COLORS.yellow}${padded}${COLORS.reset}`;
3916
+ case "DELETE": return `${COLORS.red}${padded}${COLORS.reset}`;
3917
+ default: return `${COLORS.magenta}${padded}${COLORS.reset}`;
3918
+ }
3919
+ }
3920
+ function defaultFormat(entry, colors) {
3921
+ const dur = entry.duration < 1 ? "<1ms" : entry.duration < 1e3 ? `${Math.round(entry.duration)}ms` : `${(entry.duration / 1e3).toFixed(2)}s`;
3922
+ const dim = colors ? COLORS.dim : "";
3923
+ const reset = colors ? COLORS.reset : "";
3924
+ return ` ${methodColor(entry.method, colors)} ${entry.path} ${dim}${dur}${reset}`;
3925
+ }
3926
+ /**
3927
+ * Request logging middleware.
3928
+ *
3929
+ * Logs incoming requests with method, path, and duration.
3930
+ * Runs in middleware phase — logs timing from middleware start to
3931
+ * microtask completion (approximate request duration).
3932
+ *
3933
+ * @example
3934
+ * ```ts
3935
+ * // Basic usage
3936
+ * loggerMiddleware()
3937
+ *
3938
+ * // Custom format
3939
+ * loggerMiddleware({
3940
+ * format: (e) => `${e.method} ${e.path} (${e.duration}ms)`,
3941
+ * })
3942
+ * ```
3943
+ */
3944
+ function loggerMiddleware(config) {
3945
+ if ((config?.level ?? "all") === "none") return () => {};
3946
+ const skip = config?.skip ?? [
3947
+ "/__",
3948
+ "/@",
3949
+ "/node_modules"
3950
+ ];
3951
+ const isDev = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
3952
+ const colors = config?.colors ?? isDev;
3953
+ return (ctx) => {
3954
+ if (skip.some((p) => ctx.path.startsWith(p))) return;
3955
+ const start = performance.now();
3956
+ const entry = {
3957
+ method: ctx.req.method ?? "GET",
3958
+ path: ctx.path,
3959
+ duration: 0,
3960
+ timestamp: /* @__PURE__ */ new Date(),
3961
+ userAgent: ctx.req.headers.get("user-agent") ?? void 0
3962
+ };
3963
+ queueMicrotask(() => {
3964
+ entry.duration = performance.now() - start;
3965
+ if (config?.format) {
3966
+ const line = config.format(entry);
3967
+ if (line) console.log(line);
3968
+ } else console.log(defaultFormat(entry, colors));
3969
+ });
3970
+ };
3971
+ }
3972
+
3973
+ //#endregion
3974
+ //#region src/env.ts
3975
+ /**
3976
+ * String validator — accepts any non-empty string.
3977
+ */
3978
+ function str(options) {
3979
+ return {
3980
+ __type: "env-validator",
3981
+ required: options?.default === void 0 && options?.required !== false,
3982
+ defaultValue: options?.default,
3983
+ parse(raw, key) {
3984
+ if (raw === void 0 || raw === "") {
3985
+ if (options?.default !== void 0) return options.default;
3986
+ throw new EnvError(key, "is required but not set", options?.description);
3987
+ }
3988
+ return raw;
3989
+ }
3990
+ };
3991
+ }
3992
+ /**
3993
+ * Number validator — parses to a number, rejects NaN.
3994
+ */
3995
+ function num(options) {
3996
+ return {
3997
+ __type: "env-validator",
3998
+ required: options?.default === void 0 && options?.required !== false,
3999
+ defaultValue: options?.default,
4000
+ parse(raw, key) {
4001
+ if (raw === void 0 || raw === "") {
4002
+ if (options?.default !== void 0) return options.default;
4003
+ throw new EnvError(key, "is required but not set", options?.description);
4004
+ }
4005
+ const n = Number(raw);
4006
+ if (Number.isNaN(n)) throw new EnvError(key, `must be a number, got "${raw}"`, options?.description);
4007
+ return n;
4008
+ }
4009
+ };
4010
+ }
4011
+ /**
4012
+ * Boolean validator — accepts "true"/"1" as true, "false"/"0" as false.
4013
+ */
4014
+ function bool(options) {
4015
+ return {
4016
+ __type: "env-validator",
4017
+ required: options?.default === void 0 && options?.required !== false,
4018
+ defaultValue: options?.default,
4019
+ parse(raw, key) {
4020
+ if (raw === void 0 || raw === "") {
4021
+ if (options?.default !== void 0) return options.default;
4022
+ throw new EnvError(key, "is required but not set", options?.description);
4023
+ }
4024
+ const lower = raw.toLowerCase();
4025
+ if (lower === "true" || lower === "1") return true;
4026
+ if (lower === "false" || lower === "0") return false;
4027
+ throw new EnvError(key, `must be "true" or "false", got "${raw}"`, options?.description);
4028
+ }
4029
+ };
4030
+ }
4031
+ /**
4032
+ * URL validator — validates that the value is a valid URL.
4033
+ */
4034
+ function url(options) {
4035
+ return {
4036
+ __type: "env-validator",
4037
+ required: options?.default === void 0 && options?.required !== false,
4038
+ defaultValue: options?.default,
4039
+ parse(raw, key) {
4040
+ if (raw === void 0 || raw === "") {
4041
+ if (options?.default !== void 0) return options.default;
4042
+ throw new EnvError(key, "is required but not set", options?.description);
4043
+ }
4044
+ try {
4045
+ new URL(raw);
4046
+ return raw;
4047
+ } catch {
4048
+ throw new EnvError(key, `must be a valid URL, got "${raw}"`, options?.description);
4049
+ }
4050
+ }
4051
+ };
4052
+ }
4053
+ /**
4054
+ * Enum validator — value must be one of the allowed values.
4055
+ */
4056
+ function oneOf(values, options) {
4057
+ return {
4058
+ __type: "env-validator",
4059
+ required: options?.default === void 0 && options?.required !== false,
4060
+ defaultValue: options?.default,
4061
+ parse(raw, key) {
4062
+ if (raw === void 0 || raw === "") {
4063
+ if (options?.default !== void 0) return options.default;
4064
+ throw new EnvError(key, "is required but not set", options?.description);
4065
+ }
4066
+ if (!values.includes(raw)) throw new EnvError(key, `must be one of [${values.join(", ")}], got "${raw}"`, options?.description);
4067
+ return raw;
4068
+ }
4069
+ };
4070
+ }
4071
+ var EnvError = class extends Error {
4072
+ constructor(key, message, description) {
4073
+ const desc = description ? ` (${description})` : "";
4074
+ super(`[zero:env] ${key}${desc}: ${message}`);
4075
+ this.name = "EnvError";
4076
+ }
4077
+ };
4078
+ function isEnvValidator(v) {
4079
+ return typeof v === "object" && v !== null && v.__type === "env-validator";
4080
+ }
4081
+ /**
4082
+ * Convert a plain schema value to an EnvValidator.
4083
+ *
4084
+ * - `3000` → num({ default: 3000 })
4085
+ * - `false` → bool({ default: false })
4086
+ * - `"localhost"` → str({ default: "localhost" })
4087
+ * - `String` → str() (required)
4088
+ * - `Number` → num() (required)
4089
+ * - `Boolean` → bool() (required)
4090
+ * - EnvValidator → pass through
4091
+ */
4092
+ function toValidator(value) {
4093
+ if (isEnvValidator(value)) return value;
4094
+ if (value === String) return str();
4095
+ if (value === Number) return num();
4096
+ if (value === Boolean) return bool();
4097
+ if (typeof value === "number") return num({ default: value });
4098
+ if (typeof value === "boolean") return bool({ default: value });
4099
+ if (typeof value === "string") return str({ default: value });
4100
+ throw new Error(`[zero:env] Invalid schema value: ${String(value)}. Use a default value, String/Number/Boolean, or a validator like url().`);
4101
+ }
4102
+ /**
4103
+ * Validate environment variables.
4104
+ *
4105
+ * Schema values can be:
4106
+ * - **Default values**: `3000`, `false`, `"localhost"` → type inferred, used as default
4107
+ * - **Constructors**: `String`, `Number`, `Boolean` → required, no default
4108
+ * - **Validators**: `url()`, `oneOf([...])`, `str()`, `num()`, `bool()` → explicit validation
4109
+ * - **Custom**: `schema(raw => z.coerce.number().parse(raw))` — bridge to any schema library
4110
+ *
4111
+ * @example
4112
+ * ```ts
4113
+ * import { validateEnv, url, oneOf } from "@pyreon/zero/env"
4114
+ *
4115
+ * const env = validateEnv({
4116
+ * PORT: 3000, // optional, default 3000
4117
+ * DATABASE_URL: url(), // required, validated URL
4118
+ * NODE_ENV: oneOf(["dev", "prod", "test"]), // required, must be one of
4119
+ * API_KEY: String, // required string
4120
+ * DEBUG: false, // optional, default false
4121
+ * })
4122
+ * ```
4123
+ */
4124
+ function validateEnv(schema, source) {
4125
+ const env = source ?? (typeof process !== "undefined" ? process.env : {});
4126
+ const result = {};
4127
+ const errors = [];
4128
+ for (const [key, entry] of Object.entries(schema)) {
4129
+ const validator = toValidator(entry);
4130
+ try {
4131
+ result[key] = validator.parse(env[key], key);
4132
+ } catch (e) {
4133
+ errors.push(e.message);
4134
+ }
4135
+ }
4136
+ if (errors.length > 0) {
4137
+ const header = `\n[zero:env] Environment validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n`;
4138
+ const body = errors.map((e) => ` ✗ ${e.replace("[zero:env] ", "")}`).join("\n");
4139
+ throw new Error(header + body + "\n");
4140
+ }
4141
+ return result;
4142
+ }
4143
+ function publicEnv(schema) {
4144
+ const prefix = "ZERO_PUBLIC_";
4145
+ const env = typeof process !== "undefined" ? process.env : {};
4146
+ if (!schema) {
4147
+ const result = {};
4148
+ for (const [key, value] of Object.entries(env)) if (key.startsWith(prefix) && value !== void 0) result[key.slice(12)] = value;
4149
+ return result;
4150
+ }
4151
+ const prefixedSource = {};
4152
+ for (const key of Object.keys(schema)) prefixedSource[key] = env[`${prefix}${key}`];
4153
+ return validateEnv(schema, prefixedSource);
4154
+ }
4155
+ /**
4156
+ * Create an env validator from a custom parse function.
4157
+ * Use this to integrate any schema library (Zod, Valibot, ArkType, etc.).
4158
+ *
4159
+ * @example
4160
+ * ```ts
4161
+ * import { z } from "zod"
4162
+ * import { validateEnv, schema } from "@pyreon/zero/env"
4163
+ *
4164
+ * const env = validateEnv({
4165
+ * PORT: schema(raw => z.coerce.number().parse(raw)),
4166
+ * DATABASE_URL: schema(raw => z.string().url().parse(raw)),
4167
+ * HOST: "localhost", // plain defaults still work alongside
4168
+ * })
4169
+ * ```
4170
+ */
4171
+ function schema(parse) {
4172
+ return {
4173
+ __type: "env-validator",
4174
+ required: true,
4175
+ defaultValue: void 0,
4176
+ parse(raw, key) {
4177
+ if (raw === void 0 || raw === "") throw new Error(`[zero:env] ${key}: is required but not set`);
4178
+ try {
4179
+ return parse(raw);
4180
+ } catch (e) {
4181
+ const msg = e instanceof Error ? e.message : String(e);
4182
+ throw new Error(`[zero:env] ${key}: ${msg}`);
4183
+ }
4184
+ }
4185
+ };
4186
+ }
4187
+
4188
+ //#endregion
4189
+ //#region src/ai.ts
4190
+ /**
4191
+ * Generate llms.txt content from route files and config.
4192
+ *
4193
+ * Format follows the llms.txt proposal:
4194
+ * ```
4195
+ * # {name}
4196
+ * > {description}
4197
+ *
4198
+ * ## Pages
4199
+ * - [/about](/about): About page
4200
+ *
4201
+ * ## API
4202
+ * - GET /api/posts: List posts
4203
+ * ```
4204
+ *
4205
+ * @internal Exported for testing.
4206
+ */
4207
+ function generateLlmsTxt(routeFiles, apiFiles, config) {
4208
+ const lines = [];
4209
+ lines.push(`# ${config.name}`);
4210
+ lines.push(`> ${config.description}`);
4211
+ lines.push("");
4212
+ const routes = parseFileRoutes(routeFiles);
4213
+ const pages = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && !r.isCatchAll && !r.urlPath.includes(":"));
4214
+ if (pages.length > 0) {
4215
+ lines.push("## Pages");
4216
+ lines.push("");
4217
+ for (const page of pages) {
4218
+ const desc = config.pageDescriptions?.[page.urlPath];
4219
+ const url = `${config.origin}${page.urlPath === "/" ? "" : page.urlPath}`;
4220
+ if (desc) lines.push(`- [${page.urlPath}](${url}): ${desc}`);
4221
+ else lines.push(`- [${page.urlPath}](${url})`);
4222
+ }
4223
+ lines.push("");
4224
+ }
4225
+ const dynamicRoutes = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && (r.urlPath.includes(":") || r.isCatchAll));
4226
+ if (dynamicRoutes.length > 0) {
4227
+ lines.push("## Dynamic Pages");
4228
+ lines.push("");
4229
+ for (const route of dynamicRoutes) {
4230
+ const desc = config.pageDescriptions?.[route.urlPath];
4231
+ if (desc) lines.push(`- ${route.urlPath}: ${desc}`);
4232
+ else lines.push(`- ${route.urlPath}`);
4233
+ }
4234
+ lines.push("");
4235
+ }
4236
+ const apiPatterns = parseApiFiles(apiFiles);
4237
+ if (apiPatterns.length > 0 || config.apiDescriptions) {
4238
+ lines.push("## API Endpoints");
4239
+ lines.push("");
4240
+ if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) lines.push(`- ${endpoint}: ${desc}`);
4241
+ const describedPatterns = new Set(Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/, "")));
4242
+ for (const pattern of apiPatterns) if (!describedPatterns.has(pattern)) lines.push(`- ${pattern}`);
4243
+ lines.push("");
4244
+ }
4245
+ if (config.llmsExtra) {
4246
+ lines.push(config.llmsExtra);
4247
+ lines.push("");
4248
+ }
4249
+ return lines.join("\n");
4250
+ }
4251
+ /**
4252
+ * Generate llms-full.txt — expanded version with more detail.
4253
+ * Includes all route metadata and API descriptions.
4254
+ *
4255
+ * @internal Exported for testing.
4256
+ */
4257
+ function generateLlmsFullTxt(routeFiles, apiFiles, config) {
4258
+ const lines = [];
4259
+ lines.push(`# ${config.name} — Full Reference`);
4260
+ lines.push(`> ${config.description}`);
4261
+ lines.push("");
4262
+ lines.push(`Base URL: ${config.origin}`);
4263
+ lines.push("");
4264
+ const pages = parseFileRoutes(routeFiles).filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound);
4265
+ if (pages.length > 0) {
4266
+ lines.push("## All Routes");
4267
+ lines.push("");
4268
+ for (const page of pages) {
4269
+ const desc = config.pageDescriptions?.[page.urlPath] ?? "";
4270
+ const dynamic = page.urlPath.includes(":") ? " (dynamic)" : "";
4271
+ const catchAll = page.isCatchAll ? " (catch-all)" : "";
4272
+ lines.push(`### ${page.urlPath}${dynamic}${catchAll}`);
4273
+ if (desc) lines.push(desc);
4274
+ lines.push(`- File: ${page.filePath}`);
4275
+ lines.push(`- Render mode: ${page.renderMode}`);
4276
+ lines.push("");
4277
+ }
4278
+ }
4279
+ if (config.apiDescriptions) {
4280
+ lines.push("## API Reference");
4281
+ lines.push("");
4282
+ for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
4283
+ lines.push(`### ${endpoint}`);
4284
+ lines.push(desc);
4285
+ lines.push("");
4286
+ }
4287
+ }
4288
+ if (config.llmsExtra) {
4289
+ lines.push("## Additional Information");
4290
+ lines.push("");
4291
+ lines.push(config.llmsExtra);
4292
+ lines.push("");
4293
+ }
4294
+ return lines.join("\n");
4295
+ }
4296
+ /**
4297
+ * Auto-infer JSON-LD structured data from page metadata.
4298
+ *
4299
+ * Returns an array of JSON-LD objects (multiple schemas can apply to one page).
4300
+ * For example, an article page gets both `Article` and `BreadcrumbList`.
4301
+ *
4302
+ * @example
4303
+ * ```tsx
4304
+ * const schemas = inferJsonLd({
4305
+ * url: "https://example.com/blog/my-post",
4306
+ * title: "My Post",
4307
+ * description: "A great article",
4308
+ * type: "article",
4309
+ * author: "Vit Bokisch",
4310
+ * publishedTime: "2026-03-31",
4311
+ * })
4312
+ * // → [Article schema, BreadcrumbList schema]
4313
+ * ```
4314
+ */
4315
+ function inferJsonLd(options) {
4316
+ const schemas = [];
4317
+ if (options.type === "article") {
4318
+ const article = {
4319
+ "@context": "https://schema.org",
4320
+ "@type": "Article",
4321
+ headline: options.title,
4322
+ url: options.url
4323
+ };
4324
+ if (options.description) article.description = options.description;
4325
+ if (options.image) article.image = options.image;
4326
+ if (options.publishedTime) article.datePublished = options.publishedTime;
4327
+ if (options.author) article.author = {
4328
+ "@type": "Person",
4329
+ name: options.author
4330
+ };
4331
+ if (options.tags && options.tags.length > 0) article.keywords = options.tags.join(", ");
4332
+ if (options.siteName) article.publisher = {
4333
+ "@type": "Organization",
4334
+ name: options.siteName
4335
+ };
4336
+ schemas.push(article);
4337
+ } else if (options.type === "product") {
4338
+ const product = {
4339
+ "@context": "https://schema.org",
4340
+ "@type": "Product",
4341
+ name: options.title,
4342
+ url: options.url
4343
+ };
4344
+ if (options.description) product.description = options.description;
4345
+ if (options.image) product.image = options.image;
4346
+ schemas.push(product);
4347
+ } else {
4348
+ const webpage = {
4349
+ "@context": "https://schema.org",
4350
+ "@type": "WebPage",
4351
+ name: options.title,
4352
+ url: options.url
4353
+ };
4354
+ if (options.description) webpage.description = options.description;
4355
+ if (options.image) webpage.thumbnailUrl = options.image;
4356
+ schemas.push(webpage);
4357
+ }
4358
+ if (options.breadcrumbs && options.breadcrumbs.length > 0) schemas.push({
4359
+ "@context": "https://schema.org",
4360
+ "@type": "BreadcrumbList",
4361
+ itemListElement: options.breadcrumbs.map((bc, i) => ({
4362
+ "@type": "ListItem",
4363
+ position: i + 1,
4364
+ name: bc.name,
4365
+ item: bc.url
4366
+ }))
4367
+ });
4368
+ else {
4369
+ const urlObj = safeParseUrl(options.url);
4370
+ if (urlObj) {
4371
+ const segments = urlObj.pathname.split("/").filter(Boolean);
4372
+ if (segments.length > 0) {
4373
+ const items = [{
4374
+ "@type": "ListItem",
4375
+ position: 1,
4376
+ name: "Home",
4377
+ item: urlObj.origin
4378
+ }];
4379
+ let path = "";
4380
+ for (let i = 0; i < segments.length; i++) {
4381
+ path += `/${segments[i]}`;
4382
+ items.push({
4383
+ "@type": "ListItem",
4384
+ position: i + 2,
4385
+ name: capitalize(segments[i].replace(/-/g, " ")),
4386
+ item: `${urlObj.origin}${path}`
4387
+ });
4388
+ }
4389
+ schemas.push({
4390
+ "@context": "https://schema.org",
4391
+ "@type": "BreadcrumbList",
4392
+ itemListElement: items
4393
+ });
4394
+ }
4395
+ }
4396
+ }
4397
+ return schemas;
4398
+ }
4399
+ /**
4400
+ * Generate an OpenAI-compatible AI plugin manifest.
4401
+ *
4402
+ * Follows the /.well-known/ai-plugin.json spec.
4403
+ *
4404
+ * @internal Exported for testing.
4405
+ */
4406
+ function generateAiPluginManifest(config) {
4407
+ return {
4408
+ schema_version: "v1",
4409
+ name_for_human: config.name,
4410
+ name_for_model: config.name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, ""),
4411
+ description_for_human: config.description,
4412
+ description_for_model: config.description,
4413
+ auth: { type: "none" },
4414
+ api: {
4415
+ type: "openapi",
4416
+ url: `${config.origin}/.well-known/openapi.yaml`
4417
+ },
4418
+ logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,
4419
+ contact_email: config.contactEmail ?? "",
4420
+ legal_info_url: config.legalUrl ?? `${config.origin}/legal`
4421
+ };
4422
+ }
4423
+ /**
4424
+ * Generate a minimal OpenAPI 3.0 spec from API route descriptions.
4425
+ *
4426
+ * @internal Exported for testing.
4427
+ */
4428
+ function generateOpenApiSpec(apiFiles, config) {
4429
+ const paths = {};
4430
+ if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
4431
+ const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/);
4432
+ if (match) {
4433
+ const method = match[1].toLowerCase();
4434
+ const openApiPath = match[2].replace(/:(\w+)/g, "{$1}");
4435
+ if (!paths[openApiPath]) paths[openApiPath] = {};
4436
+ paths[openApiPath][method] = {
4437
+ summary: desc,
4438
+ responses: { "200": { description: "Success" } }
4439
+ };
4440
+ }
4441
+ }
4442
+ for (const pattern of parseApiFiles(apiFiles)) {
4443
+ const openApiPath = pattern.replace(/:(\w+)/g, "{$1}");
4444
+ if (!paths[openApiPath]) paths[openApiPath] = { get: {
4445
+ summary: `${openApiPath} endpoint`,
4446
+ responses: { "200": { description: "Success" } }
4447
+ } };
4448
+ }
4449
+ return {
4450
+ openapi: "3.0.0",
4451
+ info: {
4452
+ title: config.name,
4453
+ description: config.description,
4454
+ version: "1.0.0"
4455
+ },
4456
+ servers: [{ url: config.origin }],
4457
+ paths
4458
+ };
4459
+ }
4460
+ /**
4461
+ * AI integration Vite plugin.
4462
+ *
4463
+ * Generates at build time:
4464
+ * - `/llms.txt` — concise site summary for AI agents
4465
+ * - `/llms-full.txt` — detailed reference for AI agents
4466
+ * - `/.well-known/ai-plugin.json` — OpenAI plugin manifest
4467
+ * - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes
4468
+ *
4469
+ * In dev, serves these files via middleware.
4470
+ *
4471
+ * @example
4472
+ * ```ts
4473
+ * import { aiPlugin } from "@pyreon/zero/ai"
4474
+ *
4475
+ * export default {
4476
+ * plugins: [
4477
+ * aiPlugin({
4478
+ * name: "My App",
4479
+ * origin: "https://example.com",
4480
+ * description: "A modern web application",
4481
+ * apiDescriptions: {
4482
+ * "GET /api/posts": "List blog posts",
4483
+ * "GET /api/posts/:id": "Get post by ID",
4484
+ * },
4485
+ * }),
4486
+ * ],
4487
+ * }
4488
+ * ```
4489
+ */
4490
+ function aiPlugin(config) {
4491
+ let root = "";
4492
+ let isBuild = false;
4493
+ let routeFiles = [];
4494
+ let apiFiles = [];
4495
+ return {
4496
+ name: "pyreon-zero-ai",
4497
+ enforce: "post",
4498
+ configResolved(resolvedConfig) {
4499
+ root = resolvedConfig.root;
4500
+ isBuild = resolvedConfig.command === "build";
4501
+ },
4502
+ async buildStart() {
4503
+ try {
4504
+ const { join } = await import("node:path");
4505
+ const routesDir = join(root, config.routesDir ?? "src/routes");
4506
+ const apiDir = join(root, config.apiDir ?? "src/api");
4507
+ routeFiles = await scanDir(routesDir, routesDir);
4508
+ apiFiles = await scanDir(apiDir, apiDir);
4509
+ } catch {}
4510
+ },
4511
+ configureServer(server) {
4512
+ server.middlewares.use(async (req, res, next) => {
4513
+ const url = req.url ?? "";
4514
+ if (url === "/llms.txt") {
4515
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
4516
+ res.end(generateLlmsTxt(routeFiles, apiFiles, config));
4517
+ return;
4518
+ }
4519
+ if (url === "/llms-full.txt") {
4520
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
4521
+ res.end(generateLlmsFullTxt(routeFiles, apiFiles, config));
4522
+ return;
4523
+ }
4524
+ if (url === "/.well-known/ai-plugin.json") {
4525
+ res.setHeader("Content-Type", "application/json");
4526
+ res.end(JSON.stringify(generateAiPluginManifest(config), null, 2));
4527
+ return;
4528
+ }
4529
+ if (url === "/.well-known/openapi.yaml" || url === "/.well-known/openapi.json") {
4530
+ res.setHeader("Content-Type", "application/json");
4531
+ res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2));
4532
+ return;
4533
+ }
4534
+ next();
4535
+ });
4536
+ },
4537
+ async generateBundle() {
4538
+ if (!isBuild) return;
4539
+ this.emitFile({
4540
+ type: "asset",
4541
+ fileName: "llms.txt",
4542
+ source: generateLlmsTxt(routeFiles, apiFiles, config)
4543
+ });
4544
+ this.emitFile({
4545
+ type: "asset",
4546
+ fileName: "llms-full.txt",
4547
+ source: generateLlmsFullTxt(routeFiles, apiFiles, config)
4548
+ });
4549
+ this.emitFile({
4550
+ type: "asset",
4551
+ fileName: ".well-known/ai-plugin.json",
4552
+ source: JSON.stringify(generateAiPluginManifest(config), null, 2)
4553
+ });
4554
+ this.emitFile({
4555
+ type: "asset",
4556
+ fileName: ".well-known/openapi.json",
4557
+ source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2)
4558
+ });
4559
+ }
4560
+ };
4561
+ }
4562
+ function parseApiFiles(files) {
4563
+ return files.filter((f) => f.endsWith(".ts") || f.endsWith(".js")).map((f) => {
4564
+ let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "");
4565
+ if (!path.startsWith("/")) path = `/${path}`;
4566
+ path = path.replace(/\[\.\.\.(\w+)\]/g, ":$1*").replace(/\[(\w+)\]/g, ":$1");
4567
+ return `/api${path === "/" ? "" : path}`;
4568
+ });
4569
+ }
4570
+ async function scanDir(dir, base) {
4571
+ const { readdir, stat } = await import("node:fs/promises");
4572
+ const { join, relative } = await import("node:path");
4573
+ try {
4574
+ const entries = await readdir(dir);
4575
+ const files = [];
4576
+ for (const entry of entries) {
4577
+ const full = join(dir, entry);
4578
+ if ((await stat(full)).isDirectory()) files.push(...await scanDir(full, base));
4579
+ else files.push(relative(base, full));
4580
+ }
4581
+ return files;
4582
+ } catch {
4583
+ return [];
4584
+ }
4585
+ }
4586
+ function safeParseUrl(url) {
4587
+ try {
4588
+ return new URL(url);
4589
+ } catch {
4590
+ return null;
4591
+ }
4592
+ }
4593
+ function capitalize(s) {
4594
+ return s.charAt(0).toUpperCase() + s.slice(1);
4595
+ }
4596
+
4597
+ //#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 };
3205
4599
  //# sourceMappingURL=index.js.map