@pyreon/zero 0.12.1 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/lib/actions.js +97 -0
  2. package/lib/actions.js.map +1 -0
  3. package/lib/ai.js +503 -0
  4. package/lib/ai.js.map +1 -0
  5. package/lib/api-routes.js +137 -0
  6. package/lib/api-routes.js.map +1 -0
  7. package/lib/compression.js +80 -0
  8. package/lib/compression.js.map +1 -0
  9. package/lib/cors.js +57 -0
  10. package/lib/cors.js.map +1 -0
  11. package/lib/csp.js +119 -0
  12. package/lib/csp.js.map +1 -0
  13. package/lib/env.js +217 -0
  14. package/lib/env.js.map +1 -0
  15. package/lib/favicon.js +424 -0
  16. package/lib/favicon.js.map +1 -0
  17. package/lib/i18n-routing.js +167 -0
  18. package/lib/i18n-routing.js.map +1 -0
  19. package/lib/index.js +1631 -179
  20. package/lib/index.js.map +1 -1
  21. package/lib/link.js +5 -0
  22. package/lib/link.js.map +1 -1
  23. package/lib/logger.js +78 -0
  24. package/lib/logger.js.map +1 -0
  25. package/lib/meta.js +336 -0
  26. package/lib/meta.js.map +1 -0
  27. package/lib/middleware.js +53 -0
  28. package/lib/middleware.js.map +1 -0
  29. package/lib/og-image.js +233 -0
  30. package/lib/og-image.js.map +1 -0
  31. package/lib/rate-limit.js +76 -0
  32. package/lib/rate-limit.js.map +1 -0
  33. package/lib/testing.js +179 -0
  34. package/lib/testing.js.map +1 -0
  35. package/lib/theme.js +11 -2
  36. package/lib/theme.js.map +1 -1
  37. package/lib/types/actions.d.ts +27 -24
  38. package/lib/types/actions.d.ts.map +1 -1
  39. package/lib/types/ai.d.ts +163 -0
  40. package/lib/types/ai.d.ts.map +1 -0
  41. package/lib/types/api-routes.d.ts +37 -33
  42. package/lib/types/api-routes.d.ts.map +1 -1
  43. package/lib/types/cache.d.ts +26 -22
  44. package/lib/types/cache.d.ts.map +1 -1
  45. package/lib/types/client.d.ts +13 -9
  46. package/lib/types/client.d.ts.map +1 -1
  47. package/lib/types/compression.d.ts +14 -10
  48. package/lib/types/compression.d.ts.map +1 -1
  49. package/lib/types/config.d.ts +39 -4
  50. package/lib/types/config.d.ts.map +1 -1
  51. package/lib/types/cors.d.ts +20 -16
  52. package/lib/types/cors.d.ts.map +1 -1
  53. package/lib/types/csp.d.ts +88 -0
  54. package/lib/types/csp.d.ts.map +1 -0
  55. package/lib/types/env.d.ts +118 -0
  56. package/lib/types/env.d.ts.map +1 -0
  57. package/lib/types/favicon.d.ts +70 -24
  58. package/lib/types/favicon.d.ts.map +1 -1
  59. package/lib/types/font.d.ts +68 -65
  60. package/lib/types/font.d.ts.map +1 -1
  61. package/lib/types/i18n-routing.d.ts +43 -37
  62. package/lib/types/i18n-routing.d.ts.map +1 -1
  63. package/lib/types/image-plugin.d.ts +49 -45
  64. package/lib/types/image-plugin.d.ts.map +1 -1
  65. package/lib/types/image.d.ts +47 -36
  66. package/lib/types/image.d.ts.map +1 -1
  67. package/lib/types/index.d.ts +1961 -46
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/link.d.ts +61 -56
  70. package/lib/types/link.d.ts.map +1 -1
  71. package/lib/types/logger.d.ts +57 -0
  72. package/lib/types/logger.d.ts.map +1 -0
  73. package/lib/types/meta.d.ts +180 -69
  74. package/lib/types/meta.d.ts.map +1 -1
  75. package/lib/types/middleware.d.ts +8 -4
  76. package/lib/types/middleware.d.ts.map +1 -1
  77. package/lib/types/og-image.d.ts +111 -0
  78. package/lib/types/og-image.d.ts.map +1 -0
  79. package/lib/types/rate-limit.d.ts +20 -16
  80. package/lib/types/rate-limit.d.ts.map +1 -1
  81. package/lib/types/script.d.ts +23 -19
  82. package/lib/types/script.d.ts.map +1 -1
  83. package/lib/types/seo.d.ts +47 -43
  84. package/lib/types/seo.d.ts.map +1 -1
  85. package/lib/types/testing.d.ts +64 -27
  86. package/lib/types/testing.d.ts.map +1 -1
  87. package/lib/types/theme.d.ts +22 -12
  88. package/lib/types/theme.d.ts.map +1 -1
  89. package/package.json +37 -12
  90. package/src/actions.ts +1 -3
  91. package/src/adapters/bun.ts +2 -0
  92. package/src/adapters/cloudflare.ts +84 -0
  93. package/src/adapters/index.ts +13 -1
  94. package/src/adapters/netlify.ts +86 -0
  95. package/src/adapters/node.ts +2 -0
  96. package/src/adapters/validate.ts +16 -0
  97. package/src/adapters/vercel.ts +86 -0
  98. package/src/ai.ts +623 -0
  99. package/src/compression.ts +19 -3
  100. package/src/csp.ts +207 -0
  101. package/src/entry-server.ts +28 -5
  102. package/src/env.ts +344 -0
  103. package/src/favicon.ts +221 -80
  104. package/src/index.ts +42 -2
  105. package/src/link.tsx +6 -0
  106. package/src/logger.ts +144 -0
  107. package/src/meta.tsx +124 -14
  108. package/src/og-image.ts +378 -0
  109. package/src/rate-limit.ts +11 -9
  110. package/src/theme.tsx +12 -1
  111. package/src/types.ts +1 -1
  112. package/src/vite-plugin.ts +5 -1
  113. package/lib/types/adapters/bun.d.ts +0 -6
  114. package/lib/types/adapters/bun.d.ts.map +0 -1
  115. package/lib/types/adapters/index.d.ts +0 -10
  116. package/lib/types/adapters/index.d.ts.map +0 -1
  117. package/lib/types/adapters/node.d.ts +0 -6
  118. package/lib/types/adapters/node.d.ts.map +0 -1
  119. package/lib/types/adapters/static.d.ts +0 -7
  120. package/lib/types/adapters/static.d.ts.map +0 -1
  121. package/lib/types/app.d.ts +0 -24
  122. package/lib/types/app.d.ts.map +0 -1
  123. package/lib/types/entry-server.d.ts +0 -37
  124. package/lib/types/entry-server.d.ts.map +0 -1
  125. package/lib/types/error-overlay.d.ts +0 -6
  126. package/lib/types/error-overlay.d.ts.map +0 -1
  127. package/lib/types/fs-router.d.ts +0 -47
  128. package/lib/types/fs-router.d.ts.map +0 -1
  129. package/lib/types/isr.d.ts +0 -9
  130. package/lib/types/isr.d.ts.map +0 -1
  131. package/lib/types/not-found.d.ts +0 -7
  132. package/lib/types/not-found.d.ts.map +0 -1
  133. package/lib/types/types.d.ts +0 -111
  134. package/lib/types/types.d.ts.map +0 -1
  135. package/lib/types/utils/use-intersection-observer.d.ts +0 -10
  136. package/lib/types/utils/use-intersection-observer.d.ts.map +0 -1
  137. package/lib/types/utils/with-headers.d.ts +0 -6
  138. package/lib/types/utils/with-headers.d.ts.map +0 -1
  139. package/lib/types/vite-plugin.d.ts +0 -17
  140. package/lib/types/vite-plugin.d.ts.map +0 -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";
@@ -211,14 +211,23 @@ function createRouteMiddlewareDispatcher(entries) {
211
211
  }
212
212
  };
213
213
  }
214
- /** Simple URL pattern matcher supporting :param and :param* segments. */
214
+ /**
215
+ * URL pattern matcher supporting :param and :param* segments.
216
+ *
217
+ * Rules:
218
+ * - Static segments must match exactly
219
+ * - `:param` matches a single path segment
220
+ * - `:param*` matches all remaining segments (must be last, and path must
221
+ * have matched all preceding segments)
222
+ * - Path length must match pattern length (unless catch-all)
223
+ */
215
224
  function matchPattern(pattern, path) {
216
225
  const patternParts = pattern.split("/").filter(Boolean);
217
226
  const pathParts = path.split("/").filter(Boolean);
218
227
  for (let i = 0; i < patternParts.length; i++) {
219
228
  const pp = patternParts[i];
220
- if (!pp) continue;
221
- if (pp.endsWith("*")) return true;
229
+ if (pp.endsWith("*")) return i <= pathParts.length;
230
+ if (i >= pathParts.length) return false;
222
231
  if (pp.startsWith(":")) continue;
223
232
  if (pp !== pathParts[i]) return false;
224
233
  }
@@ -496,7 +505,10 @@ function zeroPlugin(userConfig = {}) {
496
505
  if (/\.\w+$/.test(pathname)) return next();
497
506
  handle404(server, routesDir, pathname, res).then((handled) => {
498
507
  if (!handled) next();
499
- }, () => next());
508
+ }, (err) => {
509
+ console.error("[zero] Error in 404 handler:", err);
510
+ next();
511
+ });
500
512
  });
