@pyreon/zero 0.14.0 → 0.16.0

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 (114) hide show
  1. package/lib/api-routes-Ci0kVmM4.js +146 -0
  2. package/lib/client.js +7 -2
  3. package/lib/csp.js +19 -9
  4. package/lib/env.js +6 -6
  5. package/lib/font.js +3 -3
  6. package/lib/{fs-router-CQ7Zxeca.js → fs-router-MewHc5SB.js} +56 -24
  7. package/lib/i18n-routing.js +112 -1
  8. package/lib/image-plugin.js +4 -0
  9. package/lib/image.js +141 -108
  10. package/lib/index.js +253 -132
  11. package/lib/link.js +1 -49
  12. package/lib/og-image.js +5 -5
  13. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  14. package/lib/script.js +115 -74
  15. package/lib/seo.js +186 -15
  16. package/lib/server.js +275 -1247
  17. package/lib/theme.js +1 -50
  18. package/lib/types/config.d.ts +275 -3
  19. package/lib/types/env.d.ts +2 -2
  20. package/lib/types/i18n-routing.d.ts +197 -6
  21. package/lib/types/image.d.ts +105 -5
  22. package/lib/types/index.d.ts +640 -178
  23. package/lib/types/link.d.ts +3 -3
  24. package/lib/types/script.d.ts +78 -6
  25. package/lib/types/seo.d.ts +128 -4
  26. package/lib/types/server.d.ts +603 -77
  27. package/lib/types/theme.d.ts +2 -2
  28. package/lib/vite-plugin-xjWZwudX.js +2454 -0
  29. package/package.json +16 -13
  30. package/src/adapters/bun.ts +20 -1
  31. package/src/adapters/cloudflare.ts +78 -1
  32. package/src/adapters/index.ts +25 -3
  33. package/src/adapters/netlify.ts +63 -1
  34. package/src/adapters/node.ts +25 -1
  35. package/src/adapters/static.ts +26 -1
  36. package/src/adapters/validate.ts +8 -1
  37. package/src/adapters/vercel.ts +76 -1
  38. package/src/adapters/warn-missing-env.ts +49 -0
  39. package/src/app.ts +35 -1
  40. package/src/client.ts +18 -0
  41. package/src/csp.ts +28 -12
  42. package/src/entry-server.ts +55 -5
  43. package/src/env.ts +7 -7
  44. package/src/font.ts +3 -3
  45. package/src/fs-router.ts +123 -4
  46. package/src/i18n-routing.ts +246 -12
  47. package/src/image.tsx +242 -91
  48. package/src/index.ts +4 -4
  49. package/src/isr.ts +24 -6
  50. package/src/manifest.ts +675 -0
  51. package/src/og-image.ts +5 -5
  52. package/src/script.tsx +159 -36
  53. package/src/seo.ts +346 -15
  54. package/src/server.ts +10 -2
  55. package/src/ssg-plugin.ts +1523 -0
  56. package/src/types.ts +329 -19
  57. package/src/vercel-revalidate-handler.ts +204 -0
  58. package/src/vite-plugin.ts +326 -68
  59. package/lib/actions.js.map +0 -1
  60. package/lib/ai.js.map +0 -1
  61. package/lib/api-routes.js.map +0 -1
  62. package/lib/cache.js.map +0 -1
  63. package/lib/client.js.map +0 -1
  64. package/lib/compression.js.map +0 -1
  65. package/lib/config.js.map +0 -1
  66. package/lib/cors.js.map +0 -1
  67. package/lib/csp.js.map +0 -1
  68. package/lib/env.js.map +0 -1
  69. package/lib/favicon.js.map +0 -1
  70. package/lib/font.js.map +0 -1
  71. package/lib/fs-router-3xzp-4Wj.js.map +0 -1
  72. package/lib/fs-router-CQ7Zxeca.js.map +0 -1
  73. package/lib/i18n-routing.js.map +0 -1
  74. package/lib/image-plugin.js.map +0 -1
  75. package/lib/image.js.map +0 -1
  76. package/lib/index.js.map +0 -1
  77. package/lib/link.js.map +0 -1
  78. package/lib/logger.js.map +0 -1
  79. package/lib/meta.js.map +0 -1
  80. package/lib/middleware.js.map +0 -1
  81. package/lib/og-image.js.map +0 -1
  82. package/lib/rate-limit.js.map +0 -1
  83. package/lib/script.js.map +0 -1
  84. package/lib/seo.js.map +0 -1
  85. package/lib/server.js.map +0 -1
  86. package/lib/testing.js.map +0 -1
  87. package/lib/theme.js.map +0 -1
  88. package/lib/types/actions.d.ts.map +0 -1
  89. package/lib/types/ai.d.ts.map +0 -1
  90. package/lib/types/api-routes.d.ts.map +0 -1
  91. package/lib/types/cache.d.ts.map +0 -1
  92. package/lib/types/client.d.ts.map +0 -1
  93. package/lib/types/compression.d.ts.map +0 -1
  94. package/lib/types/config.d.ts.map +0 -1
  95. package/lib/types/cors.d.ts.map +0 -1
  96. package/lib/types/csp.d.ts.map +0 -1
  97. package/lib/types/env.d.ts.map +0 -1
  98. package/lib/types/favicon.d.ts.map +0 -1
  99. package/lib/types/font.d.ts.map +0 -1
  100. package/lib/types/i18n-routing.d.ts.map +0 -1
  101. package/lib/types/image-plugin.d.ts.map +0 -1
  102. package/lib/types/image.d.ts.map +0 -1
  103. package/lib/types/index.d.ts.map +0 -1
  104. package/lib/types/link.d.ts.map +0 -1
  105. package/lib/types/logger.d.ts.map +0 -1
  106. package/lib/types/meta.d.ts.map +0 -1
  107. package/lib/types/middleware.d.ts.map +0 -1
  108. package/lib/types/og-image.d.ts.map +0 -1
  109. package/lib/types/rate-limit.d.ts.map +0 -1
  110. package/lib/types/script.d.ts.map +0 -1
  111. package/lib/types/seo.d.ts.map +0 -1
  112. package/lib/types/server.d.ts.map +0 -1
  113. package/lib/types/testing.d.ts.map +0 -1
  114. package/lib/types/theme.d.ts.map +0 -1