501
513
  server.middlewares.use((req, res, next) => {
502
514
  if (!(req.headers.accept ?? "").includes("text/html")) return next();
@@ -652,6 +664,19 @@ function createISRHandler(handler, config) {
652
664
  };
653
665
  }
654
666
 
667
+ //#endregion
668
+ //#region src/adapters/validate.ts
669
+ /**
670
+ * Validate that adapter build inputs exist before copying.
671
+ * Throws with a clear error message if directories are missing.
672
+ * @internal
673
+ */
674
+ async function validateBuildInputs(options) {
675
+ const { existsSync } = await import("node:fs");
676
+ if (!existsSync(options.clientOutDir)) throw new Error(`[zero:adapter] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`);
677
+ if (!existsSync(options.serverEntry)) throw new Error(`[zero:adapter] Server entry not found: ${options.serverEntry}. Run "vite build --ssr" first.`);
678
+ }
679
+
655
680
  //#endregion
656
681
  //#region src/adapters/bun.ts
657
682
  /**
@@ -661,6 +686,7 @@ function bunAdapter() {
661
686
  return {
662
687
  name: "bun",
663
688
  async build(options) {
689
+ await validateBuildInputs(options);
664
690
  const { writeFile, cp, mkdir } = await import("node:fs/promises");
665
691
  const { join } = await import("node:path");
666
692
  const outDir = options.outDir;
@@ -709,6 +735,154 @@ console.log("\\n ⚡ Zero production server running on http://localhost:${port}
709
735
  };
710
736
  }
711
737
 
738
+ //#endregion
739
+ //#region src/adapters/cloudflare.ts
740
+ /**
741
+ * Cloudflare Pages adapter — generates output for Cloudflare Pages with Functions.
742
+ *
743
+ * Produces:
744
+ * - Client assets in the output directory root (served as static)
745
+ * - `_worker.js` — Cloudflare Pages Function for SSR
746
+ *
747
+ * Note: Cloudflare Pages Functions have a ~1MB module size limit.
748
+ * For large apps, configure Vite's SSR build to bundle server code:
749
+ * `ssr: { noExternal: true }` in vite.config.ts.
750
+ *
751
+ * Deploy with: `npx wrangler pages deploy ./dist`
752
+ *
753
+ * @example
754
+ * ```ts
755
+ * // zero.config.ts
756
+ * import { defineConfig } from "@pyreon/zero"
757
+ *
758
+ * export default defineConfig({
759
+ * adapter: "cloudflare",
760
+ * })
761
+ * ```
762
+ */
763
+ function cloudflareAdapter() {
764
+ return {
765
+ name: "cloudflare",
766
+ async build(options) {
767
+ await validateBuildInputs(options);
768
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
769
+ const { join } = await import("node:path");
770
+ const outDir = options.outDir;
771
+ await mkdir(outDir, { recursive: true });
772
+ await cp(options.clientOutDir, outDir, { recursive: true });
773
+ await cp(join(options.serverEntry, ".."), join(outDir, "_server"), { recursive: true });
774
+ const workerEntry = `
775
+ import handler from "./_server/entry-server.js"
776
+
777
+ export default {
778
+ async fetch(request, env, ctx) {
779
+ const url = new URL(request.url)
780
+
781
+ // Let Cloudflare serve static assets (files with extensions)
782
+ // This check is a fallback — Pages routes static files automatically
783
+ const ext = url.pathname.split(".").pop()
784
+ if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
785
+ // Cloudflare Pages handles static assets automatically via its asset binding
786
+ // Only reach here if the file doesn't exist — fall through to SSR
787
+ }
788
+
789
+ // SSR handler
790
+ try {
791
+ return await handler(request)
792
+ } catch (err) {
793
+ return new Response("Internal Server Error", { status: 500 })
794
+ }
795
+ },
796
+ }
797
+ `.trimStart();
798
+ await writeFile(join(outDir, "_worker.js"), workerEntry);
799
+ await writeFile(join(outDir, "_routes.json"), JSON.stringify({
800
+ version: 1,
801
+ include: ["/*"],
802
+ exclude: [
803
+ "/assets/*",
804
+ "/favicon.*",
805
+ "/site.webmanifest",
806
+ "/robots.txt",
807
+ "/sitemap.xml"
808
+ ]
809
+ }, null, 2));
810
+ }
811
+ };
812
+ }
813
+
814
+ //#endregion
815
+ //#region src/adapters/netlify.ts
816
+ /**
817
+ * Netlify adapter — generates output for Netlify Functions (v2).
818
+ *
819
+ * Produces:
820
+ * - Client assets in `publish/` directory
821
+ * - `netlify/functions/ssr.mjs` — Netlify Function for SSR
822
+ * - `netlify.toml` — routing configuration
823
+ *
824
+ * @example
825
+ * ```ts
826
+ * // zero.config.ts
827
+ * import { defineConfig } from "@pyreon/zero"
828
+ *
829
+ * export default defineConfig({
830
+ * adapter: "netlify",
831
+ * })
832
+ * ```
833
+ */
834
+ function netlifyAdapter() {
835
+ return {
836
+ name: "netlify",
837
+ async build(options) {
838
+ await validateBuildInputs(options);
839
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
840
+ const { join } = await import("node:path");
841
+ const outDir = options.outDir;
842
+ const publishDir = join(outDir, "publish");
843
+ const functionsDir = join(outDir, "netlify", "functions");
844
+ await mkdir(publishDir, { recursive: true });
845
+ await mkdir(functionsDir, { recursive: true });
846
+ await cp(options.clientOutDir, publishDir, { recursive: true });
847
+ await cp(join(options.serverEntry, ".."), join(functionsDir, "_server"), { recursive: true });
848
+ const funcEntry = `
849
+ import handler from "./_server/entry-server.js"
850
+
851
+ export default async function(req, context) {
852
+ try {
853
+ return await handler(req)
854
+ } catch (err) {
855
+ return new Response("Internal Server Error", { status: 500 })
856
+ }
857
+ }
858
+
859
+ export const config = {
860
+ path: "/*",
861
+ preferStatic: true,
862
+ }
863
+ `.trimStart();
864
+ await writeFile(join(functionsDir, "ssr.mjs"), funcEntry);
865
+ const toml = `
866
+ [build]
867
+ publish = "publish"
868
+ functions = "netlify/functions"
869
+
870
+ [[headers]]
871
+ for = "/assets/*"
872
+ [headers.values]
873
+ Cache-Control = "public, max-age=31536000, immutable"
874
+
875
+ [[redirects]]
876
+ from = "/*"
877
+ to = "/.netlify/functions/ssr"
878
+ status = 200
879
+ conditions = {Role = ["admin", "user", ""]}
880
+ `.trimStart();
881
+ await writeFile(join(outDir, "netlify.toml"), toml);
882
+ }
883
+ };
884
+ }
885
+
712
886
  //#endregion
713
887
  //#region src/adapters/node.ts
714
888
  /**
@@ -718,6 +892,7 @@ function nodeAdapter() {
718
892
  return {
719
893
  name: "node",
720
894
  async build(options) {
895
+ await validateBuildInputs(options);
721
896
  const { writeFile, cp, mkdir } = await import("node:fs/promises");
722
897
  const { join } = await import("node:path");
723
898
  const outDir = options.outDir;
@@ -827,6 +1002,73 @@ function staticAdapter() {
827
1002
  };
828
1003
  }
829
1004
 
1005
+ //#endregion
1006
+ //#region src/adapters/vercel.ts
1007
+ /**
1008
+ * Vercel adapter — generates output for Vercel's Build Output API v3.
1009
+ *
1010
+ * Produces a `.vercel/output` directory with:
1011
+ * - `static/` — client-side assets (JS, CSS, images)
1012
+ * - `functions/ssr.func/` — serverless function for SSR
1013
+ * - `config.json` — routing configuration
1014
+ *
1015
+ * @example
1016
+ * ```ts
1017
+ * // zero.config.ts
1018
+ * import { defineConfig } from "@pyreon/zero"
1019
+ *
1020
+ * export default defineConfig({
1021
+ * adapter: "vercel",
1022
+ * })
1023
+ * ```
1024
+ */
1025
+ function vercelAdapter() {
1026
+ return {
1027
+ name: "vercel",
1028
+ async build(options) {
1029
+ await validateBuildInputs(options);
1030
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
1031
+ const { join } = await import("node:path");
1032
+ const vercelDir = join(options.outDir, ".vercel", "output");
1033
+ const staticDir = join(vercelDir, "static");
1034
+ const funcDir = join(vercelDir, "functions", "ssr.func");
1035
+ await mkdir(staticDir, { recursive: true });
1036
+ await mkdir(funcDir, { recursive: true });
1037
+ await cp(options.clientOutDir, staticDir, { recursive: true });
1038
+ await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
1039
+ const funcEntry = `
1040
+ export default async function handler(req) {
1041
+ const handler = (await import("./entry-server.js")).default
1042
+ return handler(req)
1043
+ }
1044
+ `.trimStart();
1045
+ await writeFile(join(funcDir, "index.js"), funcEntry);
1046
+ await writeFile(join(funcDir, ".vc-config.json"), JSON.stringify({
1047
+ runtime: "nodejs20.x",
1048
+ handler: "index.js",
1049
+ launcherType: "Nodejs"
1050
+ }, null, 2));
1051
+ await writeFile(join(vercelDir, "config.json"), JSON.stringify({
1052
+ version: 3,
1053
+ routes: [
1054
+ {
1055
+ src: "/assets/(.*)",
1056
+ headers: { "Cache-Control": "public, max-age=31536000, immutable" }
1057
+ },
1058
+ {
1059
+ src: "/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)",
1060
+ dest: "/$1"
1061
+ },
1062
+ {
1063
+ src: "/(.*)",
1064
+ dest: "/ssr"
1065
+ }
1066
+ ]
1067
+ }, null, 2));
1068
+ }
1069
+ };
1070
+ }
1071
+
830
1072
  //#endregion
831
1073
  //#region src/adapters/index.ts
832
1074
  /**
@@ -839,7 +1081,10 @@ function resolveAdapter(config) {
839
1081
  case "node": return nodeAdapter();
840
1082
  case "bun": return bunAdapter();
841
1083
  case "static": return staticAdapter();
842
- default: throw new Error(`[zero] Unknown adapter: "${name}". Use "node", "bun", or "static".`);
1084
+ case "vercel": return vercelAdapter();
1085
+ case "cloudflare": return cloudflareAdapter();
1086
+ case "netlify": return netlifyAdapter();
1087
+ default: throw new Error(`[zero] Unknown adapter: "${name}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`);
843
1088
  }
844
1089
  }
845
1090
 
@@ -1002,9 +1247,14 @@ function Image(props) {
1002
1247
 
1003
1248
  //#endregion
1004
1249
  //#region src/link.tsx
1250
+ const MAX_PREFETCH_CACHE = 200;
1005
1251
  const prefetched = /* @__PURE__ */ new Set();
1006
1252
  function doPrefetch(href) {
1007
1253
  if (prefetched.has(href)) return;
1254
+ if (prefetched.size >= MAX_PREFETCH_CACHE) {
1255
+ const first = prefetched.values().next().value;
1256
+ if (first) prefetched.delete(first);
1257
+ }
1008
1258
  prefetched.add(href);
1009
1259
  const docLink = document.createElement("link");
1010
1260
  docLink.rel = "prefetch";
@@ -1659,10 +1909,10 @@ function fontVariables(families) {
1659
1909
 
1660
1910
  //#endregion
1661
1911
  //#region src/image-plugin.ts
1662
- let sharpWarned$1 = false;
1663
- function warnSharpMissing$1() {
1664
- if (sharpWarned$1) return;
1665
- sharpWarned$1 = true;
1912
+ let sharpWarned$2 = false;
1913
+ function warnSharpMissing$2() {
1914
+ if (sharpWarned$2) return;
1915
+ sharpWarned$2 = true;
1666
1916
  console.warn("\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n");
1667
1917
  }
1668
1918
  const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i;
@@ -1925,7 +2175,7 @@ async function resizeImage(input, output, width, format, quality) {
1925
2175
  }
1926
2176
  await pipeline.toFile(output);
1927
2177
  } catch {
1928
- warnSharpMissing$1();
2178
+ warnSharpMissing$2();
1929
2179
  await writeFile(output, await readFile(input));
1930
2180
  }
1931
2181
  }
@@ -1945,11 +2195,20 @@ async function generateBlurPlaceholder(input, size) {
1945
2195
  const STORAGE_KEY = "zero-theme";
1946
2196
  /** Reactive theme signal. */
1947
2197
  const theme = signal("system");
2198
+ /** SSR fallback when system preference can't be detected. Default: 'light'. */
2199
+ let _ssrDefault = "light";
2200
+ /**
2201
+ * Set the default theme for SSR (when `matchMedia` is unavailable).
2202
+ * Call once at server startup before rendering.
2203
+ */
2204
+ function setSSRThemeDefault(value) {
2205
+ _ssrDefault = value;
2206
+ }
1948
2207
  /** Computed resolved theme (what's actually applied). */
1949
2208
  function resolvedTheme() {
1950
2209
  const t = theme();
1951
2210
  if (t === "system") {
1952
- if (typeof window === "undefined") return "dark";
2211
+ if (typeof window === "undefined") return _ssrDefault;
1953
2212
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
1954
2213
  }
1955
2214
  return t;
@@ -2123,14 +2382,14 @@ ${[...routeFiles.filter((f) => {
2123
2382
  priority
2124
2383
  })), ...config.additionalPaths ?? []].map((entry) => {
2125
2384
  return ` <url>
2126
- <loc>${escapeXml(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
2385
+ <loc>${escapeXml$1(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
2127
2386
  <changefreq>${entry.changefreq ?? changefreq}</changefreq>
2128
2387
  <priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
2129
2388
  </url>`;
2130
2389
  }).join("\n")}
2131
2390
  </urlset>`;
2132
2391
  }
2133
- function escapeXml(str) {
2392
+ function escapeXml$1(str) {
2134
2393
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2135
2394
  }
2136
2395
  /**
@@ -2311,16 +2570,19 @@ function rateLimitMiddleware(config = {}) {
2311
2570
  const { max = 100, window: windowSec = 60, keyFn = defaultKeyFn, onLimit, include, exclude } = config;
2312
2571
  const windowMs = windowSec * 1e3;
2313
2572
  const store = /* @__PURE__ */ new Map();
2314
- const cleanupInterval = setInterval(() => {
2315
- const now = Date.now();
2573
+ const MAX_STORE_SIZE = 1e4;
2574
+ let lastCleanup = Date.now();
2575
+ function cleanupIfNeeded(now) {
2576
+ if (store.size < MAX_STORE_SIZE / 2 && now - lastCleanup < windowMs) return;
2577
+ lastCleanup = now;
2316
2578
  for (const [key, entry] of store) if (entry.resetAt <= now) store.delete(key);
2317
- }, windowMs);
2318
- if (typeof cleanupInterval === "object" && "unref" in cleanupInterval) cleanupInterval.unref();
2579
+ }
2319
2580
  return (ctx) => {
2320
2581
  if (include && !include.some((p) => matchSimpleGlob(p, ctx.path))) return;
2321
2582
  if (exclude?.some((p) => matchSimpleGlob(p, ctx.path))) return;
2322
2583
  const key = keyFn(ctx);
2323
2584
  const now = Date.now();
2585
+ cleanupIfNeeded(now);
2324
2586
  let entry = store.get(key);
2325
2587
  if (!entry || entry.resetAt <= now) {
2326
2588
  entry = {
@@ -2422,15 +2684,24 @@ function isCompressible(contentType) {
2422
2684
  return COMPRESSIBLE_TYPES.some((t) => contentType.includes(t));
2423
2685
  }
2424
2686
  async function compress(data, encoding) {
2425
- const format = encoding === "gzip" ? "gzip" : "deflate";
2426
- const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(format));
2427
- return new Response(stream).arrayBuffer();
2687
+ if (typeof CompressionStream !== "undefined") {
2688
+ const format = encoding === "gzip" ? "gzip" : "deflate";
2689
+ const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(format));
2690
+ return new Response(stream).arrayBuffer();
2691
+ }
2692
+ try {
2693
+ const zlib = await import("node:zlib");
2694
+ const { promisify } = await import("node:util");
2695
+ const result = await (encoding === "gzip" ? promisify(zlib.gzip) : promisify(zlib.deflate))(Buffer.from(data));
2696
+ return result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength);
2697
+ } catch {
2698
+ return data;
2699
+ }
2428
2700
  }
2429
2701
 
2430
2702
  //#endregion
2431
2703
  //#region src/actions.ts
2432
2704
  const actionRegistry = /* @__PURE__ */ new Map();
2433
- let actionCounter = 0;
2434
2705
  /**
2435
2706
  * Define a server action. Returns a callable function that:
2436
2707
  * - On the **client**: sends a POST request to `/_zero/actions/<id>`
@@ -2448,7 +2719,7 @@ let actionCounter = 0;
2448
2719
  * const result = await createPost({ title: 'Hello', body: '...' })
2449
2720
  */
2450
2721
  function defineAction(handler) {
2451
- const id = `action_${actionCounter++}`;
2722
+ const id = `action_${crypto.randomUUID().slice(0, 8)}`;
2452
2723
  actionRegistry.set(id, {
2453
2724
  id,
2454
2725
  handler
@@ -2514,10 +2785,10 @@ async function executeAction(action, req) {
2514
2785
 
2515
2786
  //#endregion
2516
2787
  //#region src/favicon.ts
2517
- let sharpWarned = false;
2518
- function warnSharpMissing() {
2519
- if (sharpWarned) return;
2520
- sharpWarned = true;
2788
+ let sharpWarned$1 = false;
2789
+ function warnSharpMissing$1() {
2790
+ if (sharpWarned$1) return;
2791
+ sharpWarned$1 = true;
2521
2792
  console.warn("\n[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
2522
2793
  }
2523
2794
  const SIZES = [
@@ -2573,17 +2844,31 @@ function faviconPlugin(config) {
2573
2844
  },
2574
2845
  configureServer(server) {
2575
2846
  const sourcePath = join(root, config.source);
2847
+ const devCache = /* @__PURE__ */ new Map();
2576
2848
  server.middlewares.use(async (req, res, next) => {
2577
2849
  const url = req.url ?? "";
2578
- if (url === "/favicon.svg" && config.source.endsWith(".svg")) try {
2579
- const content = await readFile(sourcePath, "utf-8");
2850
+ const localeSource = resolveLocaleSource(url, config, root);
2851
+ const svgUrl = localeSource ? localeSource.url : url;
2852
+ const svgPath = localeSource ? localeSource.sourcePath : sourcePath;
2853
+ const isSvgSource = localeSource ? localeSource.source.endsWith(".svg") : config.source.endsWith(".svg");
2854
+ if (svgUrl.endsWith("/favicon.svg") && isSvgSource) try {
2855
+ const content = await readFile(svgPath, "utf-8");
2580
2856
  res.setHeader("Content-Type", "image/svg+xml");
2581
2857
  res.end(content);
2582
2858
  return;
2583
2859
  } catch {}
2584
- const sizeMatch = SIZES.find((s) => url === `/${s.name}`);
2860
+ const baseName = svgUrl.split("/").pop() ?? "";
2861
+ const sizeMatch = SIZES.find((s) => s.name === baseName);
2585
2862
  if (sizeMatch) {
2586
- const png = await resizeToPng(sourcePath, sizeMatch.size);
2863
+ const cacheKey = `${svgPath}:${sizeMatch.size}`;
2864
+ let png = devCache.get(cacheKey);
2865
+ if (!png) {
2866
+ const result = await resizeToPng(svgPath, sizeMatch.size);
2867
+ if (result) {
2868
+ png = result;
2869
+ devCache.set(cacheKey, result);
2870
+ }
2871
+ }
2587
2872
  if (png) {
2588
2873
  res.setHeader("Content-Type", "image/png");
2589
2874
  res.setHeader("Cache-Control", "no-cache");
@@ -2591,8 +2876,16 @@ function faviconPlugin(config) {
2591
2876
  return;
2592
2877
  }
2593
2878
  }
2594
- if (url === "/favicon.ico") {
2595
- const ico = await generateIco(sourcePath);
2879
+ if (baseName === "favicon.ico") {
2880
+ const cacheKey = `ico:${svgPath}`;
2881
+ let ico = devCache.get(cacheKey);
2882
+ if (!ico) {
2883
+ const result = await generateIco(svgPath);
2884
+ if (result) {
2885
+ ico = result;
2886
+ devCache.set(cacheKey, result);
2887
+ }
2888
+ }
2596
2889
  if (ico) {
2597
2890
  res.setHeader("Content-Type", "image/x-icon");
2598
2891
  res.setHeader("Cache-Control", "no-cache");
@@ -2600,16 +2893,17 @@ function faviconPlugin(config) {
2600
2893
  return;
2601
2894
  }
2602
2895
  }
2603
- if (url === "/site.webmanifest" && generateManifest) {
2896
+ if (baseName === "site.webmanifest" && generateManifest) {
2897
+ const prefix = localeSource ? `/${localeSource.locale}` : "";
2604
2898
  const manifest = {
2605
2899
  name: config.name ?? "App",
2606
2900
  short_name: config.name ?? "App",
2607
2901
  icons: [{
2608
- src: "/icon-192.png",
2902
+ src: `${prefix}/icon-192.png`,
2609
2903
  sizes: "192x192",
2610
2904
  type: "image/png"
2611
2905
  }, {
2612
- src: "/icon-512.png",
2906
+ src: `${prefix}/icon-512.png`,
2613
2907
  sizes: "512x512",
2614
2908
  type: "image/png"
2615
2909
  }],
@@ -2683,61 +2977,8 @@ function faviconPlugin(config) {
2683
2977
  },
2684
2978
  async generateBundle() {
2685
2979
  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
- }
2980
+ await generateFaviconSet.call(this, root, config.source, config.darkSource, "", config, themeColor, backgroundColor, generateManifest);
2981
+ 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
2982
  }
2742
2983
  };
2743
2984
  }
@@ -2758,6 +2999,126 @@ function wrapSvgWithDarkMode(lightSvg, darkSvg) {
2758
2999
  function stripSvgWrapper(svg) {
2759
3000
  return svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
2760
3001
  }
3002
+ /**
3003
+ * Resolve the source path for a locale-prefixed favicon URL.
3004
+ * Returns null if the URL is not locale-prefixed or locale has no override.
3005
+ */
3006
+ function resolveLocaleSource(url, config, rootDir) {
3007
+ if (!config.locales) return null;
3008
+ for (const [locale, localeConfig] of Object.entries(config.locales)) {
3009
+ const prefix = `/${locale}/`;
3010
+ if (url.startsWith(prefix)) return {
3011
+ locale,
3012
+ url,
3013
+ source: localeConfig.source,
3014
+ sourcePath: join(rootDir, localeConfig.source)
3015
+ };
3016
+ }
3017
+ return null;
3018
+ }
3019
+ /**
3020
+ * Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.
3021
+ * Called once for base (prefix = '') and once per locale (prefix = '{locale}/').
3022
+ */
3023
+ async function generateFaviconSet(rootDir, source, darkSource, prefix, config, themeColor, backgroundColor, generateManifest) {
3024
+ const sourcePath = join(rootDir, source);
3025
+ if (!existsSync(sourcePath)) {
3026
+ console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
3027
+ return;
3028
+ }
3029
+ if (source.endsWith(".svg")) {
3030
+ const svgContent = await readFile(sourcePath, "utf-8");
3031
+ let finalSvg = svgContent;
3032
+ if (darkSource) {
3033
+ const darkPath = join(rootDir, darkSource);
3034
+ if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
3035
+ }
3036
+ this.emitFile({
3037
+ type: "asset",
3038
+ fileName: `${prefix}favicon.svg`,
3039
+ source: finalSvg
3040
+ });
3041
+ }
3042
+ for (const { size, name } of SIZES) {
3043
+ const pngBuffer = await resizeToPng(sourcePath, size);
3044
+ if (pngBuffer) this.emitFile({
3045
+ type: "asset",
3046
+ fileName: `${prefix}${name}`,
3047
+ source: pngBuffer
3048
+ });
3049
+ }
3050
+ const ico = await generateIco(sourcePath);
3051
+ if (ico) this.emitFile({
3052
+ type: "asset",
3053
+ fileName: `${prefix}favicon.ico`,
3054
+ source: ico
3055
+ });
3056
+ if (generateManifest) {
3057
+ const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : "";
3058
+ const manifest = {
3059
+ name: config.name ?? "App",
3060
+ short_name: config.name ?? "App",
3061
+ icons: [{
3062
+ src: `${manifestPrefix}/icon-192.png`,
3063
+ sizes: "192x192",
3064
+ type: "image/png"
3065
+ }, {
3066
+ src: `${manifestPrefix}/icon-512.png`,
3067
+ sizes: "512x512",
3068
+ type: "image/png"
3069
+ }],
3070
+ theme_color: themeColor,
3071
+ background_color: backgroundColor,
3072
+ display: "standalone"
3073
+ };
3074
+ this.emitFile({
3075
+ type: "asset",
3076
+ fileName: `${prefix}site.webmanifest`,
3077
+ source: JSON.stringify(manifest, null, 2)
3078
+ });
3079
+ }
3080
+ }
3081
+ /**
3082
+ * Get favicon link tags for a specific locale.
3083
+ * Returns link objects suitable for `useHead()` or direct HTML injection.
3084
+ *
3085
+ * @example
3086
+ * ```ts
3087
+ * const links = faviconLinks("de", { source: "./icon.svg", locales: { de: { source: "./icon-de.svg" } } })
3088
+ * // → [{ rel: "icon", type: "image/svg+xml", href: "/de/favicon.svg" }, ...]
3089
+ * ```
3090
+ */
3091
+ function faviconLinks(locale, config) {
3092
+ const hasLocaleOverride = locale && config.locales?.[locale];
3093
+ const prefix = hasLocaleOverride ? `/${locale}` : "";
3094
+ const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
3095
+ const links = [];
3096
+ if (isSvg) links.push({
3097
+ rel: "icon",
3098
+ type: "image/svg+xml",
3099
+ href: `${prefix}/favicon.svg`
3100
+ });
3101
+ links.push({
3102
+ rel: "icon",
3103
+ type: "image/png",
3104
+ sizes: "32x32",
3105
+ href: `${prefix}/favicon-32x32.png`
3106
+ }, {
3107
+ rel: "icon",
3108
+ type: "image/png",
3109
+ sizes: "16x16",
3110
+ href: `${prefix}/favicon-16x16.png`
3111
+ }, {
3112
+ rel: "apple-touch-icon",
3113
+ sizes: "180x180",
3114
+ href: `${prefix}/apple-touch-icon.png`
3115
+ });
3116
+ if (config.manifest !== false) links.push({
3117
+ rel: "manifest",
3118
+ href: `${prefix}/site.webmanifest`
3119
+ });
3120
+ return links;
3121
+ }
2761
3122
  async function resizeToPng(input, size) {
2762
3123
  try {
2763
3124
  return await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, {
@@ -2770,7 +3131,7 @@ async function resizeToPng(input, size) {
2770
3131
  }
2771
3132
  }).png().toBuffer();
2772
3133
  } catch {
2773
- warnSharpMissing();
3134
+ warnSharpMissing$1();
2774
3135
  return null;
2775
3136
  }
2776
3137
  }
@@ -2803,7 +3164,7 @@ async function generateIco(input) {
2803
3164
  size: 32
2804
3165
  }]);
2805
3166
  } catch {
2806
- warnSharpMissing();
3167
+ warnSharpMissing$1();
2807
3168
  return null;
2808
3169
  }
2809
3170
  }
@@ -2841,95 +3202,323 @@ function createIcoFromPngs(entries) {
2841
3202
  }
2842
3203
 
2843
3204
  //#endregion
2844
- //#region src/i18n-routing.ts
2845
- /**
2846
- * Detect preferred locale from Accept-Language header.
2847
- */
2848
- function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
2849
- if (!acceptLanguage) return defaultLocale;
2850
- const preferred = acceptLanguage.split(",").map((part) => {
2851
- const [lang, q] = part.trim().split(";q=");
2852
- return {
2853
- lang: lang?.split("-")[0]?.toLowerCase() ?? "",
2854
- quality: q ? Number.parseFloat(q) : 1
2855
- };
2856
- }).sort((a, b) => b.quality - a.quality);
2857
- for (const { lang } of preferred) if (locales.includes(lang)) return lang;
2858
- return defaultLocale;
2859
- }
3205
+ //#region src/og-image.ts
2860
3206
  /**
2861
- * Extract locale from a URL path.
2862
- * Returns { locale, pathWithoutLocale }.
2863
- */
2864
- function extractLocaleFromPath(path, locales, defaultLocale) {
2865
- const segments = path.split("/").filter(Boolean);
2866
- const firstSegment = segments[0]?.toLowerCase();
2867
- if (firstSegment && locales.includes(firstSegment)) return {
2868
- locale: firstSegment,
2869
- pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
2870
- };
2871
- return {
2872
- locale: defaultLocale,
2873
- pathWithoutLocale: path
2874
- };
2875
- }
2876
- /**
2877
- * Build a localized path.
2878
- */
2879
- function buildLocalePath(path, locale, defaultLocale, strategy) {
2880
- const clean = path === "/" ? "" : path;
2881
- if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
2882
- return `/${locale}${clean}`;
2883
- }
2884
- /**
2885
- * Create a LocaleContext for use in components and loaders.
2886
- */
2887
- function createLocaleContext(locale, path, config) {
2888
- const strategy = config.strategy ?? "prefix-except-default";
2889
- return {
2890
- locale,
2891
- locales: config.locales,
2892
- defaultLocale: config.defaultLocale,
2893
- localePath(targetPath, targetLocale) {
2894
- return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
2895
- },
2896
- alternates() {
2897
- const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
2898
- return config.locales.map((loc) => ({
2899
- locale: loc,
2900
- url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
2901
- }));
2902
- }
2903
- };
2904
- }
2905
- /**
2906
- * I18n routing middleware for Zero's server.
3207
+ * OG Image generation plugin.
2907
3208
  *
2908
- * - Detects locale from URL prefix or Accept-Language header
2909
- * - Redirects root to preferred locale (when detectLocale is true)
2910
- * - Sets locale context for loaders and components
3209
+ * Generates Open Graph images at build time from templates with
3210
+ * text overlays. Supports locale-specific text for i18n apps.
3211
+ * Uses sharp for image processing (same optional dep as favicon/image plugins).
2911
3212
  *
2912
3213
  * @example
2913
3214
  * ```ts
2914
- * // zero.config.ts
2915
- * import { i18nRouting } from "@pyreon/zero"
3215
+ * // vite.config.ts
3216
+ * import { ogImagePlugin } from "@pyreon/zero/og-image"
2916
3217
  *
2917
- * export default defineConfig({
3218
+ * export default {
2918
3219
  * plugins: [
2919
- * i18nRouting({
3220
+ * ogImagePlugin({
2920
3221
  * locales: ["en", "de", "cs"],
2921
- * defaultLocale: "en",
3222
+ * templates: [{
3223
+ * name: "default",
3224
+ * background: "./src/assets/og-bg.jpg",
3225
+ * layers: [{
3226
+ * text: { en: "Build faster", de: "Schneller bauen", cs: "Stavte rychleji" },
3227
+ * y: "40%",
3228
+ * fontSize: 72,
3229
+ * }],
3230
+ * }],
2922
3231
  * }),
2923
3232
  * ],
2924
- * })
3233
+ * }
2925
3234
  * ```
2926
3235
  */
2927
- function i18nRouting(config) {
2928
- const strategy = config.strategy ?? "prefix-except-default";
2929
- const detectEnabled = config.detectLocale !== false;
2930
- const cookieName = config.cookieName ?? "locale";
2931
- return {
2932
- name: "pyreon-zero-i18n-routing",
3236
+ let sharpWarned = false;
3237
+ function warnSharpMissing() {
3238
+ if (sharpWarned) return;
3239
+ sharpWarned = true;
3240
+ console.warn("\n[zero:og-image] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n");
3241
+ }
3242
+ function resolvePosition(value, dimension, fallback = "50%") {
3243
+ if (value === void 0) value = fallback;
3244
+ if (typeof value === "number") return value;
3245
+ if (value.endsWith("%")) return Math.round(Number.parseFloat(value) / 100 * dimension);
3246
+ return Number.parseInt(value, 10) || 0;
3247
+ }
3248
+ function resolveLayerText(layer, locale) {
3249
+ if (typeof layer.text === "string") return layer.text;
3250
+ if (typeof layer.text === "function") return layer.text(locale);
3251
+ return layer.text[locale] ?? layer.text[Object.keys(layer.text)[0] ?? ""] ?? "";
3252
+ }
3253
+ function escapeXml(str) {
3254
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3255
+ }
3256
+ /**
3257
+ * Build an SVG overlay with text layers.
3258
+ * @internal Exported for testing.
3259
+ */
3260
+ function buildTextOverlaySvg(layers, width, height, locale) {
3261
+ return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${layers.map((layer) => {
3262
+ const text = resolveLayerText(layer, locale);
3263
+ const x = resolvePosition(layer.x, width, "50%");
3264
+ const y = resolvePosition(layer.y, height, "50%");
3265
+ const fontSize = layer.fontSize ?? 64;
3266
+ const fontFamily = layer.fontFamily ?? "sans-serif";
3267
+ const fontWeight = layer.fontWeight ?? "bold";
3268
+ const color = layer.color ?? "#ffffff";
3269
+ const anchor = layer.textAnchor ?? "middle";
3270
+ const maxWidth = layer.maxWidth ?? Math.round(width * .8);
3271
+ const words = text.split(" ");
3272
+ const lines = [];
3273
+ let currentLine = "";
3274
+ const estimateWidth = (s) => {
3275
+ let width = 0;
3276
+ for (let i = 0; i < s.length; i++) {
3277
+ const code = s.charCodeAt(i);
3278
+ if (code >= 12288 && code <= 40959) width += fontSize * 1;
3279
+ else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) width += fontSize * .35;
3280
+ else width += fontSize * .55;
3281
+ }
3282
+ return width;
3283
+ };
3284
+ for (const word of words) {
3285
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
3286
+ if (estimateWidth(testLine) > maxWidth && currentLine) {
3287
+ lines.push(currentLine);
3288
+ currentLine = word;
3289
+ } else currentLine = testLine;
3290
+ }
3291
+ if (currentLine) lines.push(currentLine);
3292
+ const tspans = lines.map((line, i) => {
3293
+ return `<tspan x="${x}" dy="${i === 0 ? "0" : `${fontSize * 1.2}`}">${escapeXml(line)}</tspan>`;
3294
+ }).join("");
3295
+ 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>`;
3296
+ }).join("")}</svg>`;
3297
+ }
3298
+ /**
3299
+ * Render an OG image from a template for a specific locale.
3300
+ * @internal Exported for testing.
3301
+ */
3302
+ async function renderOgImage(template, locale, rootDir) {
3303
+ try {
3304
+ const sharp = await import("sharp").then((m) => m.default ?? m);
3305
+ const width = template.width ?? 1200;
3306
+ const height = template.height ?? 630;
3307
+ let pipeline;
3308
+ if (typeof template.background === "string") pipeline = sharp(join(rootDir, template.background)).resize(width, height, { fit: "cover" });
3309
+ else pipeline = sharp({ create: {
3310
+ width,
3311
+ height,
3312
+ channels: 4,
3313
+ background: template.background.color
3314
+ } });
3315
+ if (template.layers && template.layers.length > 0) {
3316
+ const svgOverlay = buildTextOverlaySvg(template.layers, width, height, locale);
3317
+ pipeline = pipeline.composite([{
3318
+ input: Buffer.from(svgOverlay),
3319
+ top: 0,
3320
+ left: 0
3321
+ }]);
3322
+ }
3323
+ if (template.format === "jpeg") return await pipeline.jpeg({ quality: template.quality ?? 90 }).toBuffer();
3324
+ return await pipeline.png().toBuffer();
3325
+ } catch {
3326
+ warnSharpMissing();
3327
+ return null;
3328
+ }
3329
+ }
3330
+ /**
3331
+ * Compute the OG image path for a template and locale.
3332
+ *
3333
+ * @example
3334
+ * ```ts
3335
+ * ogImagePath("default", "de") // → "/og/default-de.png"
3336
+ * ogImagePath("default") // → "/og/default.png"
3337
+ * ogImagePath("hero", "en", "images") // → "/images/hero-en.png"
3338
+ * ```
3339
+ */
3340
+ function ogImagePath(templateName, locale, outDir = "og", format = "png") {
3341
+ const ext = format === "jpeg" ? "jpg" : "png";
3342
+ return `/${outDir}/${templateName}${locale ? `-${locale}` : ""}.${ext}`;
3343
+ }
3344
+ /**
3345
+ * OG image generation Vite plugin.
3346
+ *
3347
+ * Generates Open Graph images at build time. In dev, generates on-demand.
3348
+ * Requires `sharp` as an optional dependency.
3349
+ *
3350
+ * @example
3351
+ * ```ts
3352
+ * // vite.config.ts
3353
+ * import { ogImagePlugin } from "@pyreon/zero/og-image"
3354
+ *
3355
+ * export default {
3356
+ * plugins: [
3357
+ * ogImagePlugin({
3358
+ * locales: ["en", "de"],
3359
+ * templates: [{
3360
+ * name: "default",
3361
+ * background: { color: "#0066ff" },
3362
+ * layers: [{ text: { en: "Hello", de: "Hallo" }, fontSize: 72 }],
3363
+ * }],
3364
+ * }),
3365
+ * ],
3366
+ * }
3367
+ * ```
3368
+ */
3369
+ function ogImagePlugin(config) {
3370
+ const outDir = config.outDir ?? "og";
3371
+ let root = "";
3372
+ let isBuild = false;
3373
+ return {
3374
+ name: "pyreon-zero-og-image",
3375
+ enforce: "pre",
3376
+ configResolved(resolvedConfig) {
3377
+ root = resolvedConfig.root;
3378
+ isBuild = resolvedConfig.command === "build";
3379
+ },
3380
+ configureServer(server) {
3381
+ const devCache = /* @__PURE__ */ new Map();
3382
+ server.middlewares.use(async (req, res, next) => {
3383
+ const url = req.url ?? "";
3384
+ if (!url.startsWith(`/${outDir}/`)) return next();
3385
+ const match = url.slice(outDir.length + 2).match(/^(.+?)(?:-([a-z]{2,5}))?\.(png|jpe?g)$/);
3386
+ if (!match) return next();
3387
+ const [, templateName, locale, ext] = match;
3388
+ const template = config.templates.find((t) => t.name === templateName);
3389
+ if (!template) return next();
3390
+ const resolvedLocale = locale ?? config.locales?.[0] ?? "en";
3391
+ const cacheKey = `${templateName}:${resolvedLocale}`;
3392
+ let buffer = devCache.get(cacheKey);
3393
+ if (!buffer) {
3394
+ const result = await renderOgImage(template, resolvedLocale, root);
3395
+ if (!result) return next();
3396
+ buffer = result;
3397
+ devCache.set(cacheKey, result);
3398
+ }
3399
+ const contentType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
3400
+ res.setHeader("Content-Type", contentType);
3401
+ res.setHeader("Cache-Control", "no-cache");
3402
+ res.end(Buffer.from(buffer));
3403
+ });
3404
+ },
3405
+ async generateBundle() {
3406
+ if (!isBuild) return;
3407
+ for (const template of config.templates) {
3408
+ const locales = config.locales ?? [void 0];
3409
+ const ext = (template.format ?? "png") === "jpeg" ? "jpg" : "png";
3410
+ for (const locale of locales) {
3411
+ if (typeof template.background === "string") {
3412
+ const bgPath = join(root, template.background);
3413
+ if (!existsSync(bgPath)) {
3414
+ console.warn(`[zero:og-image] Background not found: ${bgPath}`);
3415
+ continue;
3416
+ }
3417
+ }
3418
+ const buffer = await renderOgImage(template, locale ?? "en", root);
3419
+ if (!buffer) continue;
3420
+ const suffix = locale ? `-${locale}` : "";
3421
+ this.emitFile({
3422
+ type: "asset",
3423
+ fileName: `${outDir}/${template.name}${suffix}.${ext}`,
3424
+ source: buffer
3425
+ });
3426
+ }
3427
+ }
3428
+ }
3429
+ };
3430
+ }
3431
+
3432
+ //#endregion
3433
+ //#region src/i18n-routing.ts
3434
+ /**
3435
+ * Detect preferred locale from Accept-Language header.
3436
+ */
3437
+ function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
3438
+ if (!acceptLanguage) return defaultLocale;
3439
+ const preferred = acceptLanguage.split(",").map((part) => {
3440
+ const [lang, q] = part.trim().split(";q=");
3441
+ return {
3442
+ lang: lang?.split("-")[0]?.toLowerCase() ?? "",
3443
+ quality: q ? Number.parseFloat(q) : 1
3444
+ };
3445
+ }).sort((a, b) => b.quality - a.quality);
3446
+ for (const { lang } of preferred) if (locales.includes(lang)) return lang;
3447
+ return defaultLocale;
3448
+ }
3449
+ /**
3450
+ * Extract locale from a URL path.
3451
+ * Returns { locale, pathWithoutLocale }.
3452
+ */
3453
+ function extractLocaleFromPath(path, locales, defaultLocale) {
3454
+ const segments = path.split("/").filter(Boolean);
3455
+ const firstSegment = segments[0]?.toLowerCase();
3456
+ if (firstSegment && locales.includes(firstSegment)) return {
3457
+ locale: firstSegment,
3458
+ pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
3459
+ };
3460
+ return {
3461
+ locale: defaultLocale,
3462
+ pathWithoutLocale: path
3463
+ };
3464
+ }
3465
+ /**
3466
+ * Build a localized path.
3467
+ */
3468
+ function buildLocalePath(path, locale, defaultLocale, strategy) {
3469
+ const clean = path === "/" ? "" : path;
3470
+ if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
3471
+ return `/${locale}${clean}`;
3472
+ }
3473
+ /**
3474
+ * Create a LocaleContext for use in components and loaders.
3475
+ */
3476
+ function createLocaleContext(locale, path, config) {
3477
+ const strategy = config.strategy ?? "prefix-except-default";
3478
+ return {
3479
+ locale,
3480
+ locales: config.locales,
3481
+ defaultLocale: config.defaultLocale,
3482
+ localePath(targetPath, targetLocale) {
3483
+ return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
3484
+ },
3485
+ alternates() {
3486
+ const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
3487
+ return config.locales.map((loc) => ({
3488
+ locale: loc,
3489
+ url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
3490
+ }));
3491
+ }
3492
+ };
3493
+ }
3494
+ /**
3495
+ * I18n routing middleware for Zero's server.
3496
+ *
3497
+ * - Detects locale from URL prefix or Accept-Language header
3498
+ * - Redirects root to preferred locale (when detectLocale is true)
3499
+ * - Sets locale context for loaders and components
3500
+ *
3501
+ * @example
3502
+ * ```ts
3503
+ * // zero.config.ts
3504
+ * import { i18nRouting } from "@pyreon/zero"
3505
+ *
3506
+ * export default defineConfig({
3507
+ * plugins: [
3508
+ * i18nRouting({
3509
+ * locales: ["en", "de", "cs"],
3510
+ * defaultLocale: "en",
3511
+ * }),
3512
+ * ],
3513
+ * })
3514
+ * ```
3515
+ */
3516
+ function i18nRouting(config) {
3517
+ const strategy = config.strategy ?? "prefix-except-default";
3518
+ const detectEnabled = config.detectLocale !== false;
3519
+ const cookieName = config.cookieName ?? "locale";
3520
+ return {
3521
+ name: "pyreon-zero-i18n-routing",
2933
3522
  configResolved() {},
2934
3523
  configureServer(server) {
2935
3524
  server.middlewares.use((req, res, next) => {
@@ -3026,7 +3615,7 @@ const resolveStr = (v) => typeof v === "function" ? v() : v;
3026
3615
  function Meta(props) {
3027
3616
  const hasReactiveTitle = typeof props.title === "function";
3028
3617
  const hasReactiveDescription = typeof props.description === "function";
3029
- if (hasReactiveTitle || hasReactiveDescription) useHead((() => {
3618
+ if (hasReactiveTitle || hasReactiveDescription) useHead(() => {
3030
3619
  const title = resolveStr(props.title);
3031
3620
  const description = resolveStr(props.description);
3032
3621
  const tags = buildMetaTags({
@@ -3034,13 +3623,14 @@ function Meta(props) {
3034
3623
  title,
3035
3624
  description
3036
3625
  });
3037
- return {
3038
- title,
3626
+ const input = {
3039
3627
  meta: tags.meta,
3040
3628
  link: tags.link,
3041
3629
  script: tags.script
3042
3630
  };
3043
- }));
3631
+ if (title) input.title = title;
3632
+ return input;
3633
+ });
3044
3634
  else {
3045
3635
  const title = resolveStr(props.title);
3046
3636
  const description = resolveStr(props.description);
@@ -3049,12 +3639,13 @@ function Meta(props) {
3049
3639
  title,
3050
3640
  description
3051
3641
  });
3052
- useHead({
3053
- title,
3642
+ const input = {
3054
3643
  meta: tags.meta,
3055
3644
  link: tags.link,
3056
3645
  script: tags.script
3057
- });
3646
+ };
3647
+ if (title) input.title = title;
3648
+ useHead(input);
3058
3649
  }
3059
3650
  return props.children ?? null;
3060
3651
  }
@@ -3062,7 +3653,11 @@ function buildMetaTags(props) {
3062
3653
  const meta = [];
3063
3654
  const link = [];
3064
3655
  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;
3656
+ 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;
3657
+ const robots = props.noIndex ? "noindex, nofollow" : props.robots ?? "index, follow";
3658
+ const image = props.image ?? (ogTemplate ? ogImagePath(ogTemplate, locale !== "en_US" ? locale : void 0, ogImageDir, ogImageFormat) : void 0);
3659
+ const resolvedImageWidth = imageWidth ?? (ogTemplate && !props.image ? 1200 : void 0);
3660
+ const resolvedImageHeight = imageHeight ?? (ogTemplate && !props.image ? 630 : void 0);
3066
3661
  if (description) meta.push({
3067
3662
  name: "description",
3068
3663
  content: description
@@ -3095,6 +3690,14 @@ function buildMetaTags(props) {
3095
3690
  property: "og:image:alt",
3096
3691
  content: imageAlt
3097
3692
  });
3693
+ if (resolvedImageWidth) meta.push({
3694
+ property: "og:image:width",
3695
+ content: String(resolvedImageWidth)
3696
+ });
3697
+ if (resolvedImageHeight) meta.push({
3698
+ property: "og:image:height",
3699
+ content: String(resolvedImageHeight)
3700
+ });
3098
3701
  meta.push({
3099
3702
  property: "og:type",
3100
3703
  content: type
@@ -3107,6 +3710,32 @@ function buildMetaTags(props) {
3107
3710
  property: "og:locale",
3108
3711
  content: locale
3109
3712
  });
3713
+ if (video) {
3714
+ meta.push({
3715
+ property: "og:video",
3716
+ content: video
3717
+ });
3718
+ if (videoWidth) meta.push({
3719
+ property: "og:video:width",
3720
+ content: String(videoWidth)
3721
+ });
3722
+ if (videoHeight) meta.push({
3723
+ property: "og:video:height",
3724
+ content: String(videoHeight)
3725
+ });
3726
+ if (video.endsWith(".mp4")) meta.push({
3727
+ property: "og:video:type",
3728
+ content: "video/mp4"
3729
+ });
3730
+ else if (video.endsWith(".webm")) meta.push({
3731
+ property: "og:video:type",
3732
+ content: "video/webm"
3733
+ });
3734
+ }
3735
+ if (audio) meta.push({
3736
+ property: "og:audio",
3737
+ content: audio
3738
+ });
3110
3739
  if (type === "article") {
3111
3740
  if (publishedTime) meta.push({
3112
3741
  property: "article:published_time",
@@ -3193,6 +3822,14 @@ function buildMetaTags(props) {
3193
3822
  href: `${origin}${pathWithoutLocale}`
3194
3823
  });
3195
3824
  }
3825
+ if (favicon) {
3826
+ const faviconLocale = locale !== "en_US" ? locale : void 0;
3827
+ for (const fl of faviconLinks(faviconLocale, favicon)) link.push(fl);
3828
+ if (favicon.themeColor) meta.push({
3829
+ name: "theme-color",
3830
+ content: favicon.themeColor
3831
+ });
3832
+ }
3196
3833
  return {
3197
3834
  meta,
3198
3835
  link,
@@ -3201,5 +3838,820 @@ function buildMetaTags(props) {
3201
3838
  }
3202
3839
 
3203
3840
  //#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 };
3841
+ //#region src/csp.ts
3842
+ /** Client-side fallback nonce (dev server, SPA). */
3843
+ let _clientNonce = "";
3844
+ /**
3845
+ * Read the current CSP nonce in a component.
3846
+ *
3847
+ * SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
3848
+ * system — fully isolated between concurrent requests via AsyncLocalStorage.
3849
+ * Client/dev: falls back to module-level variable set by middleware.
3850
+ *
3851
+ * @example
3852
+ * ```tsx
3853
+ * import { useNonce } from "@pyreon/zero/csp"
3854
+ *
3855
+ * function InlineScript() {
3856
+ * const nonce = useNonce()
3857
+ * return <script nonce={nonce}>console.log("safe")<\/script>
3858
+ * }
3859
+ * ```
3860
+ */
3861
+ function useNonce() {
3862
+ const locals = useRequestLocals();
3863
+ if (locals.cspNonce) return locals.cspNonce;
3864
+ return _clientNonce;
3865
+ }
3866
+ const DIRECTIVE_MAP = {
3867
+ defaultSrc: "default-src",
3868
+ scriptSrc: "script-src",
3869
+ styleSrc: "style-src",
3870
+ imgSrc: "img-src",
3871
+ fontSrc: "font-src",
3872
+ connectSrc: "connect-src",
3873
+ mediaSrc: "media-src",
3874
+ objectSrc: "object-src",
3875
+ frameSrc: "frame-src",
3876
+ childSrc: "child-src",
3877
+ workerSrc: "worker-src",
3878
+ frameAncestors: "frame-ancestors",
3879
+ formAction: "form-action",
3880
+ baseUri: "base-uri",
3881
+ manifestSrc: "manifest-src",
3882
+ reportUri: "report-uri",
3883
+ reportTo: "report-to"
3884
+ };
3885
+ /**
3886
+ * Build a CSP header string from directives.
3887
+ * Exported for testing.
3888
+ */
3889
+ function buildCspHeader(directives, nonce) {
3890
+ const parts = [];
3891
+ for (const [key, cssProp] of Object.entries(DIRECTIVE_MAP)) {
3892
+ const value = directives[key];
3893
+ if (!value) continue;
3894
+ if (Array.isArray(value)) {
3895
+ const resolved = nonce ? value.map((v) => v === "'nonce'" ? `'nonce-${nonce}'` : v) : value.filter((v) => v !== "'nonce'");
3896
+ parts.push(`${cssProp} ${resolved.join(" ")}`);
3897
+ } else if (typeof value === "string") parts.push(`${cssProp} ${value}`);
3898
+ }
3899
+ if (directives.upgradeInsecureRequests) parts.push("upgrade-insecure-requests");
3900
+ if (directives.blockAllMixedContent) parts.push("block-all-mixed-content");
3901
+ return parts.join("; ");
3902
+ }
3903
+ /**
3904
+ * Generate a random nonce string (base64, 16 bytes).
3905
+ */
3906
+ function generateNonce() {
3907
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
3908
+ const bytes = new Uint8Array(16);
3909
+ crypto.getRandomValues(bytes);
3910
+ let binary = "";
3911
+ for (const byte of bytes) binary += String.fromCharCode(byte);
3912
+ return typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64");
3913
+ }
3914
+ return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
3915
+ }
3916
+ /**
3917
+ * CSP middleware — sets Content-Security-Policy header.
3918
+ *
3919
+ * When directives contain `"'nonce'"`, a fresh nonce is generated per-request
3920
+ * and attached to `ctx.locals.cspNonce` for use in inline script tags.
3921
+ *
3922
+ * @example
3923
+ * ```ts
3924
+ * // Apply to all routes
3925
+ * export default defineConfig({
3926
+ * middleware: [
3927
+ * cspMiddleware({
3928
+ * directives: {
3929
+ * defaultSrc: ["'self'"],
3930
+ * scriptSrc: ["'self'", "'nonce'"],
3931
+ * styleSrc: ["'self'", "'unsafe-inline'"],
3932
+ * imgSrc: ["'self'", "data:", "https:"],
3933
+ * },
3934
+ * }),
3935
+ * ],
3936
+ * })
3937
+ * ```
3938
+ */
3939
+ function cspMiddleware(config) {
3940
+ const headerName = config.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";
3941
+ const staticHeader = Object.values(config.directives).some((v) => Array.isArray(v) && v.includes("'nonce'")) ? null : buildCspHeader(config.directives);
3942
+ return (ctx) => {
3943
+ if (staticHeader) {
3944
+ _clientNonce = "";
3945
+ ctx.headers.set(headerName, staticHeader);
3946
+ } else {
3947
+ const nonce = generateNonce();
3948
+ _clientNonce = nonce;
3949
+ ctx.locals.cspNonce = nonce;
3950
+ ctx.headers.set(headerName, buildCspHeader(config.directives, nonce));
3951
+ }
3952
+ };
3953
+ }
3954
+
3955
+ //#endregion
3956
+ //#region src/logger.ts
3957
+ const COLORS = {
3958
+ reset: "\x1B[0m",
3959
+ dim: "\x1B[2m",
3960
+ green: "\x1B[32m",
3961
+ yellow: "\x1B[33m",
3962
+ red: "\x1B[31m",
3963
+ cyan: "\x1B[36m",
3964
+ magenta: "\x1B[35m"
3965
+ };
3966
+ function methodColor(method, colors) {
3967
+ if (!colors) return method.padEnd(7);
3968
+ const padded = method.padEnd(7);
3969
+ switch (method) {
3970
+ case "GET": return `${COLORS.green}${padded}${COLORS.reset}`;
3971
+ case "POST": return `${COLORS.cyan}${padded}${COLORS.reset}`;
3972
+ case "PUT": return `${COLORS.yellow}${padded}${COLORS.reset}`;
3973
+ case "PATCH": return `${COLORS.yellow}${padded}${COLORS.reset}`;
3974
+ case "DELETE": return `${COLORS.red}${padded}${COLORS.reset}`;
3975
+ default: return `${COLORS.magenta}${padded}${COLORS.reset}`;
3976
+ }
3977
+ }
3978
+ function defaultFormat(entry, colors) {
3979
+ const dur = entry.duration < 1 ? "<1ms" : entry.duration < 1e3 ? `${Math.round(entry.duration)}ms` : `${(entry.duration / 1e3).toFixed(2)}s`;
3980
+ const dim = colors ? COLORS.dim : "";
3981
+ const reset = colors ? COLORS.reset : "";
3982
+ return ` ${methodColor(entry.method, colors)} ${entry.path} ${dim}${dur}${reset}`;
3983
+ }
3984
+ /**
3985
+ * Request logging middleware.
3986
+ *
3987
+ * Logs incoming requests with method, path, and duration.
3988
+ * Runs in middleware phase — logs timing from middleware start to
3989
+ * microtask completion (approximate request duration).
3990
+ *
3991
+ * @example
3992
+ * ```ts
3993
+ * // Basic usage
3994
+ * loggerMiddleware()
3995
+ *
3996
+ * // Custom format
3997
+ * loggerMiddleware({
3998
+ * format: (e) => `${e.method} ${e.path} (${e.duration}ms)`,
3999
+ * })
4000
+ * ```
4001
+ */
4002
+ function loggerMiddleware(config) {
4003
+ if ((config?.level ?? "all") === "none") return () => {};
4004
+ const skip = config?.skip ?? [
4005
+ "/__",
4006
+ "/@",
4007
+ "/node_modules"
4008
+ ];
4009
+ const isDev = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
4010
+ const colors = config?.colors ?? isDev;
4011
+ return (ctx) => {
4012
+ if (skip.some((p) => ctx.path.startsWith(p))) return;
4013
+ const start = performance.now();
4014
+ const entry = {
4015
+ method: ctx.req.method ?? "GET",
4016
+ path: ctx.path,
4017
+ duration: 0,
4018
+ timestamp: /* @__PURE__ */ new Date(),
4019
+ userAgent: ctx.req.headers.get("user-agent") ?? void 0
4020
+ };
4021
+ queueMicrotask(() => {
4022
+ entry.duration = performance.now() - start;
4023
+ if (config?.format) {
4024
+ const line = config.format(entry);
4025
+ if (line) console.log(line);
4026
+ } else console.log(defaultFormat(entry, colors));
4027
+ });
4028
+ };
4029
+ }
4030
+
4031
+ //#endregion
4032
+ //#region src/env.ts
4033
+ /**
4034
+ * String validator — accepts any non-empty string.
4035
+ */
4036
+ function str(options) {
4037
+ return {
4038
+ __type: "env-validator",
4039
+ required: options?.default === void 0 && options?.required !== false,
4040
+ defaultValue: options?.default,
4041
+ parse(raw, key) {
4042
+ if (raw === void 0 || raw === "") {
4043
+ if (options?.default !== void 0) return options.default;
4044
+ throw new EnvError(key, "is required but not set", options?.description);
4045
+ }
4046
+ return raw;
4047
+ }
4048
+ };
4049
+ }
4050
+ /**
4051
+ * Number validator — parses to a number, rejects NaN.
4052
+ */
4053
+ function num(options) {
4054
+ return {
4055
+ __type: "env-validator",
4056
+ required: options?.default === void 0 && options?.required !== false,
4057
+ defaultValue: options?.default,
4058
+ parse(raw, key) {
4059
+ if (raw === void 0 || raw === "") {
4060
+ if (options?.default !== void 0) return options.default;
4061
+ throw new EnvError(key, "is required but not set", options?.description);
4062
+ }
4063
+ const n = Number(raw);
4064
+ if (Number.isNaN(n)) throw new EnvError(key, `must be a number, got "${raw}"`, options?.description);
4065
+ return n;
4066
+ }
4067
+ };
4068
+ }
4069
+ /**
4070
+ * Boolean validator — accepts "true"/"1" as true, "false"/"0" as false.
4071
+ */
4072
+ function bool(options) {
4073
+ return {
4074
+ __type: "env-validator",
4075
+ required: options?.default === void 0 && options?.required !== false,
4076
+ defaultValue: options?.default,
4077
+ parse(raw, key) {
4078
+ if (raw === void 0 || raw === "") {
4079
+ if (options?.default !== void 0) return options.default;
4080
+ throw new EnvError(key, "is required but not set", options?.description);
4081
+ }
4082
+ const lower = raw.toLowerCase();
4083
+ if (lower === "true" || lower === "1") return true;
4084
+ if (lower === "false" || lower === "0") return false;
4085
+ throw new EnvError(key, `must be "true" or "false", got "${raw}"`, options?.description);
4086
+ }
4087
+ };
4088
+ }
4089
+ /**
4090
+ * URL validator — validates that the value is a valid URL.
4091
+ */
4092
+ function url(options) {
4093
+ return {
4094
+ __type: "env-validator",
4095
+ required: options?.default === void 0 && options?.required !== false,
4096
+ defaultValue: options?.default,
4097
+ parse(raw, key) {
4098
+ if (raw === void 0 || raw === "") {
4099
+ if (options?.default !== void 0) return options.default;
4100
+ throw new EnvError(key, "is required but not set", options?.description);
4101
+ }
4102
+ try {
4103
+ new URL(raw);
4104
+ return raw;
4105
+ } catch {
4106
+ throw new EnvError(key, `must be a valid URL, got "${raw}"`, options?.description);
4107
+ }
4108
+ }
4109
+ };
4110
+ }
4111
+ /**
4112
+ * Enum validator — value must be one of the allowed values.
4113
+ */
4114
+ function oneOf(values, options) {
4115
+ return {
4116
+ __type: "env-validator",
4117
+ required: options?.default === void 0 && options?.required !== false,
4118
+ defaultValue: options?.default,
4119
+ parse(raw, key) {
4120
+ if (raw === void 0 || raw === "") {
4121
+ if (options?.default !== void 0) return options.default;
4122
+ throw new EnvError(key, "is required but not set", options?.description);
4123
+ }
4124
+ if (!values.includes(raw)) throw new EnvError(key, `must be one of [${values.join(", ")}], got "${raw}"`, options?.description);
4125
+ return raw;
4126
+ }
4127
+ };
4128
+ }
4129
+ var EnvError = class extends Error {
4130
+ constructor(key, message, description) {
4131
+ const desc = description ? ` (${description})` : "";
4132
+ super(`[zero:env] ${key}${desc}: ${message}`);
4133
+ this.name = "EnvError";
4134
+ }
4135
+ };
4136
+ function isEnvValidator(v) {
4137
+ return typeof v === "object" && v !== null && v.__type === "env-validator";
4138
+ }
4139
+ /**
4140
+ * Convert a plain schema value to an EnvValidator.
4141
+ *
4142
+ * - `3000` → num({ default: 3000 })
4143
+ * - `false` → bool({ default: false })
4144
+ * - `"localhost"` → str({ default: "localhost" })
4145
+ * - `String` → str() (required)
4146
+ * - `Number` → num() (required)
4147
+ * - `Boolean` → bool() (required)
4148
+ * - EnvValidator → pass through
4149
+ */
4150
+ function toValidator(value) {
4151
+ if (isEnvValidator(value)) return value;
4152
+ if (value === String) return str();
4153
+ if (value === Number) return num();
4154
+ if (value === Boolean) return bool();
4155
+ if (typeof value === "number") return num({ default: value });
4156
+ if (typeof value === "boolean") return bool({ default: value });
4157
+ if (typeof value === "string") return str({ default: value });
4158
+ throw new Error(`[zero:env] Invalid schema value: ${String(value)}. Use a default value, String/Number/Boolean, or a validator like url().`);
4159
+ }
4160
+ /**
4161
+ * Validate environment variables.
4162
+ *
4163
+ * Schema values can be:
4164
+ * - **Default values**: `3000`, `false`, `"localhost"` → type inferred, used as default
4165
+ * - **Constructors**: `String`, `Number`, `Boolean` → required, no default
4166
+ * - **Validators**: `url()`, `oneOf([...])`, `str()`, `num()`, `bool()` → explicit validation
4167
+ * - **Custom**: `schema(raw => z.coerce.number().parse(raw))` — bridge to any schema library
4168
+ *
4169
+ * @example
4170
+ * ```ts
4171
+ * import { validateEnv, url, oneOf } from "@pyreon/zero/env"
4172
+ *
4173
+ * const env = validateEnv({
4174
+ * PORT: 3000, // optional, default 3000
4175
+ * DATABASE_URL: url(), // required, validated URL
4176
+ * NODE_ENV: oneOf(["dev", "prod", "test"]), // required, must be one of
4177
+ * API_KEY: String, // required string
4178
+ * DEBUG: false, // optional, default false
4179
+ * })
4180
+ * ```
4181
+ */
4182
+ function validateEnv(schema, source) {
4183
+ const env = source ?? (typeof process !== "undefined" ? process.env : {});
4184
+ const result = {};
4185
+ const errors = [];
4186
+ for (const [key, entry] of Object.entries(schema)) {
4187
+ const validator = toValidator(entry);
4188
+ try {
4189
+ result[key] = validator.parse(env[key], key);
4190
+ } catch (e) {
4191
+ errors.push(e.message);
4192
+ }
4193
+ }
4194
+ if (errors.length > 0) {
4195
+ const header = `\n[zero:env] Environment validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n`;
4196
+ const body = errors.map((e) => ` ✗ ${e.replace("[zero:env] ", "")}`).join("\n");
4197
+ throw new Error(header + body + "\n");
4198
+ }
4199
+ return result;
4200
+ }
4201
+ function publicEnv(schema) {
4202
+ const prefix = "ZERO_PUBLIC_";
4203
+ const env = typeof process !== "undefined" ? process.env : {};
4204
+ if (!schema) {
4205
+ const result = {};
4206
+ for (const [key, value] of Object.entries(env)) if (key.startsWith(prefix) && value !== void 0) result[key.slice(12)] = value;
4207
+ return result;
4208
+ }
4209
+ const prefixedSource = {};
4210
+ for (const key of Object.keys(schema)) prefixedSource[key] = env[`${prefix}${key}`];
4211
+ return validateEnv(schema, prefixedSource);
4212
+ }
4213
+ /**
4214
+ * Create an env validator from a custom parse function.
4215
+ * Use this to integrate any schema library (Zod, Valibot, ArkType, etc.).
4216
+ *
4217
+ * @example
4218
+ * ```ts
4219
+ * import { z } from "zod"
4220
+ * import { validateEnv, schema } from "@pyreon/zero/env"
4221
+ *
4222
+ * const env = validateEnv({
4223
+ * PORT: schema(raw => z.coerce.number().parse(raw)),
4224
+ * DATABASE_URL: schema(raw => z.string().url().parse(raw)),
4225
+ * HOST: "localhost", // plain defaults still work alongside
4226
+ * })
4227
+ * ```
4228
+ */
4229
+ function schema(parse) {
4230
+ return {
4231
+ __type: "env-validator",
4232
+ required: true,
4233
+ defaultValue: void 0,
4234
+ parse(raw, key) {
4235
+ if (raw === void 0 || raw === "") throw new Error(`[zero:env] ${key}: is required but not set`);
4236
+ try {
4237
+ return parse(raw);
4238
+ } catch (e) {
4239
+ const msg = e instanceof Error ? e.message : String(e);
4240
+ throw new Error(`[zero:env] ${key}: ${msg}`);
4241
+ }
4242
+ }
4243
+ };
4244
+ }
4245
+
4246
+ //#endregion
4247
+ //#region src/ai.ts
4248
+ /**
4249
+ * Generate llms.txt content from route files and config.
4250
+ *
4251
+ * Format follows the llms.txt proposal:
4252
+ * ```
4253
+ * # {name}
4254
+ * > {description}
4255
+ *
4256
+ * ## Pages
4257
+ * - [/about](/about): About page
4258
+ *
4259
+ * ## API
4260
+ * - GET /api/posts: List posts
4261
+ * ```
4262
+ *
4263
+ * @internal Exported for testing.
4264
+ */
4265
+ function generateLlmsTxt(routeFiles, apiFiles, config) {
4266
+ const lines = [];
4267
+ lines.push(`# ${config.name}`);
4268
+ lines.push(`> ${config.description}`);
4269
+ lines.push("");
4270
+ const routes = parseFileRoutes(routeFiles);
4271
+ const pages = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && !r.isCatchAll && !r.urlPath.includes(":"));
4272
+ if (pages.length > 0) {
4273
+ lines.push("## Pages");
4274
+ lines.push("");
4275
+ for (const page of pages) {
4276
+ const desc = config.pageDescriptions?.[page.urlPath];
4277
+ const url = `${config.origin}${page.urlPath === "/" ? "" : page.urlPath}`;
4278
+ if (desc) lines.push(`- [${page.urlPath}](${url}): ${desc}`);
4279
+ else lines.push(`- [${page.urlPath}](${url})`);
4280
+ }
4281
+ lines.push("");
4282
+ }
4283
+ const dynamicRoutes = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && (r.urlPath.includes(":") || r.isCatchAll));
4284
+ if (dynamicRoutes.length > 0) {
4285
+ lines.push("## Dynamic Pages");
4286
+ lines.push("");
4287
+ for (const route of dynamicRoutes) {
4288
+ const desc = config.pageDescriptions?.[route.urlPath];
4289
+ if (desc) lines.push(`- ${route.urlPath}: ${desc}`);
4290
+ else lines.push(`- ${route.urlPath}`);
4291
+ }
4292
+ lines.push("");
4293
+ }
4294
+ const apiPatterns = parseApiFiles(apiFiles);
4295
+ if (apiPatterns.length > 0 || config.apiDescriptions) {
4296
+ lines.push("## API Endpoints");
4297
+ lines.push("");
4298
+ if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) lines.push(`- ${endpoint}: ${desc}`);
4299
+ const describedPatterns = new Set(Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/, "")));
4300
+ for (const pattern of apiPatterns) if (!describedPatterns.has(pattern)) lines.push(`- ${pattern}`);
4301
+ lines.push("");
4302
+ }
4303
+ if (config.llmsExtra) {
4304
+ lines.push(config.llmsExtra);
4305
+ lines.push("");
4306
+ }
4307
+ return lines.join("\n");
4308
+ }
4309
+ /**
4310
+ * Generate llms-full.txt — expanded version with more detail.
4311
+ * Includes all route metadata and API descriptions.
4312
+ *
4313
+ * @internal Exported for testing.
4314
+ */
4315
+ function generateLlmsFullTxt(routeFiles, apiFiles, config) {
4316
+ const lines = [];
4317
+ lines.push(`# ${config.name} — Full Reference`);
4318
+ lines.push(`> ${config.description}`);
4319
+ lines.push("");
4320
+ lines.push(`Base URL: ${config.origin}`);
4321
+ lines.push("");
4322
+ const pages = parseFileRoutes(routeFiles).filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound);
4323
+ if (pages.length > 0) {
4324
+ lines.push("## All Routes");
4325
+ lines.push("");
4326
+ for (const page of pages) {
4327
+ const desc = config.pageDescriptions?.[page.urlPath] ?? "";
4328
+ const dynamic = page.urlPath.includes(":") ? " (dynamic)" : "";
4329
+ const catchAll = page.isCatchAll ? " (catch-all)" : "";
4330
+ lines.push(`### ${page.urlPath}${dynamic}${catchAll}`);
4331
+ if (desc) lines.push(desc);
4332
+ lines.push(`- File: ${page.filePath}`);
4333
+ lines.push(`- Render mode: ${page.renderMode}`);
4334
+ lines.push("");
4335
+ }
4336
+ }
4337
+ if (config.apiDescriptions) {
4338
+ lines.push("## API Reference");
4339
+ lines.push("");
4340
+ for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
4341
+ lines.push(`### ${endpoint}`);
4342
+ lines.push(desc);
4343
+ lines.push("");
4344
+ }
4345
+ }
4346
+ if (config.llmsExtra) {
4347
+ lines.push("## Additional Information");
4348
+ lines.push("");
4349
+ lines.push(config.llmsExtra);
4350
+ lines.push("");
4351
+ }
4352
+ return lines.join("\n");
4353
+ }
4354
+ /**
4355
+ * Auto-infer JSON-LD structured data from page metadata.
4356
+ *
4357
+ * Returns an array of JSON-LD objects (multiple schemas can apply to one page).
4358
+ * For example, an article page gets both `Article` and `BreadcrumbList`.
4359
+ *
4360
+ * @example
4361
+ * ```tsx
4362
+ * const schemas = inferJsonLd({
4363
+ * url: "https://example.com/blog/my-post",
4364
+ * title: "My Post",
4365
+ * description: "A great article",
4366
+ * type: "article",
4367
+ * author: "Vit Bokisch",
4368
+ * publishedTime: "2026-03-31",
4369
+ * })
4370
+ * // → [Article schema, BreadcrumbList schema]
4371
+ * ```
4372
+ */
4373
+ function inferJsonLd(options) {
4374
+ const schemas = [];
4375
+ if (options.type === "article") {
4376
+ const article = {
4377
+ "@context": "https://schema.org",
4378
+ "@type": "Article",
4379
+ headline: options.title,
4380
+ url: options.url
4381
+ };
4382
+ if (options.description) article.description = options.description;
4383
+ if (options.image) article.image = options.image;
4384
+ if (options.publishedTime) article.datePublished = options.publishedTime;
4385
+ if (options.author) article.author = {
4386
+ "@type": "Person",
4387
+ name: options.author
4388
+ };
4389
+ if (options.tags && options.tags.length > 0) article.keywords = options.tags.join(", ");
4390
+ if (options.siteName) article.publisher = {
4391
+ "@type": "Organization",
4392
+ name: options.siteName
4393
+ };
4394
+ schemas.push(article);
4395
+ } else if (options.type === "product") {
4396
+ const product = {
4397
+ "@context": "https://schema.org",
4398
+ "@type": "Product",
4399
+ name: options.title,
4400
+ url: options.url
4401
+ };
4402
+ if (options.description) product.description = options.description;
4403
+ if (options.image) product.image = options.image;
4404
+ schemas.push(product);
4405
+ } else {
4406
+ const webpage = {
4407
+ "@context": "https://schema.org",
4408
+ "@type": "WebPage",
4409
+ name: options.title,
4410
+ url: options.url
4411
+ };
4412
+ if (options.description) webpage.description = options.description;
4413
+ if (options.image) webpage.thumbnailUrl = options.image;
4414
+ schemas.push(webpage);
4415
+ }
4416
+ if (options.breadcrumbs && options.breadcrumbs.length > 0) schemas.push({
4417
+ "@context": "https://schema.org",
4418
+ "@type": "BreadcrumbList",
4419
+ itemListElement: options.breadcrumbs.map((bc, i) => ({
4420
+ "@type": "ListItem",
4421
+ position: i + 1,
4422
+ name: bc.name,
4423
+ item: bc.url
4424
+ }))
4425
+ });
4426
+ else {
4427
+ const urlObj = safeParseUrl(options.url);
4428
+ if (urlObj) {
4429
+ const segments = urlObj.pathname.split("/").filter(Boolean);
4430
+ if (segments.length > 0) {
4431
+ const items = [{
4432
+ "@type": "ListItem",
4433
+ position: 1,
4434
+ name: "Home",
4435
+ item: urlObj.origin
4436
+ }];
4437
+ let path = "";
4438
+ for (let i = 0; i < segments.length; i++) {
4439
+ path += `/${segments[i]}`;
4440
+ items.push({
4441
+ "@type": "ListItem",
4442
+ position: i + 2,
4443
+ name: capitalize(segments[i].replace(/-/g, " ")),
4444
+ item: `${urlObj.origin}${path}`
4445
+ });
4446
+ }
4447
+ schemas.push({
4448
+ "@context": "https://schema.org",
4449
+ "@type": "BreadcrumbList",
4450
+ itemListElement: items
4451
+ });
4452
+ }
4453
+ }
4454
+ }
4455
+ return schemas;
4456
+ }
4457
+ /**
4458
+ * Generate an OpenAI-compatible AI plugin manifest.
4459
+ *
4460
+ * Follows the /.well-known/ai-plugin.json spec.
4461
+ *
4462
+ * @internal Exported for testing.
4463
+ */
4464
+ function generateAiPluginManifest(config) {
4465
+ return {
4466
+ schema_version: "v1",
4467
+ name_for_human: config.name,
4468
+ name_for_model: config.name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, ""),
4469
+ description_for_human: config.description,
4470
+ description_for_model: config.description,
4471
+ auth: { type: "none" },
4472
+ api: {
4473
+ type: "openapi",
4474
+ url: `${config.origin}/.well-known/openapi.yaml`
4475
+ },
4476
+ logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,
4477
+ contact_email: config.contactEmail ?? "",
4478
+ legal_info_url: config.legalUrl ?? `${config.origin}/legal`
4479
+ };
4480
+ }
4481
+ /**
4482
+ * Generate a minimal OpenAPI 3.0 spec from API route descriptions.
4483
+ *
4484
+ * @internal Exported for testing.
4485
+ */
4486
+ function generateOpenApiSpec(apiFiles, config) {
4487
+ const paths = {};
4488
+ if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
4489
+ const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/);
4490
+ if (match) {
4491
+ const method = match[1].toLowerCase();
4492
+ const openApiPath = match[2].replace(/:(\w+)/g, "{$1}");
4493
+ if (!paths[openApiPath]) paths[openApiPath] = {};
4494
+ paths[openApiPath][method] = {
4495
+ summary: desc,
4496
+ responses: { "200": { description: "Success" } }
4497
+ };
4498
+ }
4499
+ }
4500
+ for (const pattern of parseApiFiles(apiFiles)) {
4501
+ const openApiPath = pattern.replace(/:(\w+)/g, "{$1}");
4502
+ if (!paths[openApiPath]) paths[openApiPath] = { get: {
4503
+ summary: `${openApiPath} endpoint`,
4504
+ responses: { "200": { description: "Success" } }
4505
+ } };
4506
+ }
4507
+ return {
4508
+ openapi: "3.0.0",
4509
+ info: {
4510
+ title: config.name,
4511
+ description: config.description,
4512
+ version: "1.0.0"
4513
+ },
4514
+ servers: [{ url: config.origin }],
4515
+ paths
4516
+ };
4517
+ }
4518
+ /**
4519
+ * AI integration Vite plugin.
4520
+ *
4521
+ * Generates at build time:
4522
+ * - `/llms.txt` — concise site summary for AI agents
4523
+ * - `/llms-full.txt` — detailed reference for AI agents
4524
+ * - `/.well-known/ai-plugin.json` — OpenAI plugin manifest
4525
+ * - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes
4526
+ *
4527
+ * In dev, serves these files via middleware.
4528
+ *
4529
+ * @example
4530
+ * ```ts
4531
+ * import { aiPlugin } from "@pyreon/zero/ai"
4532
+ *
4533
+ * export default {
4534
+ * plugins: [
4535
+ * aiPlugin({
4536
+ * name: "My App",
4537
+ * origin: "https://example.com",
4538
+ * description: "A modern web application",
4539
+ * apiDescriptions: {
4540
+ * "GET /api/posts": "List blog posts",
4541
+ * "GET /api/posts/:id": "Get post by ID",
4542
+ * },
4543
+ * }),
4544
+ * ],
4545
+ * }
4546
+ * ```
4547
+ */
4548
+ function aiPlugin(config) {
4549
+ let root = "";
4550
+ let isBuild = false;
4551
+ let routeFiles = [];
4552
+ let apiFiles = [];
4553
+ return {
4554
+ name: "pyreon-zero-ai",
4555
+ enforce: "post",
4556
+ configResolved(resolvedConfig) {
4557
+ root = resolvedConfig.root;
4558
+ isBuild = resolvedConfig.command === "build";
4559
+ },
4560
+ async buildStart() {
4561
+ try {
4562
+ const { join } = await import("node:path");
4563
+ const routesDir = join(root, config.routesDir ?? "src/routes");
4564
+ const apiDir = join(root, config.apiDir ?? "src/api");
4565
+ routeFiles = await scanDir(routesDir, routesDir);
4566
+ apiFiles = await scanDir(apiDir, apiDir);
4567
+ } catch {}
4568
+ },
4569
+ configureServer(server) {
4570
+ server.middlewares.use(async (req, res, next) => {
4571
+ const url = req.url ?? "";
4572
+ if (url === "/llms.txt") {
4573
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
4574
+ res.end(generateLlmsTxt(routeFiles, apiFiles, config));
4575
+ return;
4576
+ }
4577
+ if (url === "/llms-full.txt") {
4578
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
4579
+ res.end(generateLlmsFullTxt(routeFiles, apiFiles, config));
4580
+ return;
4581
+ }
4582
+ if (url === "/.well-known/ai-plugin.json") {
4583
+ res.setHeader("Content-Type", "application/json");
4584
+ res.end(JSON.stringify(generateAiPluginManifest(config), null, 2));
4585
+ return;
4586
+ }
4587
+ if (url === "/.well-known/openapi.yaml" || url === "/.well-known/openapi.json") {
4588
+ res.setHeader("Content-Type", "application/json");
4589
+ res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2));
4590
+ return;
4591
+ }
4592
+ next();
4593
+ });
4594
+ },
4595
+ async generateBundle() {
4596
+ if (!isBuild) return;
4597
+ this.emitFile({
4598
+ type: "asset",
4599
+ fileName: "llms.txt",
4600
+ source: generateLlmsTxt(routeFiles, apiFiles, config)
4601
+ });
4602
+ this.emitFile({
4603
+ type: "asset",
4604
+ fileName: "llms-full.txt",
4605
+ source: generateLlmsFullTxt(routeFiles, apiFiles, config)
4606
+ });
4607
+ this.emitFile({
4608
+ type: "asset",
4609
+ fileName: ".well-known/ai-plugin.json",
4610
+ source: JSON.stringify(generateAiPluginManifest(config), null, 2)
4611
+ });
4612
+ this.emitFile({
4613
+ type: "asset",
4614
+ fileName: ".well-known/openapi.json",
4615
+ source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2)
4616
+ });
4617
+ }
4618
+ };
4619
+ }
4620
+ function parseApiFiles(files) {
4621
+ return files.filter((f) => f.endsWith(".ts") || f.endsWith(".js")).map((f) => {
4622
+ let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "");
4623
+ if (!path.startsWith("/")) path = `/${path}`;
4624
+ path = path.replace(/\[\.\.\.(\w+)\]/g, ":$1*").replace(/\[(\w+)\]/g, ":$1");
4625
+ return `/api${path === "/" ? "" : path}`;
4626
+ });
4627
+ }
4628
+ async function scanDir(dir, base) {
4629
+ const { readdir, stat } = await import("node:fs/promises");
4630
+ const { join, relative } = await import("node:path");
4631
+ try {
4632
+ const entries = await readdir(dir);
4633
+ const files = [];
4634
+ for (const entry of entries) {
4635
+ const full = join(dir, entry);
4636
+ if ((await stat(full)).isDirectory()) files.push(...await scanDir(full, base));
4637
+ else files.push(relative(base, full));
4638
+ }
4639
+ return files;
4640
+ } catch {
4641
+ return [];
4642
+ }
4643
+ }
4644
+ function safeParseUrl(url) {
4645
+ try {
4646
+ return new URL(url);
4647
+ } catch {
4648
+ return null;
4649
+ }
4650
+ }
4651
+ function capitalize(s) {
4652
+ return s.charAt(0).toUpperCase() + s.slice(1);
4653
+ }
4654
+
4655
+ //#endregion
4656
+ export { Image, Link, Meta, Script, ThemeToggle, aiPlugin, bool, buildCspHeader, buildLocalePath, buildMetaTags, bunAdapter, cacheMiddleware, cloudflareAdapter, compose, compressResponse, compressionMiddleware, corsMiddleware, createActionMiddleware, createApiMiddleware, createApp, createISRHandler, createLink, createLocaleContext, createServer, cspMiddleware, zeroPlugin as default, defineAction, defineConfig, detectLocaleFromHeader, extractLocaleFromPath, faviconLinks, faviconPlugin, filePathToUrlPath, fontPlugin, fontVariables, generateAiPluginManifest, generateApiRouteModule, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateOpenApiSpec, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, imagePlugin, inferJsonLd, initTheme, isCompressible, jsonLd, loggerMiddleware, netlifyAdapter, nodeAdapter, num, ogImagePath, ogImagePlugin, oneOf, parseFileRoutes, prefetchRoute, publicEnv, rateLimitMiddleware, render404Page, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, schema, securityHeaders, seoMiddleware, seoPlugin, setLocale, setSSRThemeDefault, setTheme, staticAdapter, str, theme, themeScript, toggleTheme, url, useLink, useLocale, useNonce, validateEnv, varyEncoding, vercelAdapter };
3205
4657
  //# sourceMappingURL=index.js.map