package/lib/server.js CHANGED
@@ -1,327 +1,9 @@
1
- import { a as generateRouteModuleFromRoutes, c as scanRouteFilesWithExports, i as generateRouteModule, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles, t as filePathToUrlPath } from "./fs-router-CQ7Zxeca.js";
2
- import { Fragment, createContext, h } from "@pyreon/core";
3
- import { HeadProvider } from "@pyreon/head";
4
- import { RouterProvider, RouterView, createRouter } from "@pyreon/router";
5
- import { createHandler } from "@pyreon/server";
6
- import { renderToString } from "@pyreon/runtime-server";
7
- import { existsSync, readdirSync } from "node:fs";
8
- import { join } from "node:path";
9
- import { readFile } from "node:fs/promises";
10
- import { signal } from "@pyreon/reactivity";
1
+ import { _ as render404Page, a as detectLocaleFromHeader, c as vercelAdapter, d as netlifyAdapter, f as cloudflareAdapter, g as createServer, h as resolveConfig, i as createLocaleContext, l as staticAdapter, m as defineConfig, o as i18nRouting, p as bunAdapter, r as zeroPlugin, s as resolveAdapter, t as getZeroPluginConfig, u as nodeAdapter, v as createApp } from "./vite-plugin-xjWZwudX.js";
2
+ import { i as generateRouteModule, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles, t as filePathToUrlPath } from "./fs-router-MewHc5SB.js";
3
+ import { existsSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { readFile, rm, writeFile } from "node:fs/promises";
11
6
 
12
- //#region src/app.ts
13
- /**
14
- * Create a full Zero app — assembles router, head provider, and root layout.
15
- *
16
- * Used internally by entry-server and entry-client.
17
- */
18
- function createApp(options) {
19
- const router = createRouter({
20
- routes: options.routes,
21
- mode: options.routerMode ?? "history",
22
- ...options.url ? { url: options.url } : {},
23
- scrollBehavior: "top"
24
- });
25
- const Layout = options.layout ?? DefaultLayout;
26
- function App() {
27
- return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
28
- }
29
- return {
30
- App,
31
- router
32
- };
33
- }
34
- function DefaultLayout(props) {
35
- return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
36
- }
37
-
38
- //#endregion
39
- //#region src/api-routes.ts
40
- /**
41
- * Match a URL path against an API route pattern.
42
- * Returns extracted params or null if no match.
43
- */
44
- function matchApiRoute(pattern, path) {
45
- const patternParts = pattern.split("/").filter(Boolean);
46
- const pathParts = path.split("/").filter(Boolean);
47
- const params = {};
48
- for (let i = 0; i < patternParts.length; i++) {
49
- const pp = patternParts[i];
50
- if (!pp) continue;
51
- if (pp.endsWith("*")) {
52
- const paramName = pp.slice(1, -1);
53
- params[paramName] = pathParts.slice(i).join("/");
54
- return params;
55
- }
56
- if (i >= pathParts.length) return null;
57
- if (pp.startsWith(":")) {
58
- params[pp.slice(1)] = pathParts[i];
59
- continue;
60
- }
61
- if (pp !== pathParts[i]) return null;
62
- }
63
- return patternParts.length === pathParts.length ? params : null;
64
- }
65
- const HTTP_METHODS = [
66
- "GET",
67
- "POST",
68
- "PUT",
69
- "PATCH",
70
- "DELETE",
71
- "HEAD",
72
- "OPTIONS"
73
- ];
74
- /**
75
- * Create a middleware that dispatches API route requests.
76
- * API routes are matched by URL pattern and HTTP method.
77
- */
78
- function createApiMiddleware(routes) {
79
- return async (ctx) => {
80
- for (const route of routes) {
81
- const params = matchApiRoute(route.pattern, ctx.path);
82
- if (!params) continue;
83
- const method = ctx.req.method.toUpperCase();
84
- const handler = route.module[method];
85
- if (!handler) {
86
- const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(", ");
87
- return new Response(null, {
88
- status: 405,
89
- headers: {
90
- Allow: allowed,
91
- "Content-Type": "application/json"
92
- }
93
- });
94
- }
95
- return handler({
96
- request: ctx.req,
97
- url: ctx.url,
98
- path: ctx.path,
99
- params,
100
- headers: ctx.req.headers
101
- });
102
- }
103
- };
104
- }
105
- /**
106
- * Detect whether a route file is an API route.
107
- * API routes are `.ts` or `.js` files inside an `api/` directory.
108
- */
109
- function isApiRoute(filePath) {
110
- const normalized = filePath.replace(/\\/g, "/");
111
- return normalized.startsWith("api/") && (normalized.endsWith(".ts") || normalized.endsWith(".js")) && !normalized.endsWith(".tsx") && !normalized.endsWith(".jsx");
112
- }
113
- /**
114
- * Convert an API route file path to a URL pattern.
115
- *
116
- * Examples:
117
- * "api/posts.ts" → "/api/posts"
118
- * "api/posts/index.ts" → "/api/posts"
119
- * "api/posts/[id].ts" → "/api/posts/:id"
120
- * "api/[...path].ts" → "/api/:path*"
121
- */
122
- function apiFilePathToPattern(filePath) {
123
- let route = filePath;
124
- for (const ext of [".ts", ".js"]) if (route.endsWith(ext)) {
125
- route = route.slice(0, -ext.length);
126
- break;
127
- }
128
- const segments = route.split("/");
129
- const urlSegments = [];
130
- for (const seg of segments) {
131
- if (seg === "index") continue;
132
- const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
133
- if (catchAll) {
134
- urlSegments.push(`:${catchAll[1]}*`);
135
- continue;
136
- }
137
- const dynamic = seg.match(/^\[(\w+)\]$/);
138
- if (dynamic) {
139
- urlSegments.push(`:${dynamic[1]}`);
140
- continue;
141
- }
142
- urlSegments.push(seg);
143
- }
144
- return `/${urlSegments.join("/")}`;
145
- }
146
- /**
147
- * Generate a virtual module that exports API route entries.
148
- * Each entry maps a URL pattern to a module with HTTP method handlers.
149
- */
150
- function generateApiRouteModule(files, routesDir) {
151
- const apiFiles = files.filter(isApiRoute);
152
- if (apiFiles.length === 0) return "export const apiRoutes = []\n";
153
- const imports = [];
154
- const entries = [];
155
- for (let i = 0; i < apiFiles.length; i++) {
156
- const name = `_api${i}`;
157
- const file = apiFiles[i];
158
- if (!file) continue;
159
- const fullPath = `${routesDir}/${file}`;
160
- const pattern = apiFilePathToPattern(file);
161
- imports.push(`import * as ${name} from "${fullPath}"`);
162
- entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`);
163
- }
164
- return [
165
- ...imports,
166
- "",
167
- "export const apiRoutes = [",
168
- entries.join(",\n"),
169
- "]"
170
- ].join("\n");
171
- }
172
-
173
- //#endregion
174
- //#region src/not-found.ts
175
- const DEFAULT_404_BODY = "<h1>404 — Not Found</h1><p>The page you requested does not exist.</p>";
176
- /**
177
- * Render a 404 component to a full HTML string.
178
- * If no component is provided, returns a default 404 page.
179
- */
180
- async function render404Page(component, template) {
181
- let body;
182
- if (component) body = await renderToString(h(component, null));
183
- else body = DEFAULT_404_BODY;
184
- if (template?.includes("<!--pyreon-app-->")) return template.replace("<!--pyreon-app-->", body);
185
- return `<!DOCTYPE html>
186
- <html lang="en">
187
- <head>
188
- <meta charset="UTF-8">
189
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
190
- <title>404 — Not Found</title>
191
- </head>
192
- <body>
193
- ${body}
194
- </body>
195
- </html>`;
196
- }
197
-
198
- //#endregion
199
- //#region src/entry-server.ts
200
- /**
201
- * Create a middleware that dispatches per-route middleware based on URL pattern matching.
202
- */
203
- function createRouteMiddlewareDispatcher(entries) {
204
- return async (ctx) => {
205
- for (const entry of entries) if (matchPattern(entry.pattern, ctx.path)) {
206
- const mw = Array.isArray(entry.middleware) ? entry.middleware : [entry.middleware];
207
- for (const fn of mw) {
208
- const result = await fn(ctx);
209
- if (result) return result;
210
- }
211
- }
212
- };
213
- }
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
- */
224
- function matchPattern(pattern, path) {
225
- const patternParts = pattern.split("/").filter(Boolean);
226
- const pathParts = path.split("/").filter(Boolean);
227
- for (let i = 0; i < patternParts.length; i++) {
228
- const pp = patternParts[i];
229
- if (pp.endsWith("*")) return i <= pathParts.length;
230
- if (i >= pathParts.length) return false;
231
- if (pp.startsWith(":")) continue;
232
- if (pp !== pathParts[i]) return false;
233
- }
234
- return patternParts.length === pathParts.length;
235
- }
236
- /**
237
- * Create the SSR request handler for production.
238
- *
239
- * @example
240
- * import { routes } from "virtual:zero/routes"
241
- * import { routeMiddleware } from "virtual:zero/route-middleware"
242
- * import { createServer } from "@pyreon/zero"
243
- *
244
- * export default createServer({ routes, routeMiddleware, apiRoutes })
245
- */
246
- function createServer(options) {
247
- const config = options.config ?? {};
248
- const allMiddleware = [];
249
- if (options.apiRoutes?.length) allMiddleware.push(createApiMiddleware(options.apiRoutes));
250
- if (options.routeMiddleware?.length) allMiddleware.push(createRouteMiddlewareDispatcher(options.routeMiddleware));
251
- allMiddleware.push(...config.middleware ?? []);
252
- allMiddleware.push(...options.middleware ?? []);
253
- const { App } = createApp({
254
- routes: options.routes,
255
- routerMode: "history"
256
- });
257
- const handler = createHandler({
258
- App,
259
- routes: options.routes,
260
- middleware: allMiddleware,
261
- mode: config.ssr?.mode ?? "string",
262
- ...options.template ? { template: options.template } : {},
263
- ...options.clientEntry ? { clientEntry: options.clientEntry } : {}
264
- });
265
- if (!options.notFoundComponent) return handler;
266
- const NotFound = options.notFoundComponent;
267
- const routePatterns = flattenRoutePatterns$1(options.routes);
268
- return async (req) => {
269
- const pathname = new URL(req.url).pathname;
270
- if (!routePatterns.some((pattern) => matchPattern(pattern, pathname))) {
271
- const fullHtml = await render404Page(NotFound, options.template);
272
- return new Response(fullHtml, {
273
- status: 404,
274
- headers: { "Content-Type": "text/html; charset=utf-8" }
275
- });
276
- }
277
- return handler(req);
278
- };
279
- }
280
- /** Extract all URL patterns from a nested route tree. */
281
- function flattenRoutePatterns$1(routes, prefix = "") {
282
- const patterns = [];
283
- for (const route of routes) {
284
- const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
285
- patterns.push(fullPath);
286
- if (route.children) patterns.push(...flattenRoutePatterns$1(route.children, fullPath));
287
- }
288
- return patterns;
289
- }
290
-
291
- //#endregion
292
- //#region src/config.ts
293
- /**
294
- * Define a Zero configuration.
295
- * Used in `zero.config.ts` at the project root.
296
- *
297
- * @example
298
- * import { defineConfig } from "@pyreon/zero/config"
299
- *
300
- * export default defineConfig({
301
- * mode: "ssr",
302
- * ssr: { mode: "stream" },
303
- * port: 3000,
304
- * })
305
- */
306
- function defineConfig(config) {
307
- return config;
308
- }
309
- /** Merge user config with defaults. */
310
- function resolveConfig(userConfig = {}) {
311
- return {
312
- mode: "ssr",
313
- base: "/",
314
- port: 3e3,
315
- adapter: "node",
316
- ...userConfig,
317
- ssr: {
318
- mode: "string",
319
- ...userConfig.ssr
320
- }
321
- };
322
- }
323
-
324
- //#endregion
325
7
  //#region src/isr.ts
326
8
  /**
327
9
  * In-memory ISR cache with stale-while-revalidate semantics.
@@ -341,6 +23,7 @@ function createISRHandler(handler, config) {
341
23
  const revalidating = /* @__PURE__ */ new Set();
342
24
  const revalidateMs = config.revalidate * 1e3;
343
25
  const maxEntries = Math.max(1, config.maxEntries ?? 1e3);
26
+ const deriveKey = typeof config.cacheKey === "function" ? (req, _url) => config.cacheKey(req) : (_req, url) => url.pathname;
344
27
  function set(key, entry) {
345
28
  if (cache.has(key)) cache.delete(key);
346
29
  cache.set(key, entry);
@@ -358,12 +41,15 @@ function createISRHandler(handler, config) {
358
41
  }
359
42
  return entry;
360
43
  }
361
- async function revalidate(url) {
362
- const key = url.pathname;
44
+ async function revalidate(url, originalReq) {
45
+ const key = deriveKey(originalReq, url);
363
46
  if (revalidating.has(key)) return;
364
47
  revalidating.add(key);
365
48
  try {
366
- const res = await handler(new Request(url.href, { method: "GET" }));
49
+ const res = await handler(new Request(url.href, {
50
+ method: "GET",
51
+ headers: originalReq.headers
52
+ }));
367
53
  const html = await res.text();
368
54
  const headers = {};
369
55
  res.headers.forEach((v, k) => {
@@ -381,11 +67,11 @@ function createISRHandler(handler, config) {
381
67
  return async (req) => {
382
68
  if (req.method !== "GET") return handler(req);
383
69
  const url = new URL(req.url);
384
- const key = url.pathname;
70
+ const key = deriveKey(req, url);
385
71
  const entry = touch(key);
386
72
  if (entry) {
387
73
  const age = Date.now() - entry.timestamp;
388
- if (age > revalidateMs) revalidate(url);
74
+ if (age > revalidateMs) revalidate(url, req);
389
75
  return new Response(entry.html, {
390
76
  status: 200,
391
77
  headers: {
@@ -402,7 +88,7 @@ function createISRHandler(handler, config) {
402
88
  res.headers.forEach((v, k) => {
403
89
  headers[k] = v;
404
90
  });
405
- cache.set(key, {
91
+ set(key, {
406
92
  html,
407
93
  headers,
408
94
  timestamp: Date.now()
@@ -419,428 +105,97 @@ function createISRHandler(handler, config) {
419
105
  }
420
106
 
421
107
  //#endregion
422
- //#region src/adapters/validate.ts
423
- /**
424
- * Validate that adapter build inputs exist before copying.
425
- * Throws with a clear error message if directories are missing.
426
- * @internal
427
- */
428
- async function validateBuildInputs(options) {
429
- const { existsSync } = await import("node:fs");
430
- if (!existsSync(options.clientOutDir)) throw new Error(`[Pyreon] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`);
431
- if (!existsSync(options.serverEntry)) throw new Error(`[Pyreon] Server entry not found: ${options.serverEntry}. Run "vite build --ssr" first.`);
432
- }
433
-
434
- //#endregion
435
- //#region src/adapters/bun.ts
436
- /**
437
- * Bun adapter — generates a standalone Bun.serve() entry.
438
- */
439
- function bunAdapter() {
440
- return {
441
- name: "bun",
442
- async build(options) {
443
- await validateBuildInputs(options);
444
- const { writeFile, cp, mkdir } = await import("node:fs/promises");
445
- const { join } = await import("node:path");
446
- const outDir = options.outDir;
447
- await mkdir(outDir, { recursive: true });
448
- await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
449
- await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
450
- const port = options.config.port ?? 3e3;
451
- const serverEntry = `
452
- const handler = (await import("./server/entry-server.js")).default
453
- const clientDir = new URL("./client/", import.meta.url).pathname
454
-
455
- Bun.serve({
456
- port: ${port},
457
- async fetch(req) {
458
- const url = new URL(req.url)
459
-
460
- // Try static files first
461
- if (req.method === "GET") {
462
- const filePath = clientDir + (url.pathname === "/" ? "index.html" : url.pathname)
463
- // Prevent path traversal — ensure resolved path stays within clientDir
464
- const resolved = Bun.resolveSync(filePath, ".")
465
- if (!resolved.startsWith(Bun.resolveSync(clientDir, "."))) {
466
- return new Response("Forbidden", { status: 403 })
467
- }
468
- const file = Bun.file(filePath)
469
- if (await file.exists()) {
470
- return new Response(file, {
471
- headers: {
472
- "cache-control": filePath.endsWith(".js") || filePath.endsWith(".css")
473
- ? "public, max-age=31536000, immutable"
474
- : "public, max-age=3600",
475
- },
476
- })
477
- }
478
- }
479
-
480
- // Fall through to SSR handler
481
- return handler(req)
482
- },
483
- })
484
-
485
- console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
486
- `.trimStart();
487
- await writeFile(join(outDir, "index.ts"), serverEntry);
488
- }
489
- };
490
- }
491
-
492
- //#endregion
493
- //#region src/adapters/cloudflare.ts
108
+ //#region src/vercel-revalidate-handler.ts
494
109
  /**
495
- * Cloudflare Pages adapter generates output for Cloudflare Pages with Functions.
110
+ * M3.1Drop-in Vercel revalidate webhook handler.
111
+ *
112
+ * Pre-M3.1 the `vercelAdapter.revalidate(path)` (PR I) POSTed to
113
+ * `/api/_pyreon-revalidate?path=...&secret=...` — a CONVENTION that users
114
+ * had to implement themselves. This helper scaffolds the convention:
496
115
  *
497
- * Produces:
498
- * - Client assets in the output directory root (served as static)
499
- * - `_worker.js` Cloudflare Pages Function for SSR
116
+ * // src/routes/api/_pyreon-revalidate.ts (or `pages/api/...` in
117
+ * // Next-style apps deployed to Vercel)
118
+ * export { vercelRevalidateHandler as default } from '@pyreon/zero/server'
500
119
  *
501
- * Note: Cloudflare Pages Functions have a ~1MB module size limit.
502
- * For large apps, configure Vite's SSR build to bundle server code:
503
- * `ssr: { noExternal: true }` in vite.config.ts.
120
+ * The handler validates the secret query param against
121
+ * `VERCEL_REVALIDATE_TOKEN`, validates the path is in the build-time
122
+ * revalidate manifest, and calls Vercel's `res.revalidate(path)` API.
504
123
  *
505
- * Deploy with: `npx wrangler pages deploy ./dist`
124
+ * Returns a standard `(req: Request) => Response` Web API handler — works
125
+ * with Vercel Edge functions, Node serverless functions (via Vercel's
126
+ * `@vercel/node` adapter that bridges Node `req`/`res` to Web standard
127
+ * fetch shapes), and the in-process `mode: 'ssr'` runtime.
506
128
  *
507
129
  * @example
508
- * ```ts
509
- * // zero.config.ts
510
- * import { defineConfig } from "@pyreon/zero"
130
+ * // src/routes/api/_pyreon-revalidate.ts
131
+ * import { vercelRevalidateHandler } from '@pyreon/zero/server'
511
132
  *
512
- * export default defineConfig({
513
- * adapter: "cloudflare",
133
+ * export const POST = vercelRevalidateHandler({
134
+ * // Optional — defaults to reading `_pyreon-revalidate.json` from cwd.
135
+ * manifestPath: './dist/_pyreon-revalidate.json',
514
136
  * })
515
- * ```
516
- */
517
- function cloudflareAdapter() {
518
- return {
519
- name: "cloudflare",
520
- async build(options) {
521
- await validateBuildInputs(options);
522
- const { writeFile, cp, mkdir } = await import("node:fs/promises");
523
- const { join } = await import("node:path");
524
- const outDir = options.outDir;
525
- await mkdir(outDir, { recursive: true });
526
- await cp(options.clientOutDir, outDir, { recursive: true });
527
- await cp(join(options.serverEntry, ".."), join(outDir, "_server"), { recursive: true });
528
- const workerEntry = `
529
- import handler from "./_server/entry-server.js"
530
-
531
- export default {
532
- async fetch(request, env, ctx) {
533
- const url = new URL(request.url)
534
-
535
- // Let Cloudflare serve static assets (files with extensions)
536
- // This check is a fallback — Pages routes static files automatically
537
- const ext = url.pathname.split(".").pop()
538
- if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
539
- // Cloudflare Pages handles static assets automatically via its asset binding
540
- // Only reach here if the file doesn't exist — fall through to SSR
541
- }
542
-
543
- // SSR handler
544
- try {
545
- return await handler(request)
546
- } catch (err) {
547
- return new Response("Internal Server Error", { status: 500 })
548
- }
549
- },
550
- }
551
- `.trimStart();
552
- await writeFile(join(outDir, "_worker.js"), workerEntry);
553
- await writeFile(join(outDir, "_routes.json"), JSON.stringify({
554
- version: 1,
555
- include: ["/*"],
556
- exclude: [
557
- "/assets/*",
558
- "/favicon.*",
559
- "/site.webmanifest",
560
- "/robots.txt",
561
- "/sitemap.xml"
562
- ]
563
- }, null, 2));
564
- }
565
- };
566
- }
567
-
568
- //#endregion
569
- //#region src/adapters/netlify.ts
570
- /**
571
- * Netlify adapter — generates output for Netlify Functions (v2).
572
- *
573
- * Produces:
574
- * - Client assets in `publish/` directory
575
- * - `netlify/functions/ssr.mjs` — Netlify Function for SSR
576
- * - `netlify.toml` — routing configuration
577
137
  *
578
138
  * @example
579
- * ```ts
580
- * // zero.config.ts
581
- * import { defineConfig } from "@pyreon/zero"
582
- *
583
- * export default defineConfig({
584
- * adapter: "netlify",
139
+ * // Custom revalidate impl (e.g. for a self-hosted Pyreon SSR runtime
140
+ * // that wants build-time revalidate behavior without Vercel's
141
+ * // `res.revalidate()` API):
142
+ * export const POST = vercelRevalidateHandler({
143
+ * onRevalidate: async (path) => {
144
+ * // Clear your in-process ISR cache, emit a metrics event, etc.
145
+ * await myCache.invalidate(path)
146
+ * },
585
147
  * })
586
- * ```
587
148
  */
588
- function netlifyAdapter() {
589
- return {
590
- name: "netlify",
591
- async build(options) {
592
- await validateBuildInputs(options);
593
- const { writeFile, cp, mkdir } = await import("node:fs/promises");
594
- const { join } = await import("node:path");
595
- const outDir = options.outDir;
596
- const publishDir = join(outDir, "publish");
597
- const functionsDir = join(outDir, "netlify", "functions");
598
- await mkdir(publishDir, { recursive: true });
599
- await mkdir(functionsDir, { recursive: true });
600
- await cp(options.clientOutDir, publishDir, { recursive: true });
601
- await cp(join(options.serverEntry, ".."), join(functionsDir, "_server"), { recursive: true });
602
- const funcEntry = `
603
- import handler from "./_server/entry-server.js"
604
-
605
- export default async function(req, context) {
606
- try {
607
- return await handler(req)
608
- } catch (err) {
609
- return new Response("Internal Server Error", { status: 500 })
610
- }
611
- }
612
-
613
- export const config = {
614
- path: "/*",
615
- preferStatic: true,
616
- }
617
- `.trimStart();
618
- await writeFile(join(functionsDir, "ssr.mjs"), funcEntry);
619
- const toml = `
620
- [build]
621
- publish = "publish"
622
- functions = "netlify/functions"
623
-
624
- [[headers]]
625
- for = "/assets/*"
626
- [headers.values]
627
- Cache-Control = "public, max-age=31536000, immutable"
628
-
629
- [[redirects]]
630
- from = "/*"
631
- to = "/.netlify/functions/ssr"
632
- status = 200
633
- conditions = {Role = ["admin", "user", ""]}
634
- `.trimStart();
635
- await writeFile(join(outDir, "netlify.toml"), toml);
636
- }
637
- };
638
- }
639
-
640
- //#endregion
641
- //#region src/adapters/node.ts
642
149
  /**
643
- * Node.js adapter generates a standalone server entry using node:http.
150
+ * Create the Web-standard request handler. Reads the manifest once on first
151
+ * invocation (cached in-process) so repeated revalidations don't re-read the
152
+ * file. Manifest read failures cache the failure too — until next process
153
+ * restart, all requests get the same 500 response (signals deploy-time misconfig).
644
154
  */
645
- function nodeAdapter() {
646
- return {
647
- name: "node",
648
- async build(options) {
649
- await validateBuildInputs(options);
650
- const { writeFile, cp, mkdir } = await import("node:fs/promises");
651
- const { join } = await import("node:path");
652
- const outDir = options.outDir;
653
- await mkdir(outDir, { recursive: true });
654
- await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
655
- await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
656
- const port = options.config.port ?? 3e3;
657
- const serverEntry = `
658
- import { createServer } from "node:http"
659
- import { readFile } from "node:fs/promises"
660
- import { join, extname } from "node:path"
661
- import { fileURLToPath } from "node:url"
662
-
663
- const __dirname = fileURLToPath(new URL(".", import.meta.url))
664
- const handler = (await import("./server/entry-server.js")).default
665
- const clientDir = join(__dirname, "client")
666
-
667
- const MIME_TYPES = {
668
- ".html": "text/html",
669
- ".js": "application/javascript",
670
- ".css": "text/css",
671
- ".json": "application/json",
672
- ".png": "image/png",
673
- ".jpg": "image/jpeg",
674
- ".svg": "image/svg+xml",
675
- ".woff2": "font/woff2",
676
- ".woff": "font/woff",
677
- ".ico": "image/x-icon",
678
- }
679
-
680
- const server = createServer(async (req, res) => {
681
- const url = new URL(req.url ?? "/", "http://localhost")
682
-
683
- // Try to serve static files first
684
- if (req.method === "GET") {
685
- try {
686
- const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
687
- // Prevent path traversal — ensure resolved path stays within clientDir
688
- const { resolve } = await import("node:path")
689
- const resolved = resolve(filePath)
690
- if (!resolved.startsWith(resolve(clientDir))) {
691
- res.writeHead(403)
692
- res.end("Forbidden")
693
- return
694
- }
695
- const ext = extname(filePath)
696
- if (ext && ext !== ".html") {
697
- const data = await readFile(filePath)
698
- const mime = MIME_TYPES[ext] || "application/octet-stream"
699
- res.writeHead(200, {
700
- "content-type": mime,
701
- "cache-control": ext === ".js" || ext === ".css"
702
- ? "public, max-age=31536000, immutable"
703
- : "public, max-age=3600",
704
- })
705
- res.end(data)
706
- return
707
- }
708
- } catch {}
709
- }
710
-
711
- // Fall through to SSR handler
712
- const headers = {}
713
- for (const [key, value] of Object.entries(req.headers)) {
714
- if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
715
- }
716
-
717
- const request = new Request(url.href, {
718
- method: req.method,
719
- headers,
720
- })
721
-
722
- const response = await handler(request)
723
- const body = await response.text()
724
-
725
- const responseHeaders = {}
726
- response.headers.forEach((v, k) => { responseHeaders[k] = v })
727
-
728
- res.writeHead(response.status, responseHeaders)
729
- res.end(body)
730
- })
731
-
732
- server.listen(${port}, () => {
733
- console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
734
- })
735
- `.trimStart();
736
- await writeFile(join(outDir, "index.js"), serverEntry);
737
- await writeFile(join(outDir, "package.json"), JSON.stringify({ type: "module" }, null, 2));
155
+ function vercelRevalidateHandler(options = {}) {
156
+ const manifestPath = options.manifestPath ?? "./dist/_pyreon-revalidate.json";
157
+ const secretEnvVar = options.secretEnvVar ?? "VERCEL_REVALIDATE_TOKEN";
158
+ let cache = null;
159
+ return async function handler(req) {
160
+ if (req.method !== "POST") return new Response(`Method ${req.method} not allowed`, { status: 405 });
161
+ const url = new URL(req.url);
162
+ const path = url.searchParams.get("path");
163
+ const secret = url.searchParams.get("secret");
164
+ if (!path || !secret) return new Response("Bad Request: missing path or secret", { status: 400 });
165
+ const expected = process.env[secretEnvVar];
166
+ if (!expected) return new Response(`Server misconfigured: ${secretEnvVar} env var not set`, { status: 500 });
167
+ if (secret !== expected) return new Response("Forbidden: invalid secret", { status: 403 });
168
+ if (cache === null) try {
169
+ const fileContent = await readFile(resolve(process.cwd(), manifestPath), "utf-8");
170
+ const parsed = JSON.parse(fileContent);
171
+ if (typeof parsed?.revalidate !== "object" || parsed.revalidate === null) throw new Error(`Malformed revalidate manifest at ${manifestPath}: missing or non-object \`revalidate\` field`);
172
+ cache = { manifest: parsed };
173
+ } catch (err) {
174
+ cache = { error: err };
738
175
  }
739
- };
740
- }
741
-
742
- //#endregion
743
- //#region src/adapters/static.ts
744
- /**
745
- * Static adapter — just copies the client build output.
746
- * Used with SSG mode where all pages are pre-rendered at build time.
747
- */
748
- function staticAdapter() {
749
- return {
750
- name: "static",
751
- async build(options) {
752
- const { cp, mkdir } = await import("node:fs/promises");
753
- await mkdir(options.outDir, { recursive: true });
754
- await cp(options.clientOutDir, options.outDir, { recursive: true });
755
- }
756
- };
757
- }
758
-
759
- //#endregion
760
- //#region src/adapters/vercel.ts
761
- /**
762
- * Vercel adapter — generates output for Vercel's Build Output API v3.
763
- *
764
- * Produces a `.vercel/output` directory with:
765
- * - `static/` — client-side assets (JS, CSS, images)
766
- * - `functions/ssr.func/` — serverless function for SSR
767
- * - `config.json` — routing configuration
768
- *
769
- * @example
770
- * ```ts
771
- * // zero.config.ts
772
- * import { defineConfig } from "@pyreon/zero"
773
- *
774
- * export default defineConfig({
775
- * adapter: "vercel",
776
- * })
777
- * ```
778
- */
779
- function vercelAdapter() {
780
- return {
781
- name: "vercel",
782
- async build(options) {
783
- await validateBuildInputs(options);
784
- const { writeFile, cp, mkdir } = await import("node:fs/promises");
785
- const { join } = await import("node:path");
786
- const vercelDir = join(options.outDir, ".vercel", "output");
787
- const staticDir = join(vercelDir, "static");
788
- const funcDir = join(vercelDir, "functions", "ssr.func");
789
- await mkdir(staticDir, { recursive: true });
790
- await mkdir(funcDir, { recursive: true });
791
- await cp(options.clientOutDir, staticDir, { recursive: true });
792
- await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
793
- const funcEntry = `
794
- export default async function handler(req) {
795
- const handler = (await import("./entry-server.js")).default
796
- return handler(req)
797
- }
798
- `.trimStart();
799
- await writeFile(join(funcDir, "index.js"), funcEntry);
800
- await writeFile(join(funcDir, ".vc-config.json"), JSON.stringify({
801
- runtime: "nodejs20.x",
802
- handler: "index.js",
803
- launcherType: "Nodejs"
804
- }, null, 2));
805
- await writeFile(join(vercelDir, "config.json"), JSON.stringify({
806
- version: 3,
807
- routes: [
808
- {
809
- src: "/assets/(.*)",
810
- headers: { "Cache-Control": "public, max-age=31536000, immutable" }
811
- },
812
- {
813
- src: "/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)",
814
- dest: "/$1"
815
- },
816
- {
817
- src: "/(.*)",
818
- dest: "/ssr"
819
- }
820
- ]
821
- }, null, 2));
176
+ if ("error" in cache) return new Response(`Server misconfigured: revalidate manifest at ${manifestPath} unreadable or malformed`, { status: 500 });
177
+ if (!Object.prototype.hasOwnProperty.call(cache.manifest.revalidate, path)) return new Response(`Path "${path}" not in revalidate manifest`, { status: 404 });
178
+ if (options.onRevalidate) try {
179
+ await options.onRevalidate(path);
180
+ } catch (err) {
181
+ return new Response(`Revalidation failed for "${path}": ${err instanceof Error ? err.message : String(err)}`, { status: 500 });
822
182
  }
183
+ return new Response(JSON.stringify({
184
+ revalidated: true,
185
+ path
186
+ }), {
187
+ status: 200,
188
+ headers: { "Content-Type": "application/json" }
189
+ });
823
190
  };
824
191
  }
825
-
826
- //#endregion
827
- //#region src/adapters/index.ts
828
192
  /**
829
- * Resolve the adapter from config.
830
- * Returns a built-in adapter or throws if unknown.
193
+ * Reset the in-process manifest cache. Test-only — production code never
194
+ * reaches this. Used by unit tests to exercise the "manifest changed
195
+ * between requests" path without spinning up a new handler.
196
+ * @internal
831
197
  */
832
- function resolveAdapter(config) {
833
- const name = config.adapter ?? "node";
834
- switch (name) {
835
- case "node": return nodeAdapter();
836
- case "bun": return bunAdapter();
837
- case "static": return staticAdapter();
838
- case "vercel": return vercelAdapter();
839
- case "cloudflare": return cloudflareAdapter();
840
- case "netlify": return netlifyAdapter();
841
- default: throw new Error(`[Pyreon] Unknown adapter: "${name}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`);
842
- }
843
- }
198
+ function _resetVercelRevalidateHandlerCache(handler) {}
844
199
 
845
200
  //#endregion
846
201
  //#region src/middleware.ts
@@ -893,372 +248,6 @@ function getContext(ctx) {
893
248
  return zctx;
894
249
  }
895
250
 
896
- //#endregion
897
- //#region src/error-overlay.ts
898
- /**
899
- * Dev-only error overlay for SSR/loader errors.
900
- * Renders a styled HTML page with the error stack trace.
901
- */
902
- function renderErrorOverlay(error) {
903
- return `<!DOCTYPE html>
904
- <html lang="en">
905
- <head>
906
- <meta charset="UTF-8">
907
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
908
- <title>SSR Error — Pyreon Zero</title>
909
- <style>
910
- * { margin: 0; padding: 0; box-sizing: border-box; }
911
- body {
912
- font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
913
- background: #1a1a2e;
914
- color: #e0e0e0;
915
- min-height: 100vh;
916
- padding: 2rem;
917
- }
918
- .overlay {
919
- max-width: 900px;
920
- margin: 0 auto;
921
- }
922
- .header {
923
- display: flex;
924
- align-items: center;
925
- gap: 0.75rem;
926
- margin-bottom: 1.5rem;
927
- }
928
- .badge {
929
- background: #e74c3c;
930
- color: white;
931
- padding: 0.25rem 0.75rem;
932
- border-radius: 4px;
933
- font-size: 0.75rem;
934
- font-weight: 600;
935
- text-transform: uppercase;
936
- letter-spacing: 0.05em;
937
- }
938
- .label {
939
- color: #888;
940
- font-size: 0.85rem;
941
- }
942
- .message {
943
- font-size: 1.25rem;
944
- color: #ff6b6b;
945
- margin-bottom: 1.5rem;
946
- line-height: 1.5;
947
- word-break: break-word;
948
- }
949
- .stack {
950
- background: #16213e;
951
- border: 1px solid #2a2a4a;
952
- border-radius: 8px;
953
- padding: 1.25rem;
954
- overflow-x: auto;
955
- font-size: 0.8rem;
956
- line-height: 1.7;
957
- white-space: pre-wrap;
958
- word-break: break-all;
959
- }
960
- .stack .at { color: #888; }
961
- .stack .file { color: #4ecdc4; }
962
- .hint {
963
- margin-top: 1.5rem;
964
- padding: 1rem;
965
- background: #1e2a45;
966
- border-radius: 6px;
967
- border-left: 3px solid #3498db;
968
- font-size: 0.8rem;
969
- color: #aaa;
970
- line-height: 1.5;
971
- }
972
- </style>
973
- </head>
974
- <body>
975
- <div class="overlay">
976
- <div class="header">
977
- <span class="badge">SSR Error</span>
978
- <span class="label">Pyreon Zero — Dev Mode</span>
979
- </div>
980
- <div class="message">${escapeHtml(error.message || "Unknown error")}</div>
981
- <pre class="stack">${formatStack(escapeHtml(error.stack || ""))}</pre>
982
- <div class="hint">
983
- This error occurred during server-side rendering. Check the terminal for
984
- the full stack trace. This overlay is only shown in development.
985
- </div>
986
- </div>
987
- </body>
988
- </html>`;
989
- }
990
- function escapeHtml(str) {
991
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
992
- }
993
- function formatStack(stack) {
994
- return stack.split("\n").map((line) => {
995
- if (line.includes("at ")) {
996
- const fileMatch = line.match(/\(([^)]+)\)/);
997
- if (fileMatch) return line.replace(fileMatch[0], `(<span class="file">${fileMatch[1]}</span>)`);
998
- }
999
- return line;
1000
- }).join("\n");
1001
- }
1002
-
1003
- //#endregion
1004
- //#region src/vite-plugin.ts
1005
- /**
1006
- * Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
1007
- * Returns package names to exclude from Vite's dep optimizer.
1008
- */
1009
- function scanPyreonPackages(root) {
1010
- const pyreonDir = join(root, "node_modules", "@pyreon");
1011
- if (!existsSync(pyreonDir)) return [];
1012
- try {
1013
- return readdirSync(pyreonDir).filter((name) => !name.startsWith(".")).map((name) => `@pyreon/${name}`);
1014
- } catch {
1015
- return [];
1016
- }
1017
- }
1018
- /**
1019
- * Resolve a package that isn't at the app's top-level `node_modules` but is
1020
- * nested under another `@pyreon/*` package. Used to alias `@pyreon/runtime-server`
1021
- * to the copy under `node_modules/@pyreon/zero/node_modules/@pyreon/runtime-server`
1022
- * so `ssrLoadModule` works without requiring the app to declare it as a
1023
- * direct dep.
1024
- */
1025
- function resolveNestedPackage(root, name) {
1026
- const direct = join(root, "node_modules", name);
1027
- if (existsSync(direct)) return direct;
1028
- const nested = join(root, "node_modules", "@pyreon", "zero", "node_modules", name);
1029
- if (existsSync(nested)) return nested;
1030
- }
1031
- const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
1032
- const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
1033
- const VIRTUAL_MIDDLEWARE_ID = "virtual:zero/route-middleware";
1034
- const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
1035
- const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
1036
- const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
1037
- /**
1038
- * Zero Vite plugin — adds file-based routing and zero-config conventions
1039
- * on top of @pyreon/vite-plugin.
1040
- *
1041
- * @example
1042
- * // vite.config.ts
1043
- * import pyreon from "@pyreon/vite-plugin"
1044
- * import zero from "@pyreon/zero"
1045
- *
1046
- * export default {
1047
- * plugins: [pyreon(), zero()],
1048
- * }
1049
- */
1050
- function zeroPlugin(userConfig = {}) {
1051
- const config = resolveConfig(userConfig);
1052
- let routesDir;
1053
- let root;
1054
- return {
1055
- name: "pyreon-zero",
1056
- enforce: "pre",
1057
- _zeroConfig: userConfig,
1058
- configResolved(resolvedConfig) {
1059
- root = resolvedConfig.root;
1060
- routesDir = `${root}/src/routes`;
1061
- },
1062
- resolveId(id) {
1063
- if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID;
1064
- if (id === VIRTUAL_MIDDLEWARE_ID) return RESOLVED_VIRTUAL_MIDDLEWARE_ID;
1065
- if (id === VIRTUAL_API_ROUTES_ID) return RESOLVED_VIRTUAL_API_ROUTES_ID;
1066
- },
1067
- async load(id) {
1068
- if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
1069
- return generateRouteModuleFromRoutes(await scanRouteFilesWithExports(routesDir, config.mode), routesDir, { staticImports: config.mode === "ssg" });
1070
- } catch (_err) {
1071
- return `export const routes = []`;
1072
- }
1073
- if (id === RESOLVED_VIRTUAL_MIDDLEWARE_ID) try {
1074
- return generateMiddlewareModule(await scanRouteFiles(routesDir), routesDir);
1075
- } catch (_err) {
1076
- return `export const routeMiddleware = []`;
1077
- }
1078
- if (id === RESOLVED_VIRTUAL_API_ROUTES_ID) try {
1079
- return generateApiRouteModule(await scanRouteFiles(routesDir), routesDir);
1080
- } catch (_err) {
1081
- return `export const apiRoutes = []`;
1082
- }
1083
- },
1084
- configureServer(server) {
1085
- if (config.mode === "ssr") server.middlewares.use((req, res, next) => {
1086
- const accept = req.headers.accept ?? "";
1087
- if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
1088
- const pathname = req.url?.split("?")[0] ?? "/";
1089
- if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
1090
- if (/\.\w+$/.test(pathname)) return next();
1091
- renderSsr(server, root, req.originalUrl ?? pathname, pathname).then((result) => {
1092
- if (result === null) return next();
1093
- res.statusCode = 200;
1094
- res.setHeader("Content-Type", "text/html; charset=utf-8");
1095
- res.setHeader("Content-Length", Buffer.byteLength(result));
1096
- res.end(result);
1097
- }, (err) => {
1098
- const error = err instanceof Error ? err : new Error(String(err));
1099
- server.ssrFixStacktrace(error);
1100
- const html = renderErrorOverlay(error);
1101
- res.statusCode = 500;
1102
- res.setHeader("Content-Type", "text/html; charset=utf-8");
1103
- res.setHeader("Content-Length", Buffer.byteLength(html));
1104
- res.end(html);
1105
- });
1106
- });
1107
- server.middlewares.use((req, res, next) => {
1108
- const accept = req.headers.accept ?? "";
1109
- if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
1110
- const pathname = req.url?.split("?")[0] ?? "/";
1111
- if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
1112
- if (/\.\w+$/.test(pathname)) return next();
1113
- handle404(server, routesDir, pathname, res).then((handled) => {
1114
- if (!handled) next();
1115
- }, (err) => {
1116
- console.error("[Pyreon] Error in 404 handler:", err);
1117
- next();
1118
- });
1119
- });
1120
- server.middlewares.use((req, res, next) => {
1121
- if (!(req.headers.accept ?? "").includes("text/html")) return next();
1122
- const originalEnd = res.end.bind(res);
1123
- let errored = false;
1124
- const handleError = (err) => {
1125
- if (errored) return;
1126
- errored = true;
1127
- const error = err instanceof Error ? err : new Error(String(err));
1128
- server.ssrFixStacktrace(error);
1129
- const html = renderErrorOverlay(error);
1130
- res.statusCode = 500;
1131
- res.setHeader("Content-Type", "text/html; charset=utf-8");
1132
- res.setHeader("Content-Length", Buffer.byteLength(html));
1133
- originalEnd(html);
1134
- };
1135
- res.on("error", handleError);
1136
- try {
1137
- const result = next();
1138
- if (result && typeof result.catch === "function") result.catch(handleError);
1139
- } catch (err) {
1140
- handleError(err);
1141
- }
1142
- });
1143
- server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`);
1144
- server.watcher.on("all", (event, path) => {
1145
- if (path.startsWith(routesDir) && (event === "add" || event === "unlink")) {
1146
- for (const resolvedId of [
1147
- RESOLVED_VIRTUAL_ROUTES_ID,
1148
- RESOLVED_VIRTUAL_MIDDLEWARE_ID,
1149
- RESOLVED_VIRTUAL_API_ROUTES_ID
1150
- ]) {
1151
- const mod = server.moduleGraph.getModuleById(resolvedId);
1152
- if (mod) server.moduleGraph.invalidateModule(mod);
1153
- }
1154
- server.ws.send({ type: "full-reload" });
1155
- }
1156
- });
1157
- },
1158
- config(userConfig) {
1159
- const root = userConfig.root ?? process.cwd();
1160
- const pyreonExclude = scanPyreonPackages(root);
1161
- const runtimeServerAlias = resolveNestedPackage(root, "@pyreon/runtime-server");
1162
- return {
1163
- resolve: {
1164
- conditions: ["bun"],
1165
- ...runtimeServerAlias ? { alias: { "@pyreon/runtime-server": runtimeServerAlias } } : {}
1166
- },
1167
- ssr: { resolve: {
1168
- conditions: ["bun"],
1169
- ...runtimeServerAlias ? { alias: { "@pyreon/runtime-server": runtimeServerAlias } } : {}
1170
- } },
1171
- optimizeDeps: { exclude: pyreonExclude },
1172
- server: { port: config.port },
1173
- define: {
1174
- __ZERO_MODE__: JSON.stringify(config.mode),
1175
- __ZERO_BASE__: JSON.stringify(config.base)
1176
- }
1177
- };
1178
- }
1179
- };
1180
- }
1181
- /**
1182
- * Check if the requested path matches any route. If not, render a 404 page.
1183
- * Returns true if the 404 was handled (response sent), false otherwise.
1184
- *
1185
- * In dev mode, the _404.tsx component cannot be SSR-rendered because
1186
- * the compiler emits _tpl() calls that require `document`. Instead,
1187
- * we return a static 404 page. The actual component rendering happens
1188
- * on the client side when the SPA loads.
1189
- */
1190
- async function handle404(server, _routesDir, pathname, res) {
1191
- const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
1192
- if (flattenRoutePatterns(routes).some((pattern) => matchPattern(pattern, pathname))) return false;
1193
- const html = await render404Page(void 0);
1194
- res.statusCode = 404;
1195
- res.setHeader("Content-Type", "text/html; charset=utf-8");
1196
- res.setHeader("Content-Length", Buffer.byteLength(html));
1197
- res.end(html);
1198
- return true;
1199
- }
1200
- /**
1201
- * Dev-mode SSR render pipeline. Returns the composed HTML string, or `null`
1202
- * if the URL doesn't match any known route (caller falls through to the 404
1203
- * middleware). Mirrors the production `createServer` flow:
1204
- * 1. Load virtual:zero/routes + app.ts via Vite's ssrLoadModule
1205
- * 2. Create a per-request router bound to the request URL
1206
- * 3. Pre-run loaders for the matched route(s)
1207
- * 4. Render app tree with head tag collection
1208
- * 5. Serialize loader data into `window.__PYREON_LOADER_DATA__`
1209
- * 6. Inject everything into the user's transformed index.html (so Vite
1210
- * still gets a chance to inject its HMR client + JSX runtime prelude)
1211
- */
1212
- async function renderSsr(server, root, originalUrl, pathname) {
1213
- const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
1214
- if (!flattenRoutePatterns(routes).some((pattern) => matchPattern(pattern, pathname))) return null;
1215
- let template = await readFile(join(root, "index.html"), "utf-8");
1216
- template = await server.transformIndexHtml(originalUrl, template);
1217
- const [core, headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all([
1218
- server.ssrLoadModule("@pyreon/core"),
1219
- server.ssrLoadModule("@pyreon/head"),
1220
- server.ssrLoadModule("@pyreon/head/ssr"),
1221
- server.ssrLoadModule("@pyreon/router"),
1222
- server.ssrLoadModule("@pyreon/runtime-server")
1223
- ]);
1224
- let userLayout;
1225
- for (const ext of [
1226
- "tsx",
1227
- "ts",
1228
- "jsx",
1229
- "js"
1230
- ]) try {
1231
- const layoutMod = await server.ssrLoadModule(`/src/routes/_layout.${ext}`);
1232
- userLayout = layoutMod.layout ?? layoutMod.default;
1233
- if (userLayout) break;
1234
- } catch {}
1235
- const { App, router: routerInst } = (await server.ssrLoadModule("@pyreon/zero/server")).createApp({
1236
- routes,
1237
- routerMode: "history",
1238
- url: pathname,
1239
- ...userLayout ? { layout: userLayout } : {}
1240
- });
1241
- await routerInst.preload(pathname);
1242
- return runtimeServer.runWithRequestContext(async () => {
1243
- const app = core.h(App, null);
1244
- const { html: appHtml, head } = await headSsr.renderWithHead(app);
1245
- const loaderData = routerPkg.serializeLoaderData(routerInst);
1246
- const loaderScript = loaderData && Object.keys(loaderData).length > 0 ? `<script>window.__PYREON_LOADER_DATA__=${JSON.stringify(loaderData).replace(/<\//g, "<\\/")}<\/script>` : "";
1247
- return template.replace("<!--pyreon-head-->", head).replace("<!--pyreon-app-->", appHtml).replace("<!--pyreon-scripts-->", loaderScript);
1248
- });
1249
- }
1250
- /** Extract all URL patterns from a nested route tree. */
1251
- function flattenRoutePatterns(routes, prefix = "") {
1252
- const patterns = [];
1253
- for (const route of routes) {
1254
- if (!route.path) continue;
1255
- const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
1256
- patterns.push(fullPath);
1257
- if (route.children) patterns.push(...flattenRoutePatterns(route.children, fullPath));
1258
- }
1259
- return patterns;
1260
- }
1261
-
1262
251
  //#endregion
1263
252
  //#region src/favicon.ts
1264
253
  let sharpWarned$1 = false;
@@ -1832,12 +821,16 @@ async function addDevBadgeToPng(pngBuffer, size) {
1832
821
  //#region src/seo.ts
1833
822
  /**
1834
823
  * Generate a sitemap.xml string from route file paths.
824
+ *
825
+ * When `i18n` is set (PR K — passed by `seoPlugin` after reading the
826
+ * i18n config from `zero({ i18n: ... })`), URLs are clustered by their
827
+ * un-prefixed (default-locale) form and each `<url>` carries
828
+ * `<xhtml:link rel="alternate" hreflang="...">` siblings for every
829
+ * locale variant + an `x-default` entry pointing at the default locale.
1835
830
  */
1836
- function generateSitemap(routeFiles, config) {
831
+ function generateSitemap(routeFiles, config, i18n) {
1837
832
  const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
1838
- return `<?xml version="1.0" encoding="UTF-8"?>
1839
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
1840
- ${[...routeFiles.filter((f) => {
833
+ const clusters = clusterPathsByLocale([...routeFiles.filter((f) => {
1841
834
  const name = f.split("/").pop()?.replace(/\.\w+$/, "");
1842
835
  return name !== "_layout" && name !== "_error" && name !== "_loading";
1843
836
  }).map((f) => {
@@ -1850,19 +843,141 @@ ${[...routeFiles.filter((f) => {
1850
843
  path: p,
1851
844
  changefreq,
1852
845
  priority
1853
- })), ...config.additionalPaths ?? []].map((entry) => {
1854
- return ` <url>
1855
- <loc>${escapeXml$1(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
1856
- <changefreq>${entry.changefreq ?? changefreq}</changefreq>
1857
- <priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
1858
- </url>`;
1859
- }).join("\n")}
846
+ })), ...config.additionalPaths ?? []], i18n);
847
+ return `<?xml version="1.0" encoding="UTF-8"?>
848
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${i18n != null && i18n.locales.length > 0 ? " xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"" : ""}>
849
+ ${clusters.map((cluster) => renderClusterEntry(cluster, origin, changefreq, priority, i18n)).join("\n")}
1860
850
  </urlset>`;
1861
851
  }
852
+ /**
853
+ * Cluster URL entries by their un-prefixed (default-locale) form.
854
+ *
855
+ * Each output cluster has:
856
+ * - `canonical`: the SitemapEntry that should be used as the `<url>`
857
+ * payload (default-locale variant; falls back to the first variant
858
+ * if no default-locale entry exists in the cluster).
859
+ * - `variantsByLocale`: Map of locale → SitemapEntry for the cluster.
860
+ *
861
+ * Without i18n, every entry becomes its own single-variant cluster.
862
+ *
863
+ * @internal — exported for unit testing.
864
+ */
865
+ function clusterPathsByLocale(entries, i18n) {
866
+ if (i18n == null || i18n.locales.length === 0) return entries.map((entry) => ({
867
+ canonical: entry,
868
+ variantsByLocale: new Map([[null, entry]])
869
+ }));
870
+ const strategy = i18n.strategy ?? "prefix-except-default";
871
+ const { defaultLocale, locales } = i18n;
872
+ const byUnPrefixed = /* @__PURE__ */ new Map();
873
+ for (const entry of entries) {
874
+ const { unPrefixed, locale } = stripLocalePrefix(entry.path, locales, defaultLocale, strategy);
875
+ let cluster = byUnPrefixed.get(unPrefixed);
876
+ if (!cluster) {
877
+ cluster = /* @__PURE__ */ new Map();
878
+ byUnPrefixed.set(unPrefixed, cluster);
879
+ }
880
+ cluster.set(locale, entry);
881
+ }
882
+ const out = [];
883
+ for (const variantsByLocale of byUnPrefixed.values()) {
884
+ const canonical = variantsByLocale.get(defaultLocale) ?? variantsByLocale.get(null) ?? [...variantsByLocale.values()][0];
885
+ out.push({
886
+ canonical,
887
+ variantsByLocale
888
+ });
889
+ }
890
+ return out;
891
+ }
892
+ /**
893
+ * Strip the locale prefix from a path under the i18n strategy.
894
+ *
895
+ * Returns `{ unPrefixed, locale }`:
896
+ * - `/about` under `prefix-except-default` (default=en) → `{ unPrefixed: '/about', locale: 'en' }`
897
+ * - `/de/about` under either strategy → `{ unPrefixed: '/about', locale: 'de' }`
898
+ * - `/de` (locale root) → `{ unPrefixed: '/', locale: 'de' }`
899
+ * - `/about` under `prefix` → no locale match, returns `{ unPrefixed: '/about', locale: null }`
900
+ * (the URL doesn't fit any locale subtree — sitemap treats it as standalone).
901
+ *
902
+ * @internal — exported for unit testing.
903
+ */
904
+ function stripLocalePrefix(path, locales, defaultLocale, strategy) {
905
+ for (const locale of locales) {
906
+ if (path === `/${locale}`) return {
907
+ unPrefixed: "/",
908
+ locale
909
+ };
910
+ if (path.startsWith(`/${locale}/`)) return {
911
+ unPrefixed: path.slice(`/${locale}`.length),
912
+ locale
913
+ };
914
+ }
915
+ if (strategy === "prefix-except-default") return {
916
+ unPrefixed: path,
917
+ locale: defaultLocale
918
+ };
919
+ return {
920
+ unPrefixed: path,
921
+ locale: null
922
+ };
923
+ }
924
+ function renderClusterEntry(cluster, origin, changefreq, priority, i18n) {
925
+ const { canonical, variantsByLocale } = cluster;
926
+ const lines = [
927
+ " <url>",
928
+ ` <loc>${escapeXml$1(`${origin}${canonical.path === "/" ? "" : canonical.path}`)}</loc>`,
929
+ ` <changefreq>${canonical.changefreq ?? changefreq}</changefreq>`,
930
+ ` <priority>${canonical.priority ?? priority}</priority>`
931
+ ];
932
+ if (canonical.lastmod) lines.push(` <lastmod>${canonical.lastmod}</lastmod>`);
933
+ if (i18n != null && i18n.locales.length > 0 && variantsByLocale.size > 1) {
934
+ for (const locale of i18n.locales) {
935
+ const variant = variantsByLocale.get(locale);
936
+ if (!variant) continue;
937
+ const variantLoc = `${origin}${variant.path === "/" ? "" : variant.path}`;
938
+ lines.push(` <xhtml:link rel="alternate" hreflang="${escapeXml$1(locale)}" href="${escapeXml$1(variantLoc)}"/>`);
939
+ }
940
+ const defaultVariant = variantsByLocale.get(i18n.defaultLocale);
941
+ if (defaultVariant) {
942
+ const defaultLoc = `${origin}${defaultVariant.path === "/" ? "" : defaultVariant.path}`;
943
+ lines.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml$1(defaultLoc)}"/>`);
944
+ }
945
+ }
946
+ lines.push(" </url>");
947
+ return lines.join("\n");
948
+ }
1862
949
  function escapeXml$1(str) {
1863
950
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1864
951
  }
1865
952
  /**
953
+ * Resolve the i18n config to feed `generateSitemap` for hreflang
954
+ * emission. Priority order:
955
+ * 1. Explicit user config — `hreflang: I18nRoutingConfig` (object)
956
+ * 2. Auto-detect from SSG manifest — `hreflang: true` + `manifestI18n`
957
+ * present (only happens in SSG mode where the manifest exists)
958
+ * 3. Nothing — emit plain sitemap without xhtml:link siblings
959
+ *
960
+ * @internal — exported for unit testing.
961
+ */
962
+ function resolveHreflangI18n(hreflang, manifestI18n) {
963
+ if (hreflang == null || hreflang === false) return void 0;
964
+ if (hreflang === true) return manifestI18n;
965
+ return hreflang;
966
+ }
967
+ /**
968
+ * Duck-type guard for `I18nRoutingConfig`. The SSG manifest is JSON,
969
+ * so the embedded i18n field could in principle be malformed if a
970
+ * downstream user hand-edits the manifest (don't). Validate the shape
971
+ * before trusting it.
972
+ *
973
+ * @internal
974
+ */
975
+ function isI18nRoutingConfig(value) {
976
+ if (value == null || typeof value !== "object") return false;
977
+ const v = value;
978
+ return Array.isArray(v.locales) && v.locales.every((l) => typeof l === "string") && typeof v.defaultLocale === "string";
979
+ }
980
+ /**
1866
981
  * Generate a robots.txt string.
1867
982
  */
1868
983
  function generateRobots(config = {}) {
@@ -1913,22 +1028,33 @@ function jsonLd(data) {
1913
1028
  * pyreon(),
1914
1029
  * zero(),
1915
1030
  * seoPlugin({
1916
- * sitemap: { origin: "https://example.com" },
1031
+ * sitemap: {
1032
+ * origin: "https://example.com",
1033
+ * useSsgPaths: true, // include dynamic-route enumerations
1034
+ * },
1917
1035
  * robots: { sitemap: "https://example.com/sitemap.xml" },
1918
1036
  * }),
1919
1037
  * ],
1920
1038
  * }
1921
1039
  */
1922
1040
  function seoPlugin(config = {}) {
1041
+ const useSsgPaths = config.sitemap?.useSsgPaths === true;
1042
+ let distDir = "";
1923
1043
  return {
1924
1044
  name: "pyreon-zero-seo",
1925
1045
  apply: "build",
1046
+ ...useSsgPaths ? { enforce: "post" } : {},
1047
+ configResolved(resolved) {
1048
+ distDir = resolve(resolved.root, resolved.build.outDir);
1049
+ },
1926
1050
  async generateBundle(_, _bundle) {
1927
- if (config.sitemap) {
1928
- const { scanRouteFiles } = await import("./fs-router-CQ7Zxeca.js").then((n) => n.n);
1051
+ if (config.sitemap && !useSsgPaths) {
1052
+ const { scanRouteFiles } = await import("./fs-router-MewHc5SB.js").then((n) => n.n);
1929
1053
  const routesDir = `${process.cwd()}/src/routes`;
1930
1054
  try {
1931
- const sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
1055
+ const files = await scanRouteFiles(routesDir);
1056
+ const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, void 0);
1057
+ const sitemap = generateSitemap(files, config.sitemap, hreflangI18n);
1932
1058
  this.emitFile({
1933
1059
  type: "asset",
1934
1060
  fileName: "sitemap.xml",
@@ -1944,6 +1070,36 @@ function seoPlugin(config = {}) {
1944
1070
  source: robots
1945
1071
  });
1946
1072
  }
1073
+ },
1074
+ async closeBundle() {
1075
+ if (!config.sitemap || !useSsgPaths) return;
1076
+ const { scanRouteFiles } = await import("./fs-router-MewHc5SB.js").then((n) => n.n);
1077
+ const routesDir = `${process.cwd()}/src/routes`;
1078
+ const manifestPath = join(distDir, "_pyreon-ssg-paths.json");
1079
+ try {
1080
+ let ssgPaths = [];
1081
+ let manifestI18n;
1082
+ if (existsSync(manifestPath)) {
1083
+ const raw = await readFile(manifestPath, "utf-8");
1084
+ const parsed = JSON.parse(raw);
1085
+ if (Array.isArray(parsed.paths)) ssgPaths = parsed.paths.filter((p) => typeof p === "string").map((path) => ({ path }));
1086
+ if (isI18nRoutingConfig(parsed.i18n)) manifestI18n = parsed.i18n;
1087
+ try {
1088
+ await rm(manifestPath, { force: true });
1089
+ } catch {}
1090
+ }
1091
+ let files = [];
1092
+ try {
1093
+ files = await scanRouteFiles(routesDir);
1094
+ } catch {}
1095
+ const merged = {
1096
+ ...config.sitemap,
1097
+ additionalPaths: [...ssgPaths, ...config.sitemap.additionalPaths ?? []]
1098
+ };
1099
+ const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, manifestI18n);
1100
+ const sitemap = generateSitemap(files, merged, hreflangI18n);
1101
+ await writeFile(join(distDir, "sitemap.xml"), sitemap, "utf-8");
1102
+ } catch {}
1947
1103
  }
1948
1104
  };
1949
1105
  }
@@ -1955,7 +1111,7 @@ function seoMiddleware(config = {}) {
1955
1111
  return async (ctx) => {
1956
1112
  if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
1957
1113
  if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
1958
- const { scanRouteFiles } = await import("./fs-router-CQ7Zxeca.js").then((n) => n.n);
1114
+ const { scanRouteFiles } = await import("./fs-router-MewHc5SB.js").then((n) => n.n);
1959
1115
  const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
1960
1116
  return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
1961
1117
  } catch {}
@@ -2033,14 +1189,14 @@ function buildTextOverlaySvg(layers, width, height, locale) {
2033
1189
  const lines = [];
2034
1190
  let currentLine = "";
2035
1191
  const estimateWidth = (s) => {
2036
- let width = 0;
1192
+ let w = 0;
2037
1193
  for (let i = 0; i < s.length; i++) {
2038
1194
  const code = s.charCodeAt(i);
2039
- if (code >= 12288 && code <= 40959) width += fontSize * 1;
2040
- else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) width += fontSize * .35;
2041
- else width += fontSize * .55;
1195
+ if (code >= 12288 && code <= 40959) w += fontSize * 1;
1196
+ else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) w += fontSize * .35;
1197
+ else w += fontSize * .55;
2042
1198
  }
2043
- return width;
1199
+ return w;
2044
1200
  };
2045
1201
  for (const word of words) {
2046
1202
  const testLine = currentLine ? `${currentLine} ${word}` : word;
@@ -2600,133 +1756,5 @@ function capitalize(s) {
2600
1756
  }
2601
1757
 
2602
1758
  //#endregion
2603
- //#region src/i18n-routing.ts
2604
- /**
2605
- * Detect preferred locale from Accept-Language header.
2606
- */
2607
- function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
2608
- if (!acceptLanguage) return defaultLocale;
2609
- const preferred = acceptLanguage.split(",").map((part) => {
2610
- const [lang, q] = part.trim().split(";q=");
2611
- return {
2612
- lang: lang?.split("-")[0]?.toLowerCase() ?? "",
2613
- quality: q ? Number.parseFloat(q) : 1
2614
- };
2615
- }).sort((a, b) => b.quality - a.quality);
2616
- for (const { lang } of preferred) if (locales.includes(lang)) return lang;
2617
- return defaultLocale;
2618
- }
2619
- /**
2620
- * Extract locale from a URL path.
2621
- * Returns { locale, pathWithoutLocale }.
2622
- */
2623
- function extractLocaleFromPath(path, locales, defaultLocale) {
2624
- const segments = path.split("/").filter(Boolean);
2625
- const firstSegment = segments[0]?.toLowerCase();
2626
- if (firstSegment && locales.includes(firstSegment)) return {
2627
- locale: firstSegment,
2628
- pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
2629
- };
2630
- return {
2631
- locale: defaultLocale,
2632
- pathWithoutLocale: path
2633
- };
2634
- }
2635
- /**
2636
- * Build a localized path.
2637
- */
2638
- function buildLocalePath(path, locale, defaultLocale, strategy) {
2639
- const clean = path === "/" ? "" : path;
2640
- if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
2641
- return `/${locale}${clean}`;
2642
- }
2643
- /**
2644
- * Create a LocaleContext for use in components and loaders.
2645
- */
2646
- function createLocaleContext(locale, path, config) {
2647
- const strategy = config.strategy ?? "prefix-except-default";
2648
- return {
2649
- locale,
2650
- locales: config.locales,
2651
- defaultLocale: config.defaultLocale,
2652
- localePath(targetPath, targetLocale) {
2653
- return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
2654
- },
2655
- alternates() {
2656
- const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
2657
- return config.locales.map((loc) => ({
2658
- locale: loc,
2659
- url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
2660
- }));
2661
- }
2662
- };
2663
- }
2664
- /**
2665
- * I18n routing middleware for Zero's server.
2666
- *
2667
- * - Detects locale from URL prefix or Accept-Language header
2668
- * - Redirects root to preferred locale (when detectLocale is true)
2669
- * - Sets locale context for loaders and components
2670
- *
2671
- * @example
2672
- * ```ts
2673
- * // zero.config.ts
2674
- * import { i18nRouting } from "@pyreon/zero"
2675
- *
2676
- * export default defineConfig({
2677
- * plugins: [
2678
- * i18nRouting({
2679
- * locales: ["en", "de", "cs"],
2680
- * defaultLocale: "en",
2681
- * }),
2682
- * ],
2683
- * })
2684
- * ```
2685
- */
2686
- function i18nRouting(config) {
2687
- const strategy = config.strategy ?? "prefix-except-default";
2688
- const detectEnabled = config.detectLocale !== false;
2689
- const cookieName = config.cookieName ?? "locale";
2690
- return {
2691
- name: "pyreon-zero-i18n-routing",
2692
- configResolved() {},
2693
- configureServer(server) {
2694
- server.middlewares.use((req, res, next) => {
2695
- const url = req.url ?? "/";
2696
- if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
2697
- const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
2698
- if (detectEnabled && url === "/") {
2699
- const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
2700
- const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
2701
- const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
2702
- if (strategy === "prefix" || preferred !== config.defaultLocale) {
2703
- res.writeHead(302, { Location: `/${preferred}/` });
2704
- res.end();
2705
- return;
2706
- }
2707
- }
2708
- req.__locale = locale;
2709
- req.__localeContext = createLocaleContext(locale, url, config);
2710
- localeSignal.set(locale);
2711
- next();
2712
- });
2713
- }
2714
- };
2715
- }
2716
- function parseCookies(header) {
2717
- if (!header) return {};
2718
- const result = {};
2719
- for (const pair of header.split(";")) {
2720
- const [key, value] = pair.trim().split("=");
2721
- if (key && value) result[key] = decodeURIComponent(value);
2722
- }
2723
- return result;
2724
- }
2725
- /** @internal Context for the current locale. */
2726
- const LocaleCtx = createContext("en");
2727
- /** Current locale signal — set by the server middleware or client-side detection. */
2728
- const localeSignal = signal("en");
2729
-
2730
- //#endregion
2731
- export { aiPlugin, bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter };
1759
+ export { _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
2732
1760
  //# sourceMappingURL=server.js.map