@pyreon/zero 0.15.0 → 0.18.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 (52) hide show
  1. package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
  2. package/lib/client.js +4 -1
  3. package/lib/env.js +6 -6
  4. package/lib/font.js +3 -3
  5. package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
  6. package/lib/i18n-routing.js +112 -1
  7. package/lib/image.js +140 -58
  8. package/lib/index.js +252 -82
  9. package/lib/og-image.js +5 -5
  10. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  11. package/lib/script.js +114 -25
  12. package/lib/seo.js +186 -15
  13. package/lib/server.js +274 -564
  14. package/lib/types/config.d.ts +307 -3
  15. package/lib/types/env.d.ts +2 -2
  16. package/lib/types/i18n-routing.d.ts +193 -2
  17. package/lib/types/image.d.ts +105 -5
  18. package/lib/types/index.d.ts +666 -182
  19. package/lib/types/script.d.ts +78 -6
  20. package/lib/types/seo.d.ts +128 -4
  21. package/lib/types/server.d.ts +607 -72
  22. package/lib/vite-plugin-y0NmCLJA.js +2476 -0
  23. package/package.json +11 -10
  24. package/src/adapters/bun.ts +20 -1
  25. package/src/adapters/cloudflare.ts +78 -1
  26. package/src/adapters/index.ts +25 -3
  27. package/src/adapters/netlify.ts +63 -1
  28. package/src/adapters/node.ts +25 -1
  29. package/src/adapters/static.ts +26 -1
  30. package/src/adapters/validate.ts +8 -1
  31. package/src/adapters/vercel.ts +76 -1
  32. package/src/adapters/warn-missing-env.ts +49 -0
  33. package/src/app.ts +14 -0
  34. package/src/client.ts +18 -0
  35. package/src/entry-server.ts +55 -5
  36. package/src/env.ts +7 -7
  37. package/src/font.ts +3 -3
  38. package/src/fs-router.ts +72 -3
  39. package/src/i18n-routing.ts +246 -12
  40. package/src/image.tsx +242 -91
  41. package/src/index.ts +4 -4
  42. package/src/isr.ts +24 -6
  43. package/src/manifest.ts +675 -0
  44. package/src/og-image.ts +5 -5
  45. package/src/script.tsx +159 -36
  46. package/src/seo.ts +346 -15
  47. package/src/server.ts +10 -2
  48. package/src/ssg-plugin.ts +1211 -54
  49. package/src/types.ts +333 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +171 -41
  52. package/lib/vite-plugin-E4BHYvYW.js +0 -855
@@ -0,0 +1,2476 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-CjeV3_4I.js";
2
+ import { i as matchApiRoute, n as createApiMiddleware, r as generateApiRouteModule } from "./api-routes-Ci0kVmM4.js";
3
+ import { a as generateRouteModuleFromRoutes, c as scanRouteFilesWithExports, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles } from "./fs-router-MewHc5SB.js";
4
+ import { Fragment, createContext, h } from "@pyreon/core";
5
+ import { HeadProvider } from "@pyreon/head";
6
+ import { RouterProvider, RouterView, createRouter, getRedirectInfo } from "@pyreon/router";
7
+ import { createHandler } from "@pyreon/server";
8
+ import { renderToString } from "@pyreon/runtime-server";
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
12
+ import { Readable } from "node:stream";
13
+ import { signal } from "@pyreon/reactivity";
14
+ import { pathToFileURL } from "node:url";
15
+
16
+ //#region src/app.ts
17
+ /**
18
+ * Create a full Zero app — assembles router, head provider, and root layout.
19
+ *
20
+ * Used internally by entry-server and entry-client.
21
+ */
22
+ function createApp(options) {
23
+ const router = createRouter({
24
+ routes: options.routes,
25
+ mode: options.routerMode ?? "history",
26
+ ...options.url ? { url: options.url } : {},
27
+ ...options.base && options.base !== "/" ? { base: options.base } : {},
28
+ scrollBehavior: "top"
29
+ });
30
+ const hasLayoutInRoutes = options.layout !== void 0 && options.routes.some((r) => r.component === options.layout);
31
+ if (hasLayoutInRoutes && process.env.NODE_ENV !== "production") console.warn("[Pyreon] `createApp({ layout })` was passed a component that is ALSO a parent route in the matched chain (likely an fs-router `_layout.tsx`). The explicit `layout` option is being ignored to prevent double-mount. Remove the `layout` argument from `createApp`/`startClient` — the fs-router-emitted route handles it.");
32
+ const Layout = hasLayoutInRoutes ? DefaultLayout : options.layout ?? DefaultLayout;
33
+ function App() {
34
+ return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
35
+ }
36
+ return {
37
+ App,
38
+ router
39
+ };
40
+ }
41
+ function DefaultLayout(props) {
42
+ return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
43
+ }
44
+
45
+ //#endregion
46
+ //#region src/not-found.ts
47
+ const DEFAULT_404_BODY = "<h1>404 — Not Found</h1><p>The page you requested does not exist.</p>";
48
+ /**
49
+ * Render a 404 component to a full HTML string.
50
+ * If no component is provided, returns a default 404 page.
51
+ */
52
+ async function render404Page(component, template) {
53
+ let body;
54
+ if (component) body = await renderToString(h(component, null));
55
+ else body = DEFAULT_404_BODY;
56
+ if (template?.includes("<!--pyreon-app-->")) return template.replace("<!--pyreon-app-->", body);
57
+ return `<!DOCTYPE html>
58
+ <html lang="en">
59
+ <head>
60
+ <meta charset="UTF-8">
61
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
62
+ <title>404 — Not Found</title>
63
+ </head>
64
+ <body>
65
+ ${body}
66
+ </body>
67
+ </html>`;
68
+ }
69
+
70
+ //#endregion
71
+ //#region src/entry-server.ts
72
+ /**
73
+ * Create a middleware that dispatches per-route middleware based on URL pattern matching.
74
+ */
75
+ function createRouteMiddlewareDispatcher(entries) {
76
+ return async (ctx) => {
77
+ for (const entry of entries) if (matchPattern(entry.pattern, ctx.path)) {
78
+ const mw = Array.isArray(entry.middleware) ? entry.middleware : [entry.middleware];
79
+ for (const fn of mw) {
80
+ const result = await fn(ctx);
81
+ if (result) return result;
82
+ }
83
+ }
84
+ };
85
+ }
86
+ /**
87
+ * URL pattern matcher supporting :param and :param* segments.
88
+ *
89
+ * Rules:
90
+ * - Static segments must match exactly
91
+ * - `:param` matches a single path segment
92
+ * - `:param*` matches all remaining segments (must be last, and path must
93
+ * have matched all preceding segments)
94
+ * - Path length must match pattern length (unless catch-all)
95
+ */
96
+ function matchPattern(pattern, path) {
97
+ const patternParts = pattern.split("/").filter(Boolean);
98
+ const pathParts = path.split("/").filter(Boolean);
99
+ for (let i = 0; i < patternParts.length; i++) {
100
+ const pp = patternParts[i];
101
+ if (pp.endsWith("*")) return i <= pathParts.length;
102
+ if (i >= pathParts.length) return false;
103
+ if (pp.startsWith(":")) continue;
104
+ if (pp !== pathParts[i]) return false;
105
+ }
106
+ return patternParts.length === pathParts.length;
107
+ }
108
+ /**
109
+ * Create the SSR request handler for production.
110
+ *
111
+ * @example
112
+ * import { routes } from "virtual:zero/routes"
113
+ * import { routeMiddleware } from "virtual:zero/route-middleware"
114
+ * import { createServer } from "@pyreon/zero"
115
+ *
116
+ * export default createServer({ routes, routeMiddleware, apiRoutes })
117
+ */
118
+ function createServer(options) {
119
+ const config = options.config ?? {};
120
+ const allMiddleware = [];
121
+ if (options.apiRoutes?.length) allMiddleware.push(createApiMiddleware(options.apiRoutes));
122
+ if (options.routeMiddleware?.length) allMiddleware.push(createRouteMiddlewareDispatcher(options.routeMiddleware));
123
+ allMiddleware.push(...config.middleware ?? []);
124
+ allMiddleware.push(...options.middleware ?? []);
125
+ const { App } = createApp({
126
+ routes: options.routes,
127
+ routerMode: "history",
128
+ ...config.base && config.base !== "/" ? { base: config.base } : {}
129
+ });
130
+ const handler = createHandler({
131
+ App,
132
+ routes: options.routes,
133
+ middleware: allMiddleware,
134
+ mode: config.ssr?.mode ?? "string",
135
+ ...options.template ? { template: options.template } : {},
136
+ ...options.clientEntry ? { clientEntry: options.clientEntry } : {}
137
+ });
138
+ if (!options.notFoundComponent) return handler;
139
+ const NotFound = options.notFoundComponent;
140
+ const hasRouteTreeNotFound = routeTreeHasNotFound(options.routes);
141
+ return async (req) => {
142
+ if (hasRouteTreeNotFound) return handler(req);
143
+ const pathname = new URL(req.url).pathname;
144
+ if (!routePatternsCache(options.routes).some((p) => matchPattern(p, pathname))) {
145
+ const fullHtml = await render404Page(NotFound, options.template);
146
+ return new Response(fullHtml, {
147
+ status: 404,
148
+ headers: { "Content-Type": "text/html; charset=utf-8" }
149
+ });
150
+ }
151
+ return handler(req);
152
+ };
153
+ }
154
+ /** Walk the route tree looking for any record with a `notFoundComponent`. */
155
+ function routeTreeHasNotFound(routes) {
156
+ for (const r of routes) {
157
+ if (typeof r.notFoundComponent === "function") return true;
158
+ if (r.children && routeTreeHasNotFound(r.children)) return true;
159
+ }
160
+ return false;
161
+ }
162
+ /** Lazy cache of flattened patterns — only computed if legacy fallback fires. */
163
+ const _routePatternsCache = /* @__PURE__ */ new WeakMap();
164
+ function routePatternsCache(routes) {
165
+ const cached = _routePatternsCache.get(routes);
166
+ if (cached) return cached;
167
+ const out = flattenRoutePatterns$1(routes);
168
+ _routePatternsCache.set(routes, out);
169
+ return out;
170
+ }
171
+ /** Extract all URL patterns from a nested route tree. */
172
+ function flattenRoutePatterns$1(routes, prefix = "") {
173
+ const patterns = [];
174
+ for (const route of routes) {
175
+ const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
176
+ patterns.push(fullPath);
177
+ if (route.children) patterns.push(...flattenRoutePatterns$1(route.children, fullPath));
178
+ }
179
+ return patterns;
180
+ }
181
+
182
+ //#endregion
183
+ //#region src/config.ts
184
+ /**
185
+ * Define a Zero configuration.
186
+ * Used in `zero.config.ts` at the project root.
187
+ *
188
+ * @example
189
+ * import { defineConfig } from "@pyreon/zero/config"
190
+ *
191
+ * export default defineConfig({
192
+ * mode: "ssr",
193
+ * ssr: { mode: "stream" },
194
+ * port: 3000,
195
+ * })
196
+ */
197
+ function defineConfig(config) {
198
+ return config;
199
+ }
200
+ /** Merge user config with defaults. */
201
+ function resolveConfig(userConfig = {}) {
202
+ return {
203
+ mode: "ssr",
204
+ base: "/",
205
+ port: 3e3,
206
+ adapter: "node",
207
+ ...userConfig,
208
+ ssr: {
209
+ mode: "string",
210
+ ...userConfig.ssr
211
+ }
212
+ };
213
+ }
214
+
215
+ //#endregion
216
+ //#region src/adapters/validate.ts
217
+ /**
218
+ * Validate that SSR-mode adapter build inputs exist before copying.
219
+ * Throws with a clear error message if directories are missing.
220
+ *
221
+ * SSG-mode passes through unchanged — the SSG branch doesn't need a
222
+ * server entry (every page is prerendered) and `outDir` IS the dist
223
+ * directory the SSG plugin already populated. Validating it here would
224
+ * be redundant.
225
+ *
226
+ * @internal
227
+ */
228
+ async function validateBuildInputs(options) {
229
+ if (options.kind !== "ssr") return;
230
+ const { existsSync } = await import("node:fs");
231
+ if (!existsSync(options.clientOutDir)) throw new Error(`[Pyreon] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`);
232
+ if (!existsSync(options.serverEntry)) throw new Error(`[Pyreon] Server entry not found: ${options.serverEntry}. Run "vite build --ssr" first.`);
233
+ }
234
+
235
+ //#endregion
236
+ //#region src/adapters/bun.ts
237
+ /**
238
+ * Bun adapter — generates a standalone Bun.serve() entry.
239
+ *
240
+ * **SSG mode (PR J)**: no-op. Bun adapter exists for serving the SSR
241
+ * runtime; SSG output is already complete static HTML — serve it with
242
+ * any static-file server (`bun preview` / `bunx serve` / nginx / Caddy).
243
+ * Use `staticAdapter()` if you want explicit SSG semantics.
244
+ */
245
+ function bunAdapter() {
246
+ return {
247
+ name: "bun",
248
+ async build(options) {
249
+ if (options.kind === "ssg") return;
250
+ await validateBuildInputs(options);
251
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
252
+ const { join } = await import("node:path");
253
+ const outDir = options.outDir;
254
+ await mkdir(outDir, { recursive: true });
255
+ await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
256
+ await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
257
+ const port = options.config.port ?? 3e3;
258
+ const serverEntry = `
259
+ const handler = (await import("./server/entry-server.js")).default
260
+ const clientDir = new URL("./client/", import.meta.url).pathname
261
+
262
+ Bun.serve({
263
+ port: ${port},
264
+ async fetch(req) {
265
+ const url = new URL(req.url)
266
+
267
+ // Try static files first
268
+ if (req.method === "GET") {
269
+ const filePath = clientDir + (url.pathname === "/" ? "index.html" : url.pathname)
270
+ // Prevent path traversal — ensure resolved path stays within clientDir
271
+ const resolved = Bun.resolveSync(filePath, ".")
272
+ if (!resolved.startsWith(Bun.resolveSync(clientDir, "."))) {
273
+ return new Response("Forbidden", { status: 403 })
274
+ }
275
+ const file = Bun.file(filePath)
276
+ if (await file.exists()) {
277
+ return new Response(file, {
278
+ headers: {
279
+ "cache-control": filePath.endsWith(".js") || filePath.endsWith(".css")
280
+ ? "public, max-age=31536000, immutable"
281
+ : "public, max-age=3600",
282
+ },
283
+ })
284
+ }
285
+ }
286
+
287
+ // Fall through to SSR handler
288
+ return handler(req)
289
+ },
290
+ })
291
+
292
+ console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
293
+ `.trimStart();
294
+ await writeFile(join(outDir, "index.ts"), serverEntry);
295
+ },
296
+ async revalidate(_path) {
297
+ if (process.env.NODE_ENV !== "production") console.warn("[Pyreon] bunAdapter.revalidate() is a no-op — self-hosted Bun has no platform-driven ISR. Use mode: \"isr\" for runtime LRU caching, or vercelAdapter / cloudflareAdapter / netlifyAdapter for platform-driven build-time ISR.");
298
+ return { regenerated: false };
299
+ }
300
+ };
301
+ }
302
+
303
+ //#endregion
304
+ //#region src/adapters/warn-missing-env.ts
305
+ /**
306
+ * M2.4 — Loud first-call warning for missing adapter env vars.
307
+ *
308
+ * Pre-M2.4 the adapter `revalidate()` methods returned `{ regenerated: false }`
309
+ * silently in production when required env vars were missing. The dev-mode
310
+ * warning was gated on `process.env.NODE_ENV !== 'production'` — exactly the
311
+ * env condition that DEPLOYED apps run under, where users would most need
312
+ * the signal. Symptom: CMS triggers `adapter.revalidate(path)`, nothing
313
+ * happens, no console output, no failure mode reported back to the
314
+ * triggering webhook handler. The bug only surfaces when someone notices
315
+ * stale content.
316
+ *
317
+ * Fix: warn ALWAYS (regardless of NODE_ENV) on the FIRST invocation per
318
+ * `(adapterName + varSet)` combination. Dedupe via a module-level Set so
319
+ * a busy revalidation handler doesn't spam logs — one warn per process
320
+ * per missing-env-set is enough to expose the misconfiguration.
321
+ *
322
+ * Returns the canonical `{ regenerated: false }` so adapters can write
323
+ * `return warnMissingEnv(...)` as a one-liner.
324
+ *
325
+ * @internal Exposed for unit tests via `_internal.warnMissingEnv` (not yet wired) + `_warnedKeys` reset.
326
+ */
327
+ const _warnedKeys = /* @__PURE__ */ new Set();
328
+ function warnMissingEnv(adapterName, missingVars, hint) {
329
+ const key = `${adapterName}:${missingVars.join(",")}`;
330
+ if (!_warnedKeys.has(key)) {
331
+ _warnedKeys.add(key);
332
+ console.warn(`[Pyreon] ${adapterName}Adapter.revalidate() needs ${missingVars.join(" + ")} env var(s). ${hint}`);
333
+ }
334
+ return { regenerated: false };
335
+ }
336
+
337
+ //#endregion
338
+ //#region src/adapters/cloudflare.ts
339
+ /**
340
+ * Cloudflare Pages adapter — generates output for Cloudflare Pages with Functions.
341
+ *
342
+ * Produces:
343
+ * - Client assets in the output directory root (served as static)
344
+ * - `_worker.js` — Cloudflare Pages Function for SSR
345
+ *
346
+ * Note: Cloudflare Pages Functions have a ~1MB module size limit.
347
+ * For large apps, configure Vite's SSR build to bundle server code:
348
+ * `ssr: { noExternal: true }` in vite.config.ts.
349
+ *
350
+ * Deploy with: `npx wrangler pages deploy ./dist`
351
+ *
352
+ * @example
353
+ * ```ts
354
+ * // zero.config.ts
355
+ * import { defineConfig } from "@pyreon/zero"
356
+ *
357
+ * export default defineConfig({
358
+ * adapter: "cloudflare",
359
+ * })
360
+ * ```
361
+ */
362
+ function cloudflareAdapter() {
363
+ return {
364
+ name: "cloudflare",
365
+ async build(options) {
366
+ if (options.kind === "ssg") {
367
+ const { writeFile } = await import("node:fs/promises");
368
+ const { join } = await import("node:path");
369
+ await writeFile(join(options.outDir, "_routes.json"), JSON.stringify({
370
+ version: 1,
371
+ include: [],
372
+ exclude: ["/*"]
373
+ }, null, 2));
374
+ return;
375
+ }
376
+ await validateBuildInputs(options);
377
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
378
+ const { join } = await import("node:path");
379
+ const outDir = options.outDir;
380
+ await mkdir(outDir, { recursive: true });
381
+ await cp(options.clientOutDir, outDir, { recursive: true });
382
+ await cp(join(options.serverEntry, ".."), join(outDir, "_server"), { recursive: true });
383
+ const workerEntry = `
384
+ import handler from "./_server/entry-server.js"
385
+
386
+ export default {
387
+ async fetch(request, env, ctx) {
388
+ const url = new URL(request.url)
389
+
390
+ // Let Cloudflare serve static assets (files with extensions)
391
+ // This check is a fallback — Pages routes static files automatically
392
+ const ext = url.pathname.split(".").pop()
393
+ if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
394
+ // Cloudflare Pages handles static assets automatically via its asset binding
395
+ // Only reach here if the file doesn't exist — fall through to SSR
396
+ }
397
+
398
+ // SSR handler
399
+ try {
400
+ return await handler(request)
401
+ } catch (err) {
402
+ return new Response("Internal Server Error", { status: 500 })
403
+ }
404
+ },
405
+ }
406
+ `.trimStart();
407
+ await writeFile(join(outDir, "_worker.js"), workerEntry);
408
+ await writeFile(join(outDir, "_routes.json"), JSON.stringify({
409
+ version: 1,
410
+ include: ["/*"],
411
+ exclude: [
412
+ "/assets/*",
413
+ "/favicon.*",
414
+ "/site.webmanifest",
415
+ "/robots.txt",
416
+ "/sitemap.xml"
417
+ ]
418
+ }, null, 2));
419
+ },
420
+ async revalidate(path) {
421
+ const zoneId = process.env.CLOUDFLARE_ZONE_ID;
422
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN;
423
+ const siteUrl = process.env.CLOUDFLARE_SITE_URL;
424
+ if (!zoneId || !apiToken || !siteUrl) {
425
+ const missing = [];
426
+ if (!zoneId) missing.push("CLOUDFLARE_ZONE_ID");
427
+ if (!apiToken) missing.push("CLOUDFLARE_API_TOKEN");
428
+ if (!siteUrl) missing.push("CLOUDFLARE_SITE_URL");
429
+ return warnMissingEnv("cloudflare", missing, "Set them in Cloudflare Pages dashboard → Settings → Environment Variables. Note: Cloudflare imposes a 1000-purge-per-24h rate limit per zone — high-frequency revalidation will hit it.");
430
+ }
431
+ const fullUrl = `${siteUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
432
+ try {
433
+ return { regenerated: (await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
434
+ method: "POST",
435
+ headers: {
436
+ "Authorization": `Bearer ${apiToken}`,
437
+ "Content-Type": "application/json"
438
+ },
439
+ body: JSON.stringify({ files: [fullUrl] })
440
+ })).ok };
441
+ } catch (err) {
442
+ if (process.env.NODE_ENV !== "production") console.warn(`[Pyreon] cloudflareAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`);
443
+ return { regenerated: false };
444
+ }
445
+ }
446
+ };
447
+ }
448
+
449
+ //#endregion
450
+ //#region src/adapters/netlify.ts
451
+ /**
452
+ * Netlify adapter — generates output for Netlify Functions (v2).
453
+ *
454
+ * Produces:
455
+ * - Client assets in `publish/` directory
456
+ * - `netlify/functions/ssr.mjs` — Netlify Function for SSR
457
+ * - `netlify.toml` — routing configuration
458
+ *
459
+ * @example
460
+ * ```ts
461
+ * // zero.config.ts
462
+ * import { defineConfig } from "@pyreon/zero"
463
+ *
464
+ * export default defineConfig({
465
+ * adapter: "netlify",
466
+ * })
467
+ * ```
468
+ */
469
+ function netlifyAdapter() {
470
+ return {
471
+ name: "netlify",
472
+ async build(options) {
473
+ if (options.kind === "ssg") {
474
+ const { writeFile } = await import("node:fs/promises");
475
+ const { join } = await import("node:path");
476
+ await writeFile(join(options.outDir, "netlify.toml"), `[build]
477
+ publish = "."
478
+
479
+ [[headers]]
480
+ for = "/assets/*"
481
+ [headers.values]
482
+ Cache-Control = "public, max-age=31536000, immutable"
483
+ `);
484
+ return;
485
+ }
486
+ await validateBuildInputs(options);
487
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
488
+ const { join } = await import("node:path");
489
+ const outDir = options.outDir;
490
+ const publishDir = join(outDir, "publish");
491
+ const functionsDir = join(outDir, "netlify", "functions");
492
+ await mkdir(publishDir, { recursive: true });
493
+ await mkdir(functionsDir, { recursive: true });
494
+ await cp(options.clientOutDir, publishDir, { recursive: true });
495
+ await cp(join(options.serverEntry, ".."), join(functionsDir, "_server"), { recursive: true });
496
+ const funcEntry = `
497
+ import handler from "./_server/entry-server.js"
498
+
499
+ export default async function(req, context) {
500
+ try {
501
+ return await handler(req)
502
+ } catch (err) {
503
+ return new Response("Internal Server Error", { status: 500 })
504
+ }
505
+ }
506
+
507
+ export const config = {
508
+ path: "/*",
509
+ preferStatic: true,
510
+ }
511
+ `.trimStart();
512
+ await writeFile(join(functionsDir, "ssr.mjs"), funcEntry);
513
+ const toml = `
514
+ [build]
515
+ publish = "publish"
516
+ functions = "netlify/functions"
517
+
518
+ [[headers]]
519
+ for = "/assets/*"
520
+ [headers.values]
521
+ Cache-Control = "public, max-age=31536000, immutable"
522
+
523
+ [[redirects]]
524
+ from = "/*"
525
+ to = "/.netlify/functions/ssr"
526
+ status = 200
527
+ conditions = {Role = ["admin", "user", ""]}
528
+ `.trimStart();
529
+ await writeFile(join(outDir, "netlify.toml"), toml);
530
+ },
531
+ async revalidate(path) {
532
+ const hookUrl = process.env.NETLIFY_BUILD_HOOK_URL;
533
+ if (!hookUrl) return warnMissingEnv("netlify", ["NETLIFY_BUILD_HOOK_URL"], "Create a build hook in Site settings → Build & deploy → Build hooks → Add build hook. Note: Netlify Build Hooks trigger a FULL site rebuild — the path arg is recorded as `trigger_title` for audit traceability but Netlify doesn't support per-page ISR natively.");
534
+ try {
535
+ const triggerTitle = `revalidate:${path}`;
536
+ return { regenerated: (await fetch(`${hookUrl}?trigger_title=${encodeURIComponent(triggerTitle)}`, { method: "POST" })).ok };
537
+ } catch (err) {
538
+ if (process.env.NODE_ENV !== "production") console.warn(`[Pyreon] netlifyAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`);
539
+ return { regenerated: false };
540
+ }
541
+ }
542
+ };
543
+ }
544
+
545
+ //#endregion
546
+ //#region src/adapters/node.ts
547
+ /**
548
+ * Node.js adapter — generates a standalone server entry using node:http.
549
+ *
550
+ * **SSG mode (PR J)**: no-op. Node adapter exists for serving the SSR
551
+ * runtime; SSG output is already complete static HTML — serve it with
552
+ * any static-file server (`bun preview` / nginx / Caddy / `npx serve`).
553
+ * Use `staticAdapter()` if you want explicit SSG semantics.
554
+ */
555
+ function nodeAdapter() {
556
+ return {
557
+ name: "node",
558
+ async build(options) {
559
+ if (options.kind === "ssg") return;
560
+ await validateBuildInputs(options);
561
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
562
+ const { join } = await import("node:path");
563
+ const outDir = options.outDir;
564
+ await mkdir(outDir, { recursive: true });
565
+ await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
566
+ await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
567
+ const port = options.config.port ?? 3e3;
568
+ const serverEntry = `
569
+ import { createServer } from "node:http"
570
+ import { readFile } from "node:fs/promises"
571
+ import { join, extname } from "node:path"
572
+ import { fileURLToPath } from "node:url"
573
+
574
+ const __dirname = fileURLToPath(new URL(".", import.meta.url))
575
+ const handler = (await import("./server/entry-server.js")).default
576
+ const clientDir = join(__dirname, "client")
577
+
578
+ const MIME_TYPES = {
579
+ ".html": "text/html",
580
+ ".js": "application/javascript",
581
+ ".css": "text/css",
582
+ ".json": "application/json",
583
+ ".png": "image/png",
584
+ ".jpg": "image/jpeg",
585
+ ".svg": "image/svg+xml",
586
+ ".woff2": "font/woff2",
587
+ ".woff": "font/woff",
588
+ ".ico": "image/x-icon",
589
+ }
590
+
591
+ const server = createServer(async (req, res) => {
592
+ const url = new URL(req.url ?? "/", "http://localhost")
593
+
594
+ // Try to serve static files first
595
+ if (req.method === "GET") {
596
+ try {
597
+ const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
598
+ // Prevent path traversal — ensure resolved path stays within clientDir
599
+ const { resolve } = await import("node:path")
600
+ const resolved = resolve(filePath)
601
+ if (!resolved.startsWith(resolve(clientDir))) {
602
+ res.writeHead(403)
603
+ res.end("Forbidden")
604
+ return
605
+ }
606
+ const ext = extname(filePath)
607
+ if (ext && ext !== ".html") {
608
+ const data = await readFile(filePath)
609
+ const mime = MIME_TYPES[ext] || "application/octet-stream"
610
+ res.writeHead(200, {
611
+ "content-type": mime,
612
+ "cache-control": ext === ".js" || ext === ".css"
613
+ ? "public, max-age=31536000, immutable"
614
+ : "public, max-age=3600",
615
+ })
616
+ res.end(data)
617
+ return
618
+ }
619
+ } catch {}
620
+ }
621
+
622
+ // Fall through to SSR handler
623
+ const headers = {}
624
+ for (const [key, value] of Object.entries(req.headers)) {
625
+ if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
626
+ }
627
+
628
+ const request = new Request(url.href, {
629
+ method: req.method,
630
+ headers,
631
+ })
632
+
633
+ const response = await handler(request)
634
+ const body = await response.text()
635
+
636
+ const responseHeaders = {}
637
+ response.headers.forEach((v, k) => { responseHeaders[k] = v })
638
+
639
+ res.writeHead(response.status, responseHeaders)
640
+ res.end(body)
641
+ })
642
+
643
+ server.listen(${port}, () => {
644
+ console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
645
+ })
646
+ `.trimStart();
647
+ await writeFile(join(outDir, "index.js"), serverEntry);
648
+ await writeFile(join(outDir, "package.json"), JSON.stringify({ type: "module" }, null, 2));
649
+ },
650
+ async revalidate(_path) {
651
+ if (process.env.NODE_ENV !== "production") console.warn("[Pyreon] nodeAdapter.revalidate() is a no-op — self-hosted Node has no platform-driven ISR. Use mode: \"isr\" for runtime LRU caching, or vercelAdapter / cloudflareAdapter / netlifyAdapter for platform-driven build-time ISR.");
652
+ return { regenerated: false };
653
+ }
654
+ };
655
+ }
656
+
657
+ //#endregion
658
+ //#region src/adapters/static.ts
659
+ /**
660
+ * Static adapter — just copies the client build output.
661
+ * Used with SSG mode where all pages are pre-rendered at build time.
662
+ *
663
+ * **SSG mode (PR J)**: no-op — `outDir` already IS the dist directory
664
+ * the SSG plugin produced. Copying it onto itself would only fail. The
665
+ * static adapter is the canonical zero-overhead deploy target for
666
+ * pure-static sites.
667
+ *
668
+ * **SSR mode**: copies clientOutDir → outDir. Calling `static` with SSR
669
+ * mode is unusual — the static adapter doesn't support server-side
670
+ * execution — but preserved as a "client-only output packager".
671
+ */
672
+ function staticAdapter() {
673
+ return {
674
+ name: "static",
675
+ async build(options) {
676
+ if (options.kind === "ssg") return;
677
+ const { cp, mkdir } = await import("node:fs/promises");
678
+ await mkdir(options.outDir, { recursive: true });
679
+ await cp(options.clientOutDir, options.outDir, { recursive: true });
680
+ },
681
+ async revalidate(_path) {
682
+ if (process.env.NODE_ENV !== "production") console.warn("[Pyreon] staticAdapter.revalidate() is a no-op — static hosts require a full rebuild + redeploy to refresh prerendered pages. Use vercelAdapter / cloudflareAdapter / netlifyAdapter for platform-driven ISR.");
683
+ return { regenerated: false };
684
+ }
685
+ };
686
+ }
687
+
688
+ //#endregion
689
+ //#region src/adapters/vercel.ts
690
+ /**
691
+ * Vercel adapter — generates output for Vercel's Build Output API v3.
692
+ *
693
+ * Produces a `.vercel/output` directory with:
694
+ * - `static/` — client-side assets (JS, CSS, images)
695
+ * - `functions/ssr.func/` — serverless function for SSR
696
+ * - `config.json` — routing configuration
697
+ *
698
+ * @example
699
+ * ```ts
700
+ * // zero.config.ts
701
+ * import { defineConfig } from "@pyreon/zero"
702
+ *
703
+ * export default defineConfig({
704
+ * adapter: "vercel",
705
+ * })
706
+ * ```
707
+ */
708
+ function vercelAdapter() {
709
+ return {
710
+ name: "vercel",
711
+ async build(options) {
712
+ if (options.kind === "ssg") {
713
+ const { writeFile, mkdir } = await import("node:fs/promises");
714
+ const { join } = await import("node:path");
715
+ const vercelDir = join(options.outDir, ".vercel", "output");
716
+ await mkdir(vercelDir, { recursive: true });
717
+ await writeFile(join(vercelDir, "config.json"), JSON.stringify({
718
+ version: 3,
719
+ routes: [{
720
+ src: "/assets/(.*)",
721
+ headers: { "Cache-Control": "public, max-age=31536000, immutable" }
722
+ }]
723
+ }, null, 2));
724
+ return;
725
+ }
726
+ await validateBuildInputs(options);
727
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
728
+ const { join } = await import("node:path");
729
+ const vercelDir = join(options.outDir, ".vercel", "output");
730
+ const staticDir = join(vercelDir, "static");
731
+ const funcDir = join(vercelDir, "functions", "ssr.func");
732
+ await mkdir(staticDir, { recursive: true });
733
+ await mkdir(funcDir, { recursive: true });
734
+ await cp(options.clientOutDir, staticDir, { recursive: true });
735
+ await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
736
+ const funcEntry = `
737
+ export default async function handler(req) {
738
+ const handler = (await import("./entry-server.js")).default
739
+ return handler(req)
740
+ }
741
+ `.trimStart();
742
+ await writeFile(join(funcDir, "index.js"), funcEntry);
743
+ await writeFile(join(funcDir, ".vc-config.json"), JSON.stringify({
744
+ runtime: "nodejs20.x",
745
+ handler: "index.js",
746
+ launcherType: "Nodejs"
747
+ }, null, 2));
748
+ await writeFile(join(vercelDir, "config.json"), JSON.stringify({
749
+ version: 3,
750
+ routes: [
751
+ {
752
+ src: "/assets/(.*)",
753
+ headers: { "Cache-Control": "public, max-age=31536000, immutable" }
754
+ },
755
+ {
756
+ src: "/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)",
757
+ dest: "/$1"
758
+ },
759
+ {
760
+ src: "/(.*)",
761
+ dest: "/ssr"
762
+ }
763
+ ]
764
+ }, null, 2));
765
+ },
766
+ async revalidate(path) {
767
+ const deploymentUrl = process.env.VERCEL_DEPLOYMENT_URL ?? process.env.VERCEL_URL;
768
+ const token = process.env.VERCEL_REVALIDATE_TOKEN;
769
+ if (!deploymentUrl || !token) {
770
+ const missing = [];
771
+ if (!deploymentUrl) missing.push("VERCEL_DEPLOYMENT_URL (or VERCEL_URL)");
772
+ if (!token) missing.push("VERCEL_REVALIDATE_TOKEN");
773
+ return warnMissingEnv("vercel", missing, "Set the token in Vercel project settings → Environment Variables. VERCEL_DEPLOYMENT_URL / VERCEL_URL is auto-injected by the Vercel runtime.");
774
+ }
775
+ const url = `${deploymentUrl.startsWith("http") ? "" : "https://"}${deploymentUrl}/api/_pyreon-revalidate?path=${encodeURIComponent(path)}&secret=${encodeURIComponent(token)}`;
776
+ try {
777
+ return { regenerated: (await fetch(url, { method: "POST" })).ok };
778
+ } catch (err) {
779
+ if (process.env.NODE_ENV !== "production") console.warn(`[Pyreon] vercelAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`);
780
+ return { regenerated: false };
781
+ }
782
+ }
783
+ };
784
+ }
785
+
786
+ //#endregion
787
+ //#region src/adapters/index.ts
788
+ /**
789
+ * Resolve the adapter from config.
790
+ * Returns a built-in adapter or throws if unknown.
791
+ *
792
+ * Accepts BOTH forms — the `ZeroConfig.adapter` type advertises string
793
+ * names (`'vercel'` / `'cloudflare'` / …) but the scaffolded templates
794
+ * historically emit `adapter: vercelAdapter()` (an Adapter instance via
795
+ * the named factory). Both work: a string goes through the switch lookup;
796
+ * an Adapter object (duck-typed via `name` + `build` fields) passes
797
+ * through. Pre-PR-J `resolveAdapter` was never called from production
798
+ * code so the string-vs-object mismatch was invisible; PR J wires the
799
+ * call into `ssgPlugin.closeBundle`, surfacing the contract divergence.
800
+ * The passthrough preserves both shapes without a breaking type change.
801
+ */
802
+ function resolveAdapter(config) {
803
+ const value = config.adapter ?? "node";
804
+ if (typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.build === "function") return value;
805
+ switch (value) {
806
+ case "node": return nodeAdapter();
807
+ case "bun": return bunAdapter();
808
+ case "static": return staticAdapter();
809
+ case "vercel": return vercelAdapter();
810
+ case "cloudflare": return cloudflareAdapter();
811
+ case "netlify": return netlifyAdapter();
812
+ default: throw new Error(`[Pyreon] Unknown adapter: "${String(value)}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`);
813
+ }
814
+ }
815
+
816
+ //#endregion
817
+ //#region src/error-overlay.ts
818
+ /**
819
+ * Dev-only error overlay for SSR/loader errors.
820
+ * Renders a styled HTML page with the error stack trace.
821
+ */
822
+ function renderErrorOverlay(error) {
823
+ return `<!DOCTYPE html>
824
+ <html lang="en">
825
+ <head>
826
+ <meta charset="UTF-8">
827
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
828
+ <title>SSR Error — Pyreon Zero</title>
829
+ <style>
830
+ * { margin: 0; padding: 0; box-sizing: border-box; }
831
+ body {
832
+ font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
833
+ background: #1a1a2e;
834
+ color: #e0e0e0;
835
+ min-height: 100vh;
836
+ padding: 2rem;
837
+ }
838
+ .overlay {
839
+ max-width: 900px;
840
+ margin: 0 auto;
841
+ }
842
+ .header {
843
+ display: flex;
844
+ align-items: center;
845
+ gap: 0.75rem;
846
+ margin-bottom: 1.5rem;
847
+ }
848
+ .badge {
849
+ background: #e74c3c;
850
+ color: white;
851
+ padding: 0.25rem 0.75rem;
852
+ border-radius: 4px;
853
+ font-size: 0.75rem;
854
+ font-weight: 600;
855
+ text-transform: uppercase;
856
+ letter-spacing: 0.05em;
857
+ }
858
+ .label {
859
+ color: #888;
860
+ font-size: 0.85rem;
861
+ }
862
+ .message {
863
+ font-size: 1.25rem;
864
+ color: #ff6b6b;
865
+ margin-bottom: 1.5rem;
866
+ line-height: 1.5;
867
+ word-break: break-word;
868
+ }
869
+ .stack {
870
+ background: #16213e;
871
+ border: 1px solid #2a2a4a;
872
+ border-radius: 8px;
873
+ padding: 1.25rem;
874
+ overflow-x: auto;
875
+ font-size: 0.8rem;
876
+ line-height: 1.7;
877
+ white-space: pre-wrap;
878
+ word-break: break-all;
879
+ }
880
+ .stack .at { color: #888; }
881
+ .stack .file { color: #4ecdc4; }
882
+ .hint {
883
+ margin-top: 1.5rem;
884
+ padding: 1rem;
885
+ background: #1e2a45;
886
+ border-radius: 6px;
887
+ border-left: 3px solid #3498db;
888
+ font-size: 0.8rem;
889
+ color: #aaa;
890
+ line-height: 1.5;
891
+ }
892
+ </style>
893
+ </head>
894
+ <body>
895
+ <div class="overlay">
896
+ <div class="header">
897
+ <span class="badge">SSR Error</span>
898
+ <span class="label">Pyreon Zero — Dev Mode</span>
899
+ </div>
900
+ <div class="message">${escapeHtml(error.message || "Unknown error")}</div>
901
+ <pre class="stack">${formatStack(escapeHtml(error.stack || ""))}</pre>
902
+ <div class="hint">
903
+ This error occurred during server-side rendering. Check the terminal for
904
+ the full stack trace. This overlay is only shown in development.
905
+ </div>
906
+ </div>
907
+ </body>
908
+ </html>`;
909
+ }
910
+ function escapeHtml(str) {
911
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
912
+ }
913
+ function formatStack(stack) {
914
+ return stack.split("\n").map((line) => {
915
+ if (line.includes("at ")) {
916
+ const fileMatch = line.match(/\(([^)]+)\)/);
917
+ if (fileMatch) return line.replace(fileMatch[0], `(<span class="file">${fileMatch[1]}</span>)`);
918
+ }
919
+ return line;
920
+ }).join("\n");
921
+ }
922
+
923
+ //#endregion
924
+ //#region src/i18n-routing.ts
925
+ /**
926
+ * Detect preferred locale from Accept-Language header.
927
+ */
928
+ function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
929
+ if (!acceptLanguage) return defaultLocale;
930
+ const preferred = acceptLanguage.split(",").map((part) => {
931
+ const [lang, q] = part.trim().split(";q=");
932
+ return {
933
+ lang: lang?.split("-")[0]?.toLowerCase() ?? "",
934
+ quality: q ? Number.parseFloat(q) : 1
935
+ };
936
+ }).sort((a, b) => b.quality - a.quality);
937
+ for (const { lang } of preferred) if (locales.includes(lang)) return lang;
938
+ return defaultLocale;
939
+ }
940
+ /**
941
+ * Extract locale from a URL path.
942
+ * Returns { locale, pathWithoutLocale }.
943
+ */
944
+ function extractLocaleFromPath(path, locales, defaultLocale) {
945
+ const segments = path.split("/").filter(Boolean);
946
+ const firstSegment = segments[0]?.toLowerCase();
947
+ if (firstSegment && locales.includes(firstSegment)) return {
948
+ locale: firstSegment,
949
+ pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
950
+ };
951
+ return {
952
+ locale: defaultLocale,
953
+ pathWithoutLocale: path
954
+ };
955
+ }
956
+ /**
957
+ * Build a localized path.
958
+ */
959
+ function buildLocalePath(path, locale, defaultLocale, strategy) {
960
+ const clean = path === "/" ? "" : path;
961
+ if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
962
+ return `/${locale}${clean}`;
963
+ }
964
+ /**
965
+ * Fan a `FileRoute[]` into per-locale duplicates so the file-system router
966
+ * knows about every localized URL pattern at build time. PR H — was the
967
+ * missing half of the i18n story before this PR (the `i18nRouting()` Vite
968
+ * plugin only handled request-time locale detection; routes themselves
969
+ * were never duplicated, so static-host SSG outputs and SSR matching had
970
+ * no `/de/about` / `/cs/about` records to render against).
971
+ *
972
+ * Strategy semantics:
973
+ *
974
+ * - **`prefix-except-default`** (default): the default locale's routes
975
+ * keep their original `urlPath` unchanged (`/about` stays `/about`); all
976
+ * non-default locales get a prefix (`/de/about`, `/cs/about`). Best for
977
+ * SEO-on-default-locale apps — search engines see canonical URLs at
978
+ * `/about` while non-default speakers get explicit prefixes.
979
+ *
980
+ * - **`prefix`**: every locale gets its own prefix, including the default
981
+ * (`/en/about`, `/de/about`, `/cs/about`). Root `/` becomes `/en` /
982
+ * `/de` / `/cs`. Better when no locale is "primary" — every URL
983
+ * self-identifies its locale.
984
+ *
985
+ * Layouts, error boundaries, loading components, and 404 pages duplicate
986
+ * along with their pages — same source file (same `filePath`), new
987
+ * locale-prefixed `urlPath` / `dirPath` / `depth`. The route tree built
988
+ * from the expanded array therefore has one fully-formed subtree per
989
+ * locale, so layout matching, dynamic params (`[id]` → `:id`), and
990
+ * catch-all routes (`[...slug]` → `:slug*`) all compose naturally with
991
+ * the locale prefix — no special cases.
992
+ *
993
+ * `getStaticPaths` composition (for SSG): each duplicate route inherits
994
+ * the same `exports.getStaticPaths`. The SSG plugin's `expandUrlPattern`
995
+ * step then expands `/blog/[slug]` × `[en, de]` × `getStaticPaths()
996
+ * → ['a', 'b']` into `/blog/a`, `/blog/b`, `/de/blog/a`, `/de/blog/b`
997
+ * (or all six prefixed forms under `'prefix'` strategy). Cardinality
998
+ * compounds, which is by design — `ssg.concurrency` (PR D) limits
999
+ * in-flight renders independent of route count.
1000
+ *
1001
+ * No-op when `config.locales` is empty or contains only the default
1002
+ * locale (prefix-except-default strategy with no other locales) — returns
1003
+ * the input array unchanged. Always return a fresh array on duplication
1004
+ * so callers don't accidentally mutate cached input.
1005
+ *
1006
+ * Reference: the helper is called from `vite-plugin.ts`'s virtual route
1007
+ * module load AND `ssg-plugin.ts`'s pre-render path expansion. Tested in
1008
+ * isolation — duplication is a pure transform on FileRoute[] with no
1009
+ * filesystem or network side effects.
1010
+ */
1011
+ function expandRoutesForLocales(routes, config) {
1012
+ const strategy = config.strategy ?? "prefix-except-default";
1013
+ const { locales, defaultLocale } = config;
1014
+ if (locales.length === 0) return routes;
1015
+ for (const locale of locales) validateLocale(locale);
1016
+ validateLocale(defaultLocale);
1017
+ if (strategy === "prefix-except-default" && locales.length === 1 && locales[0] === defaultLocale) return routes;
1018
+ const expanded = [];
1019
+ for (const route of routes) for (const locale of locales) {
1020
+ if (strategy === "prefix-except-default" && locale === defaultLocale) {
1021
+ expanded.push(route);
1022
+ continue;
1023
+ }
1024
+ if (strategy === "prefix-except-default" && route.isLayout && route.urlPath === "/") continue;
1025
+ const newUrlPath = prefixUrlPath(route.urlPath, locale);
1026
+ const newDirPath = route.dirPath === "" ? locale : `${locale}/${route.dirPath}`;
1027
+ const newDepth = newUrlPath === "/" ? 0 : newUrlPath.split("/").filter(Boolean).length;
1028
+ expanded.push({
1029
+ ...route,
1030
+ urlPath: newUrlPath,
1031
+ dirPath: newDirPath,
1032
+ depth: newDepth
1033
+ });
1034
+ }
1035
+ return expanded;
1036
+ }
1037
+ /**
1038
+ * Prepend `/locale` to a URL pattern. Handles three shapes:
1039
+ * `/` → `/de`
1040
+ * `/about` → `/de/about`
1041
+ * `/users/:id` / `/blog/:slug*` → `/de/users/:id` / `/de/blog/:slug*`
1042
+ *
1043
+ * Internal helper to `expandRoutesForLocales`; not exported because the
1044
+ * public surface for path-building is `buildLocalePath` (which strips
1045
+ * existing locale prefixes — different semantics).
1046
+ */
1047
+ function prefixUrlPath(urlPath, locale) {
1048
+ if (urlPath === "/") return `/${locale}`;
1049
+ return `/${locale}${urlPath}`;
1050
+ }
1051
+ /**
1052
+ * Validate a locale string (PR L2).
1053
+ *
1054
+ * The locale drives both URL pattern emission AND filesystem writes
1055
+ * (see `expandRoutesForLocales` for full rationale). Reject input that
1056
+ * would either:
1057
+ * - break path-traversal boundaries (`..`, `/`, `\`)
1058
+ * - produce invalid URL segments (whitespace, NUL)
1059
+ * - create hidden-file artifacts (`.` leading)
1060
+ * - silently kill the app (empty string)
1061
+ *
1062
+ * Throws with an actionable `[Pyreon]` error message. Called per-locale
1063
+ * by `expandRoutesForLocales` after the empty-locales no-op guard.
1064
+ *
1065
+ * @internal — exported for unit testing.
1066
+ */
1067
+ function validateLocale(locale) {
1068
+ if (typeof locale !== "string" || locale === "") throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Locales must be non-empty strings (e.g. "en", "de", "en-US").`);
1069
+ if (locale.trim() !== locale) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading or trailing whitespace not allowed.`);
1070
+ if (locale.includes("/") || locale.includes("\\")) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path separators ("/", "\\\\") not allowed — they would break URL emission and could write outside the dist directory.`);
1071
+ if (locale === ".." || locale === ".") throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path-traversal segments not allowed.`);
1072
+ if (locale.startsWith(".")) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading dot not allowed — it would create a hidden-file directory (\`dist/.${locale.slice(1)}/\`) invisible to most file listings.`);
1073
+ if (locale.includes("\0")) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. NUL characters not allowed.`);
1074
+ }
1075
+ /**
1076
+ * Create a LocaleContext for use in components and loaders.
1077
+ */
1078
+ function createLocaleContext(locale, path, config) {
1079
+ const strategy = config.strategy ?? "prefix-except-default";
1080
+ return {
1081
+ locale,
1082
+ locales: config.locales,
1083
+ defaultLocale: config.defaultLocale,
1084
+ localePath(targetPath, targetLocale) {
1085
+ return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
1086
+ },
1087
+ alternates() {
1088
+ const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
1089
+ return config.locales.map((loc) => ({
1090
+ locale: loc,
1091
+ url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
1092
+ }));
1093
+ }
1094
+ };
1095
+ }
1096
+ /**
1097
+ * I18n routing middleware for Zero's server.
1098
+ *
1099
+ * - Detects locale from URL prefix or Accept-Language header
1100
+ * - Redirects root to preferred locale (when detectLocale is true)
1101
+ * - Sets locale context for loaders and components
1102
+ *
1103
+ * @example
1104
+ * ```ts
1105
+ * // zero.config.ts
1106
+ * import { i18nRouting } from "@pyreon/zero"
1107
+ *
1108
+ * export default defineConfig({
1109
+ * plugins: [
1110
+ * i18nRouting({
1111
+ * locales: ["en", "de", "cs"],
1112
+ * defaultLocale: "en",
1113
+ * }),
1114
+ * ],
1115
+ * })
1116
+ * ```
1117
+ */
1118
+ function i18nRouting(config) {
1119
+ const strategy = config.strategy ?? "prefix-except-default";
1120
+ const detectEnabled = config.detectLocale !== false;
1121
+ const cookieName = config.cookieName ?? "locale";
1122
+ return {
1123
+ name: "pyreon-zero-i18n-routing",
1124
+ configResolved() {},
1125
+ configureServer(server) {
1126
+ server.middlewares.use((req, res, next) => {
1127
+ const url = req.url ?? "/";
1128
+ if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
1129
+ const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
1130
+ if (detectEnabled && url === "/") {
1131
+ const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
1132
+ const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
1133
+ const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
1134
+ if (strategy === "prefix" || preferred !== config.defaultLocale) {
1135
+ res.writeHead(302, { Location: `/${preferred}/` });
1136
+ res.end();
1137
+ return;
1138
+ }
1139
+ }
1140
+ req.__locale = locale;
1141
+ req.__localeContext = createLocaleContext(locale, url, config);
1142
+ localeSignal.set(locale);
1143
+ next();
1144
+ });
1145
+ }
1146
+ };
1147
+ }
1148
+ function parseCookies(header) {
1149
+ if (!header) return {};
1150
+ const result = {};
1151
+ for (const pair of header.split(";")) {
1152
+ const [key, value] = pair.trim().split("=");
1153
+ if (key && value) result[key] = decodeURIComponent(value);
1154
+ }
1155
+ return result;
1156
+ }
1157
+ /** @internal Context for the current locale. */
1158
+ const LocaleCtx = createContext("en");
1159
+ /** Current locale signal — set by the server middleware or client-side detection. */
1160
+ const localeSignal = signal("en");
1161
+
1162
+ //#endregion
1163
+ //#region src/ssg-plugin.ts
1164
+ /**
1165
+ * SSG (Static Site Generation) build hook for `@pyreon/zero`.
1166
+ *
1167
+ * Activates when `mode: "ssg"` is set in zero's config. After Vite's client
1168
+ * build finishes, this plugin:
1169
+ *
1170
+ * 1. Triggers a programmatic SSR build via Vite's `build()` API, producing
1171
+ * a server bundle in `dist/.zero-ssg-server/` from a synthetic entry
1172
+ * that imports `virtual:zero/routes` and `createServer`.
1173
+ * 2. Loads the built handler with dynamic `import()`.
1174
+ * 3. Resolves the path list from `config.ssg.paths` (string[], async fn,
1175
+ * or auto-detected from the static-only routes in the route tree).
1176
+ * 4. Calls `prerender()` from `@pyreon/server` to render each path.
1177
+ * 5. Cleans up the temporary SSR build directory.
1178
+ *
1179
+ * Before this PR, `mode: "ssg"` and `ssg.paths` were typed in
1180
+ * `types.ts` but had no runtime implementation — the plugin file had zero
1181
+ * Rollup build hooks. Apps configured for SSG silently shipped a bare SPA
1182
+ * shell with no per-route HTML files, which broke direct-URL deploys to
1183
+ * static hosts (no `dist/<path>/index.html`, every URL falls back to the
1184
+ * SPA index).
1185
+ */
1186
+ const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
1187
+ const _countSink = globalThis;
1188
+ const SSG_BUILD_FLAG = "PYREON_ZERO_SSG_INNER_BUILD";
1189
+ const renderSsrEntrySource = (locales = []) => {
1190
+ return `
1191
+ import { routes } from "virtual:zero/routes"
1192
+ import { h } from "@pyreon/core"
1193
+ import { renderWithHead } from "@pyreon/head/ssr"
1194
+ import { getRedirectInfo, serializeLoaderData, stringifyLoaderData } from "@pyreon/router"
1195
+ import { runWithRequestContext } from "@pyreon/runtime-server"
1196
+ import { createApp } from "@pyreon/zero/server"
1197
+
1198
+ // Lazy-imported styler integration. Projects that don't depend on
1199
+ // @pyreon/styler skip this entirely (the import fails silently, the
1200
+ // helper stays a no-op). Hot path: an awaited dynamic import resolved
1201
+ // once at entry-module evaluation, then sync calls per request.
1202
+ //
1203
+ // **No reset between paths.** @pyreon/styler's styled() inserts CSS
1204
+ // rules into sheet.ssrBuffer at MODULE-EVAL TIME (top-level of styled.ts:95),
1205
+ // not per-render. After that initial insert, each render of a styled
1206
+ // component just attaches the cached class name to props — no new buffer
1207
+ // push. Calling sheet.reset() between SSG paths would WIPE all rules and
1208
+ // leave subsequent pages style-less. For SSG this is acceptable: the
1209
+ // generated CSS is identical across all pages (same module-eval cache),
1210
+ // and shipping the full rule set in every page's <style> tag matches
1211
+ // how static SSG sites handle CSS — every page is self-contained,
1212
+ // cacheable by the browser, no per-route CSS code splitting needed.
1213
+ let __pyreonGetStylerTag = () => ""
1214
+ try {
1215
+ const stylerMod = await import("@pyreon/styler")
1216
+ if (stylerMod && stylerMod.sheet && typeof stylerMod.sheet.getStyleTag === "function") {
1217
+ __pyreonGetStylerTag = () => stylerMod.sheet.getStyleTag()
1218
+ }
1219
+ } catch {
1220
+ // No @pyreon/styler in the project — leave the no-op stub in place.
1221
+ }
1222
+
1223
+ // PR E — \`__ZERO_BASE__\` is the Vite-defined build-time constant
1224
+ // carrying the value of \`zero({ base })\`. Read it once at module
1225
+ // eval and forward to createRouter via createApp so SSG-rendered
1226
+ // pages have correctly-prefixed RouterLink hrefs that match the
1227
+ // asset URLs Vite already prefixed in the built HTML template.
1228
+ const __ssgBase = typeof __ZERO_BASE__ !== "undefined" && __ZERO_BASE__ !== "/"
1229
+ ? __ZERO_BASE__
1230
+ : undefined
1231
+
1232
+ export default async function renderPath(path, options) {
1233
+ const { App, router } = createApp({
1234
+ routes,
1235
+ routerMode: "history",
1236
+ url: path,
1237
+ ...(__ssgBase ? { base: __ssgBase } : {}),
1238
+ })
1239
+
1240
+ // PR B — redirect handling. \`router.preload\` runs every loader in the
1241
+ // matched chain and surfaces \`redirect()\` throws as the rejection
1242
+ // reason. We catch BEFORE the render: rendering past a redirect would
1243
+ // produce HTML for the wrong page AND leak the auth-gated layout
1244
+ // structure for unauthenticated users. The runtime SSR handler
1245
+ // (createHandler in @pyreon/server) already does this same catch and
1246
+ // returns a 302/307 Location response; SSG mirrors that — return
1247
+ // \`{ kind: 'redirect', from, to, status }\` instead of HTML, and the
1248
+ // outer plugin emits a redirect manifest entry instead of an
1249
+ // \`index.html\`. Any non-redirect error rethrows and lands in the
1250
+ // existing \`errors[]\` collection.
1251
+ //
1252
+ // PR C — \`isNotFound\` skips parent-layout loaders during the 404 build.
1253
+ // Layout loaders that hit auth resources (cookies, session tokens,
1254
+ // private APIs) shouldn't fire when generating a static 404 page —
1255
+ // the build has no real request context. Lazy components still
1256
+ // resolve so the synthetic chain renders cleanly; only the
1257
+ // \`r.loader()\` invocations are skipped. \`__renderNotFound\` below
1258
+ // forwards \`{ isNotFound: true }\` for this path.
1259
+ try {
1260
+ await router.preload(path, undefined, {
1261
+ skipLoaders: options?.isNotFound === true,
1262
+ })
1263
+ } catch (err) {
1264
+ const info = getRedirectInfo(err)
1265
+ if (info) {
1266
+ return { kind: "redirect", from: path, to: info.url, status: info.status }
1267
+ }
1268
+ throw err
1269
+ }
1270
+
1271
+ return runWithRequestContext(async () => {
1272
+ const app = h(App, null)
1273
+ const { html: appHtml, head } = await renderWithHead(app)
1274
+
1275
+ // Inject styler's <style data-pyreon-styler="..."> tag into the head
1276
+ // BEFORE @pyreon/head's tags so the CSS cascade orders correctly with
1277
+ // any meta/link tags the user added. Empty buffer emits a benign
1278
+ // empty <style></style> — detected via the literal closing pair to
1279
+ // avoid polluting the head when no styler is in use.
1280
+ const styleTag = __pyreonGetStylerTag()
1281
+ const isEmpty = !styleTag || styleTag.indexOf("></style>") !== -1
1282
+ const finalHead = isEmpty ? head : styleTag + "\\n" + head
1283
+
1284
+ const loaderData = serializeLoaderData(router)
1285
+ const hasData = loaderData && Object.keys(loaderData).length > 0
1286
+ // M2.2 — safe serializer drops function/symbol values, throws a clear
1287
+ // Pyreon-prefixed error on circular refs, escapes <\/script> uniformly.
1288
+ const loaderScript = hasData
1289
+ ? \`<script>window.__PYREON_LOADER_DATA__=\${stringifyLoaderData(loaderData)}<\/script>\`
1290
+ : ""
1291
+ return { kind: "html", appHtml, head: finalHead, loaderScript }
1292
+ })
1293
+ }
1294
+
1295
+ // ─── getStaticPaths enumeration (PR A) ──────────────────────────────────────
1296
+ //
1297
+ // Walks the generated routes tree and collects every dynamic route's
1298
+ // \`getStaticPaths\` function alongside its URL pattern. The SSG plugin
1299
+ // calls this once before rendering and uses the returned map to expand
1300
+ // dynamic routes (\`/posts/:id\` × \`[{id:'a'},{id:'b'}]\` → \`/posts/a\`,
1301
+ // \`/posts/b\`). Routes without \`getStaticPaths\` are absent from the map.
1302
+ //
1303
+ // Why we collect ALL routes here instead of resolving on-demand: the
1304
+ // SSG plugin's \`resolvePaths\` runs in the OUTER Vite plugin context (no
1305
+ // access to the bundled routes module). The SSR sub-build is the only
1306
+ // place where the user's compiled route exports are reachable, so we
1307
+ // expose a sync collector that lets the plugin call user functions
1308
+ // indirectly via the entry's exports.
1309
+ function collectStaticPathsRegistry(rs, out) {
1310
+ for (const r of rs) {
1311
+ if (typeof r.getStaticPaths === "function" && typeof r.path === "string") {
1312
+ out.set(r.path, r.getStaticPaths)
1313
+ }
1314
+ if (Array.isArray(r.children)) collectStaticPathsRegistry(r.children, out)
1315
+ }
1316
+ return out
1317
+ }
1318
+
1319
+ /** Map of \`urlPath → getStaticPaths function\` for every dynamic route. */
1320
+ export const __getStaticPathsRegistry = collectStaticPathsRegistry(routes, new Map())
1321
+
1322
+ // ─── 404 emission (PR C) ────────────────────────────────────────────────────
1323
+ //
1324
+ // Locales the build was configured for (PR H: \`zero({ i18n: { locales } })\`).
1325
+ // Injected as a JSON-literal by the outer plugin so the walker can detect
1326
+ // which RouteRecord serves which locale by matching its \`path\` against
1327
+ // the \`/\${locale}\` / \`/\${locale}/*\` prefix. Empty array = no i18n,
1328
+ // single-default-locale shape, walker collects exactly one entry keyed
1329
+ // by \`null\` and the closeBundle writes a single \`dist/404.html\`.
1330
+ const __i18nLocales = ${JSON.stringify(locales)}
1331
+
1332
+ // Walk the route tree and return ALL \`notFoundComponent\` references,
1333
+ // keyed by which locale subtree they were found in (or \`null\` for the
1334
+ // default / no-i18n case). fs-router attaches \`_404.tsx\` to its parent
1335
+ // layout's RouteRecord (or to each page record when no wrapping layout
1336
+ // exists — which is the per-locale subtree shape under PR H's root-
1337
+ // layout-skip). The walker collects the FIRST match per locale via
1338
+ // depth-first traversal: the per-locale subtree's \`notFoundComponent\`
1339
+ // wins over the root's for that locale.
1340
+ //
1341
+ // Locale detection: a RouteRecord serves locale \`X\` if its \`path\`
1342
+ // matches \`/X\` or starts with \`/X/\`. The default-locale entry
1343
+ // (under \`prefix-except-default\` strategy) is keyed by \`null\` since
1344
+ // its path doesn't carry a locale prefix.
1345
+ function findNotFoundComponentsByLocale(rs, currentLocale) {
1346
+ const result = new Map()
1347
+ function walk(records, ambient) {
1348
+ for (const r of records) {
1349
+ const path = typeof r.path === "string" ? r.path : ""
1350
+ let locale = ambient
1351
+ for (const l of __i18nLocales) {
1352
+ if (path === \`/\${l}\` || path.startsWith(\`/\${l}/\`)) {
1353
+ locale = l
1354
+ break
1355
+ }
1356
+ }
1357
+ if (typeof r.notFoundComponent === "function") {
1358
+ if (!result.has(locale)) result.set(locale, r.notFoundComponent)
1359
+ }
1360
+ if (Array.isArray(r.children)) walk(r.children, locale)
1361
+ }
1362
+ }
1363
+ walk(rs, currentLocale)
1364
+ return result
1365
+ }
1366
+ export const __notFoundComponentsByLocale = findNotFoundComponentsByLocale(routes, null)
1367
+
1368
+ // Back-compat: legacy single-export, picks up whatever the walker
1369
+ // classified as \`null\`-locale (default-locale or non-i18n root 404).
1370
+ // External callers (none currently in main, but downstream consumers
1371
+ // that imported this export pre-PR) keep working.
1372
+ export const __notFoundComponent = __notFoundComponentsByLocale.get(null) ?? null
1373
+
1374
+ // Render the not-found component THROUGH the router (PR L5). We navigate
1375
+ // to a synthetic non-matching probe URL per locale — \`resolveRoute\`
1376
+ // (post-L5) walks the route tree finding the deepest parent
1377
+ // \`notFoundComponent\` and builds a matched chain
1378
+ // \`[...ancestorLayouts, syntheticLeaf]\`. The leaf carries the
1379
+ // not-found component; rendering through the normal pipeline produces
1380
+ // HTML WITH layout chrome — same headers, footers, navigation as
1381
+ // regular pages. The locale prefix in the probe URL ensures the right
1382
+ // per-locale layout subtree matches (under PR H's prefix strategy).
1383
+ //
1384
+ // Pre-L5 behavior (\`h(component, null)\` standalone) is preserved as a
1385
+ // fallback when the route tree has \`notFoundComponent\` at the root
1386
+ // but no \`isNotFound\` chain forms (older route shapes). The outer
1387
+ // plugin checks \`__notFoundComponentsByLocale\` first to gate emission
1388
+ // — if no notFoundComponent exists anywhere, \`__renderNotFound\` is
1389
+ // never called.
1390
+ export async function __renderNotFound(locale) {
1391
+ // Probe URL chosen to be highly improbable as a real route. The
1392
+ // suffix is deliberately literal (no \`Math.random\`) so build
1393
+ // outputs are deterministic across runs.
1394
+ const probePath = locale == null
1395
+ ? "/__pyreon_not_found_probe__"
1396
+ : \`/\${locale}/__pyreon_not_found_probe__\`
1397
+
1398
+ // Try the router-driven path first. If the route tree has a parent
1399
+ // \`notFoundComponent\` reachable from the probe URL, resolveRoute's
1400
+ // fallback builds the chain through the layout and the normal render
1401
+ // pipeline produces 404 HTML wrapped in layout chrome.
1402
+ //
1403
+ // PR C — pass \`isNotFound: true\` so renderPath skips parent-layout
1404
+ // loaders. Layout loaders that hit auth resources / external APIs
1405
+ // shouldn't fire when generating a static 404 page (the build has
1406
+ // no real request context). Lazy components still resolve; only
1407
+ // \`r.loader()\` invocations are skipped.
1408
+ const result = await renderPath(probePath, { isNotFound: true })
1409
+ if (result && result.kind === "html") {
1410
+ return {
1411
+ appHtml: result.appHtml,
1412
+ head: result.head,
1413
+ loaderScript: result.loaderScript,
1414
+ }
1415
+ }
1416
+
1417
+ // Fallback for tree shapes where the resolver returns empty matched
1418
+ // (no notFoundComponent walkable from this probe path). Render the
1419
+ // component standalone — same shape pre-L5.
1420
+ const component = locale == null
1421
+ ? (__notFoundComponentsByLocale.get(null) ?? __notFoundComponent)
1422
+ : __notFoundComponentsByLocale.get(locale)
1423
+ if (typeof component !== "function") return null
1424
+
1425
+ return runWithRequestContext(async () => {
1426
+ const vnode = h(component, null)
1427
+ const { html: appHtml, head } = await renderWithHead(vnode)
1428
+
1429
+ const styleTag = __pyreonGetStylerTag()
1430
+ const isEmpty = !styleTag || styleTag.indexOf("></style>") !== -1
1431
+ const finalHead = isEmpty ? head : styleTag + "\\n" + head
1432
+
1433
+ return { appHtml, head: finalHead, loaderScript: "" }
1434
+ })
1435
+ }
1436
+ `.trimStart();
1437
+ };
1438
+ const SSR_ENTRY_FILENAME = "__pyreon-zero-ssg-entry.js";
1439
+ /**
1440
+ * Substitute concrete values for `:param` / `:param*` segments in a URL
1441
+ * pattern. Mirrors the inverse of `filePathToUrlPath`.
1442
+ *
1443
+ * /posts/:id × { id: 'a' } → /posts/a
1444
+ * /posts/:id/:slug × { id: 'a', slug: 'b' } → /posts/a/b
1445
+ * /blog/:rest* × { rest: 'a/b' } → /blog/a/b (catch-all preserves slashes)
1446
+ *
1447
+ * Missing params or empty values throw — the SSG plugin treats this as a
1448
+ * `getStaticPaths` error and records it in the per-path errors array.
1449
+ */
1450
+ function expandUrlPattern(pattern, params) {
1451
+ return pattern.split("/").map((seg) => {
1452
+ if (!seg.startsWith(":")) return seg;
1453
+ const name = seg.endsWith("*") ? seg.slice(1, -1) : seg.slice(1);
1454
+ const value = params[name];
1455
+ if (value === void 0 || value === "") throw new Error(`[zero:ssg] getStaticPaths for "${pattern}" returned params without "${name}"`);
1456
+ return value;
1457
+ }).join("/");
1458
+ }
1459
+ /**
1460
+ * Auto-detect static paths from the route tree AND expand dynamic routes
1461
+ * via each route's `getStaticPaths` export (when present). A "static" path
1462
+ * is one with NO dynamic segments (`[id]`, `[...rest]`); a "dynamic" path
1463
+ * with `getStaticPaths` is expanded via the registry; remaining dynamic
1464
+ * routes are silently skipped (the user must hand-list them in
1465
+ * `ssg.paths`).
1466
+ */
1467
+ async function autoDetectStaticPaths(routesDir, registry, errors = [], i18n) {
1468
+ if (!existsSync(routesDir)) return ["/"];
1469
+ const baseRoutes = parseFileRoutes(await scanRouteFiles(routesDir));
1470
+ const fileRoutes = i18n ? expandRoutesForLocales(baseRoutes, i18n) : baseRoutes;
1471
+ const out = [];
1472
+ for (const r of fileRoutes) {
1473
+ if (r.isLayout || r.isError || r.isLoading || r.isNotFound) continue;
1474
+ const path = r.urlPath;
1475
+ if (!path) continue;
1476
+ if (!/[:*]/.test(path)) {
1477
+ out.push(path);
1478
+ continue;
1479
+ }
1480
+ const enumerator = registry?.get(path);
1481
+ if (!enumerator) continue;
1482
+ try {
1483
+ const result = await enumerator();
1484
+ if (!Array.isArray(result)) throw new Error(`getStaticPaths for "${path}" must return an array, got ${typeof result}`);
1485
+ for (const entry of result) {
1486
+ if (!entry || typeof entry !== "object" || !entry.params) throw new Error(`getStaticPaths for "${path}" returned an entry without "params"`);
1487
+ out.push(expandUrlPattern(path, entry.params));
1488
+ }
1489
+ } catch (error) {
1490
+ errors.push({
1491
+ path,
1492
+ error
1493
+ });
1494
+ }
1495
+ }
1496
+ return out.length > 0 ? out : ["/"];
1497
+ }
1498
+ async function resolvePaths(config, routesDir, registry, errors = []) {
1499
+ const explicit = config.ssg?.paths;
1500
+ if (typeof explicit === "function") {
1501
+ const result = await explicit();
1502
+ return Array.isArray(result) ? result : [];
1503
+ }
1504
+ if (Array.isArray(explicit)) return explicit;
1505
+ return autoDetectStaticPaths(routesDir, registry, errors, config.i18n);
1506
+ }
1507
+ /**
1508
+ * Write `content` to `target` atomically: write to a sibling temp file first,
1509
+ * then `rename` into place. Rename is an atomic syscall on POSIX (and Windows
1510
+ * for same-volume renames) — readers either see the OLD content or the FULL
1511
+ * new content, never a half-written file.
1512
+ *
1513
+ * M2.1 — Use this for manifests that adapters consume (`_redirects`,
1514
+ * `_pyreon-ssg-paths.json`, `_pyreon-revalidate.json`, etc.). A SIGINT during
1515
+ * a sequential plain-`writeFile` chain in `closeBundle` would leave partial
1516
+ * state: half the manifests pointing at the new render, half the old. Atomic
1517
+ * writes mean each manifest is independently consistent — readers see either
1518
+ * the old build's manifest or the new build's, never a mix.
1519
+ *
1520
+ * Per-page HTML writes (`dist/<path>/index.html`) intentionally do NOT use
1521
+ * this — they're individually-readable files (no cross-file invariants), and
1522
+ * the rename-per-page cost on 10k-path sites would be significant.
1523
+ *
1524
+ * Temp filename embeds `pid + perf-counter` so concurrent runs (e.g. CI
1525
+ * pipelines that fight over the same dist) don't collide. The tmp file is
1526
+ * cleaned up in a finally block — even if the rename fails, no orphaned
1527
+ * `.tmp.*` files leak.
1528
+ *
1529
+ * @internal Exposed via `_internal.writeFileAtomic` for unit tests.
1530
+ */
1531
+ let _atomicSeq = 0;
1532
+ async function writeFileAtomic(target, content) {
1533
+ const tmp = `${target}.tmp.${process.pid}.${++_atomicSeq}`;
1534
+ try {
1535
+ await writeFile(tmp, content);
1536
+ await rename(tmp, target);
1537
+ } catch (err) {
1538
+ try {
1539
+ await unlink(tmp);
1540
+ } catch {}
1541
+ throw err;
1542
+ }
1543
+ }
1544
+ /**
1545
+ * Build the per-locale breakdown summary string for the closeBundle log.
1546
+ *
1547
+ * M2.5 — Computes per-locale path counts from `writtenPaths` by checking
1548
+ * each path's leading segment against the configured locale list. Paths
1549
+ * with no locale prefix go to the default locale (under
1550
+ * `prefix-except-default`) or are skipped (under `prefix`, where every
1551
+ * locale carries an explicit prefix — unprefixed paths are unexpected).
1552
+ *
1553
+ * Returns `` ` [en: 100, de: 100, cs: 100]` `` (with leading space) for
1554
+ * pretty concatenation into the summary line, or empty string when i18n
1555
+ * is unconfigured / writtenPaths is empty.
1556
+ *
1557
+ * @internal Exposed via `_internal.buildLocaleSummary` for unit tests.
1558
+ */
1559
+ function buildLocaleSummary(writtenPaths, i18n) {
1560
+ if (writtenPaths.length === 0 || i18n.locales.length === 0) return "";
1561
+ const counts = /* @__PURE__ */ new Map();
1562
+ for (const locale of i18n.locales) counts.set(locale, 0);
1563
+ const defaultLocale = i18n.defaultLocale ?? i18n.locales[0] ?? "";
1564
+ const strategy = i18n.strategy ?? "prefix-except-default";
1565
+ for (const p of writtenPaths) {
1566
+ const firstSeg = p.split("/")[1];
1567
+ if (firstSeg && counts.has(firstSeg)) counts.set(firstSeg, (counts.get(firstSeg) ?? 0) + 1);
1568
+ else if (strategy === "prefix-except-default" && defaultLocale) counts.set(defaultLocale, (counts.get(defaultLocale) ?? 0) + 1);
1569
+ }
1570
+ const parts = [];
1571
+ for (const locale of i18n.locales) parts.push(`${locale}: ${counts.get(locale) ?? 0}`);
1572
+ return ` [${parts.join(", ")}]`;
1573
+ }
1574
+ /**
1575
+ * Detect duplicate URLs in the resolved-paths list. Returns the duplicates
1576
+ * (sorted, unique). Empty array = no collisions.
1577
+ *
1578
+ * The render loop's `writtenPaths.push(p)` would silently last-wins on
1579
+ * duplicates — two routes producing the same URL would have one's HTML
1580
+ * overwrite the other's with no error. Catching the collision before
1581
+ * render makes the conflict visible at the source-of-truth (the routes
1582
+ * tree), not at the symptom (mysterious HTML drift between rebuilds).
1583
+ *
1584
+ * @internal Exposed via `_internal.detectPathCollisions` for unit tests.
1585
+ */
1586
+ function detectPathCollisions(paths) {
1587
+ const seen = /* @__PURE__ */ new Set();
1588
+ const duplicates = /* @__PURE__ */ new Set();
1589
+ for (const p of paths) {
1590
+ if (seen.has(p)) duplicates.add(p);
1591
+ seen.add(p);
1592
+ }
1593
+ return [...duplicates].sort();
1594
+ }
1595
+ /** Format a path-collision error message with actionable guidance. */
1596
+ /**
1597
+ * Wiring helper: run the collision detector + throw with the formatted error
1598
+ * when any collisions are found. The closeBundle handler calls this between
1599
+ * `resolvePaths` and the render loop. Factored out so unit tests can exercise
1600
+ * the full "detect → throw" path without spinning up a Vite SSR sub-build.
1601
+ *
1602
+ * @internal Exposed via `_internal.assertNoPathCollisions` for unit tests.
1603
+ */
1604
+ function assertNoPathCollisions(paths) {
1605
+ const collisions = detectPathCollisions(paths);
1606
+ if (collisions.length > 0) throw new Error(formatPathCollisionError(collisions));
1607
+ }
1608
+ function formatPathCollisionError(duplicates) {
1609
+ const list = duplicates.map((p) => ` - ${p}`).join("\n");
1610
+ return `[Pyreon] SSG path collision — ${duplicates.length} URL(s) resolved by multiple routes:\n${list}\nThis happens when a static route + getStaticPaths return overlap, or two getStaticPaths enumerators produce the same URL. Inspect your routes tree and ensure each URL is produced by exactly one route.`;
1611
+ }
1612
+ function resolveOutputPath(distDir, path) {
1613
+ if (path === "/") return join(distDir, "index.html");
1614
+ if (path.endsWith(".html")) return join(distDir, path);
1615
+ return join(distDir, path, "index.html");
1616
+ }
1617
+ /**
1618
+ * Render Netlify / Cloudflare Pages `_redirects` file content. One line
1619
+ * per redirect, format: `<from> <to> <status>`. Both platforms parse this
1620
+ * format identically; Vercel ignores it (use the JSON below). Lines with
1621
+ * leading `#` are comments — included so the file is self-documenting in
1622
+ * a deploy log.
1623
+ */
1624
+ function renderNetlifyRedirects(entries) {
1625
+ if (entries.length === 0) return "";
1626
+ const lines = ["# Auto-generated by @pyreon/zero SSG. Do not edit."];
1627
+ for (const e of entries) lines.push(`${e.from} ${e.to} ${e.status}`);
1628
+ return `${lines.join("\n")}\n`;
1629
+ }
1630
+ /**
1631
+ * Render Vercel `_redirects.json` content. Vercel reads this from the
1632
+ * `vercel.json` `redirects` array shape — but the bare `_redirects.json`
1633
+ * file ships alongside as documentation / fallback for adapters that
1634
+ * read either format. The 308/301/302/307 status maps to Vercel's
1635
+ * `permanent: true|false` boolean (308/301 → permanent; 302/307 →
1636
+ * temporary).
1637
+ */
1638
+ function renderVercelRedirectsJson(entries) {
1639
+ return `${JSON.stringify({ redirects: entries.map((e) => ({
1640
+ source: e.from,
1641
+ destination: e.to,
1642
+ permanent: e.status === 301 || e.status === 308,
1643
+ statusCode: e.status
1644
+ })) }, null, 2)}\n`;
1645
+ }
1646
+ /**
1647
+ * Render a meta-refresh HTML stub for static hosts that don't read
1648
+ * `_redirects` (plain S3, GitHub Pages, simple file servers). The
1649
+ * `<meta http-equiv="refresh" content="0; url=…">` triggers a
1650
+ * client-side refresh; the canonical link is for SEO so search
1651
+ * engines de-dupe the source path against the target. Status code
1652
+ * has no HTML equivalent — a meta-refresh is always "client-side."
1653
+ */
1654
+ function renderMetaRefreshHtml(target) {
1655
+ const escaped = target.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1656
+ return `<!DOCTYPE html>
1657
+ <html>
1658
+ <head>
1659
+ <meta charset="utf-8">
1660
+ <meta http-equiv="refresh" content="0; url=${escaped}">
1661
+ <link rel="canonical" href="${escaped}">
1662
+ <title>Redirecting to ${escaped}</title>
1663
+ </head>
1664
+ <body>
1665
+ <p>Redirecting to <a href="${escaped}">${escaped}</a>...</p>
1666
+ </body>
1667
+ </html>
1668
+ `;
1669
+ }
1670
+ /**
1671
+ * Serialize the captured render-loop errors as a stable JSON artifact.
1672
+ * Each entry has `{ path, message, name, stack }`. Errors that aren't
1673
+ * `Error` instances (e.g. a loader that threw a string) are coerced via
1674
+ * `String()` for `message`; `name` falls back to `'Error'`; `stack` is
1675
+ * `undefined` (omitted from JSON output).
1676
+ *
1677
+ * Wrapped in `{ errors: [...] }` rather than emitted as a bare array so
1678
+ * future fields (timing, build metadata) can be added without breaking
1679
+ * existing CI consumers. Pretty-printed with 2-space indent — the file
1680
+ * is meant to be read both by tooling AND humans diagnosing a failed
1681
+ * build, so byte-density is not the priority.
1682
+ */
1683
+ /**
1684
+ * Drain `items` through `concurrency` parallel workers, calling
1685
+ * `processItem(item)` on each and `onSettled(item, idx)` once each
1686
+ * settles (regardless of resolve/reject). The work-stealing pattern
1687
+ * (each worker pulls from a shared `nextIdx++` cursor) keeps load
1688
+ * balanced even when individual `processItem` calls vary widely in
1689
+ * duration — a fast item doesn't make its worker idle until the slowest
1690
+ * peer finishes.
1691
+ *
1692
+ * Settle ordering: `onSettled` fires in the order items finish, NOT in
1693
+ * input order. `idx` is the index into `items` (same identity across
1694
+ * `processItem` and `onSettled`), useful for "completed N of M"
1695
+ * progress reporting.
1696
+ *
1697
+ * Concurrency clamping: ≤ 0 inputs ARE clamped to 1 — the worker pool
1698
+ * is meaningless without at least one worker, but a value of `0` from
1699
+ * a misconfiguration shouldn't silently hang. The actual worker count
1700
+ * is `min(concurrency, items.length)` so a 2-item list with concurrency
1701
+ * 10 only spawns 2 workers (no idle workers spawned).
1702
+ *
1703
+ * Errors from `processItem` are NOT caught here — callers must handle
1704
+ * exceptions inside `processItem` (the SSG path does so via try/catch
1705
+ * in `renderOne`). Errors from `onSettled` likewise propagate; in the
1706
+ * SSG path the caller wraps it to record into `errors[]`. We don't
1707
+ * silently swallow because that would hide real bugs.
1708
+ *
1709
+ * Atomic operations under Node's single-threaded JS: `nextIdx++` is
1710
+ * atomic — workers never observe a partial increment, so two workers
1711
+ * never claim the same index. The pool relies on this invariant; do
1712
+ * NOT port to a multi-threaded runtime without revisiting.
1713
+ */
1714
+ async function runWithConcurrency(items, concurrency, processItem, onSettled) {
1715
+ const workerCount = Math.min(Math.max(1, concurrency), items.length);
1716
+ if (workerCount === 0) return;
1717
+ let nextIdx = 0;
1718
+ const worker = async () => {
1719
+ while (true) {
1720
+ const idx = nextIdx++;
1721
+ if (idx >= items.length) return;
1722
+ const item = items[idx];
1723
+ await processItem(item, idx);
1724
+ if (onSettled) await onSettled(item, idx);
1725
+ }
1726
+ };
1727
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
1728
+ }
1729
+ /**
1730
+ * PR I — build the revalidate manifest from scanned FileRoutes + the
1731
+ * list of paths that successfully rendered.
1732
+ *
1733
+ * For each FileRoute with a `revalidateLiteral` (captured at scan time
1734
+ * via `detectRouteExports`), parse the literal as JSON (numbers and
1735
+ * `false` are valid JSON tokens), build a regex from the route's
1736
+ * urlPath pattern, and match against `writtenPaths`. Each matching
1737
+ * concrete path goes into the manifest under the route's revalidate
1738
+ * value.
1739
+ *
1740
+ * Returns `{}` when no routes have a revalidate literal — the caller
1741
+ * checks `Object.keys(...).length > 0` before writing the manifest.
1742
+ *
1743
+ * Static routes match exactly (urlPath === concretePath). Dynamic
1744
+ * routes (`/posts/:id`) compile to `^\/posts\/[^/]+$`. Catch-alls
1745
+ * (`/blog/:slug*`) compile to `^\/blog\/.*$`. Layout / error / loading
1746
+ * / not-found routes are skipped — they don't appear in writtenPaths
1747
+ * anyway, but the explicit guard keeps the helper stand-alone-testable.
1748
+ *
1749
+ * Exposed via `_internal.buildRevalidateManifest` so it can be unit-
1750
+ * tested without a full SSG round-trip.
1751
+ */
1752
+ function buildRevalidateManifest(fileRoutes, writtenPaths) {
1753
+ const manifest = {};
1754
+ for (const route of fileRoutes) {
1755
+ if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue;
1756
+ const literal = route.exports?.revalidateLiteral;
1757
+ if (literal === void 0) continue;
1758
+ let parsed;
1759
+ try {
1760
+ parsed = JSON.parse(literal);
1761
+ } catch {
1762
+ continue;
1763
+ }
1764
+ if (typeof parsed !== "number" && parsed !== false) continue;
1765
+ const value = parsed;
1766
+ const matcher = compileUrlPatternMatcher(route.urlPath);
1767
+ for (const concretePath of writtenPaths) if (matcher(concretePath)) manifest[concretePath] = value;
1768
+ }
1769
+ return manifest;
1770
+ }
1771
+ /**
1772
+ * Compile a route's urlPath pattern (`/posts/:id`, `/blog/:slug*`,
1773
+ * `/about`) into a predicate that returns `true` for any concrete
1774
+ * path that matches. Static patterns return a `===` comparator.
1775
+ * Dynamic / catch-all patterns return a regex predicate.
1776
+ *
1777
+ * Internal helper to `buildRevalidateManifest`. Mirrors the routing
1778
+ * matcher's behaviour for `:param` (single segment) and `:param*`
1779
+ * (catch-all, zero-or-more segments). Doesn't need to handle every
1780
+ * router edge case — the writtenPaths it matches against are already
1781
+ * concrete (no params, no wildcards).
1782
+ */
1783
+ function compileUrlPatternMatcher(urlPath) {
1784
+ if (!urlPath.includes(":") && !urlPath.includes("*")) return (concrete) => concrete === urlPath;
1785
+ const regex = urlPath.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/:([A-Za-z_$][\w$]*)\*/g, ".*").replace(/:([A-Za-z_$][\w$]*)/g, "[^/]+");
1786
+ const re = new RegExp(`^${regex}$`);
1787
+ return (concrete) => re.test(concrete);
1788
+ }
1789
+ function renderErrorArtifact(entries) {
1790
+ const errors = entries.map(({ path, error }) => ({
1791
+ path,
1792
+ message: error instanceof Error ? error.message : String(error),
1793
+ name: error instanceof Error ? error.name : "Error",
1794
+ stack: error instanceof Error ? error.stack : void 0
1795
+ }));
1796
+ return `${JSON.stringify({ errors }, null, 2)}\n`;
1797
+ }
1798
+ /**
1799
+ * Inject a rendered SSR result into the index.html template. Prefers
1800
+ * Pyreon's `<!--pyreon-head-->` / `<!--pyreon-app-->` /
1801
+ * `<!--pyreon-scripts-->` placeholders; falls back to inserting before
1802
+ * `</head>` / inside `<div id="app">` / before `</body>` so a bare
1803
+ * Vite-style `index.html` (no Pyreon comments) still receives content.
1804
+ *
1805
+ * Factored out of the per-path render loop so the 404 emission path can
1806
+ * reuse the exact same injection rules — keeps the rendered _404.tsx
1807
+ * subject to the same head/body/scripts pipeline as regular pages
1808
+ * (styler tag, @pyreon/head meta, hashed asset preload links).
1809
+ */
1810
+ function injectIntoTemplate(template, result) {
1811
+ let html = template;
1812
+ if (html.includes("<!--pyreon-head-->")) html = html.replace("<!--pyreon-head-->", result.head);
1813
+ else if (result.head) html = html.replace("</head>", `${result.head}</head>`);
1814
+ if (html.includes("<!--pyreon-app-->")) html = html.replace("<!--pyreon-app-->", result.appHtml);
1815
+ else if (result.appHtml) {
1816
+ const appDivMatch = html.match(/<div\s+id=["']app["']\s*>([\s\S]*?)<\/div>/);
1817
+ if (appDivMatch) html = html.replace(appDivMatch[0], `<div id="app">${result.appHtml}</div>`);
1818
+ else html = html.replace("</body>", `<div id="app">${result.appHtml}</div></body>`);
1819
+ }
1820
+ if (html.includes("<!--pyreon-scripts-->")) html = html.replace("<!--pyreon-scripts-->", result.loaderScript);
1821
+ else if (result.loaderScript) html = html.replace("</body>", `${result.loaderScript}</body>`);
1822
+ return html;
1823
+ }
1824
+ /**
1825
+ * Plugin that performs SSG when `mode: "ssg"` is configured. Wires into
1826
+ * Vite's `closeBundle` hook so it runs once after the main client build
1827
+ * completes. The recursive SSR sub-build is gated by an env flag.
1828
+ */
1829
+ function ssgPlugin(userConfig = {}) {
1830
+ const config = resolveConfig(userConfig);
1831
+ let root = "";
1832
+ let distDir = "";
1833
+ const isInnerBuild = process.env[SSG_BUILD_FLAG] === "1";
1834
+ return {
1835
+ name: "pyreon-zero-ssg",
1836
+ apply: "build",
1837
+ enforce: "post",
1838
+ configResolved(resolved) {
1839
+ root = resolved.root;
1840
+ distDir = resolve(root, resolved.build.outDir);
1841
+ },
1842
+ async closeBundle() {
1843
+ if (config.mode !== "ssg") return;
1844
+ if (isInnerBuild) return;
1845
+ const ssrOutDir = join(distDir, ".zero-ssg-server");
1846
+ const indexHtmlPath = join(distDir, "index.html");
1847
+ if (!existsSync(indexHtmlPath)) {
1848
+ console.warn(`[zero:ssg] Skipping SSG — ${indexHtmlPath} not found. Did the client build complete?`);
1849
+ return;
1850
+ }
1851
+ const entryPath = join(root, SSR_ENTRY_FILENAME);
1852
+ await writeFile(entryPath, renderSsrEntrySource(config.i18n?.locales ?? []), "utf-8");
1853
+ const { build } = await import("vite");
1854
+ process.env[SSG_BUILD_FLAG] = "1";
1855
+ try {
1856
+ const [{ zeroPlugin }, pyreonModule] = await Promise.all([Promise.resolve().then(() => vite_plugin_exports), import("@pyreon/vite-plugin")]);
1857
+ const pyreon = pyreonModule.default;
1858
+ await build({
1859
+ root,
1860
+ mode: "production",
1861
+ logLevel: "error",
1862
+ configFile: false,
1863
+ publicDir: false,
1864
+ plugins: [pyreon(), zeroPlugin(userConfig)],
1865
+ resolve: { conditions: ["bun"] },
1866
+ build: {
1867
+ ssr: entryPath,
1868
+ outDir: ssrOutDir,
1869
+ emptyOutDir: true,
1870
+ target: "esnext",
1871
+ rollupOptions: {
1872
+ input: entryPath,
1873
+ output: {
1874
+ format: "es",
1875
+ entryFileNames: "entry-server.mjs"
1876
+ },
1877
+ external: [/^node:/]
1878
+ }
1879
+ }
1880
+ });
1881
+ } finally {
1882
+ delete process.env[SSG_BUILD_FLAG];
1883
+ try {
1884
+ await rm(entryPath, { force: true });
1885
+ } catch {}
1886
+ }
1887
+ const handlerPath = join(ssrOutDir, "entry-server.mjs");
1888
+ if (!existsSync(handlerPath)) {
1889
+ console.warn(`[zero:ssg] SSR build did not produce ${handlerPath} — skipping prerender`);
1890
+ return;
1891
+ }
1892
+ const handlerMod = await import(
1893
+ /* @vite-ignore */
1894
+ pathToFileURL(handlerPath).href
1895
+ );
1896
+ const renderPath = handlerMod.default;
1897
+ const registry = handlerMod.__getStaticPathsRegistry;
1898
+ const template = await readFile(indexHtmlPath, "utf-8");
1899
+ const errors = [];
1900
+ const routesDir = join(root, "src", "routes");
1901
+ const paths = await resolvePaths(config, routesDir, registry, errors);
1902
+ if (paths.length === 0) {
1903
+ console.warn("[zero:ssg] No static paths to prerender — set ssg.paths in zero config");
1904
+ await rm(ssrOutDir, {
1905
+ recursive: true,
1906
+ force: true
1907
+ });
1908
+ return;
1909
+ }
1910
+ assertNoPathCollisions(paths);
1911
+ let pages = 0;
1912
+ const redirects = [];
1913
+ const writtenPaths = [];
1914
+ const start = Date.now();
1915
+ const renderOne = async (p) => {
1916
+ if (__DEV__) _countSink.__pyreon_count__?.("ssg.pathRender");
1917
+ try {
1918
+ const result = await Promise.race([renderPath(p), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`Prerender timeout for "${p}" (30s)`)), 3e4))]);
1919
+ if (result.kind === "redirect") {
1920
+ if (__DEV__) _countSink.__pyreon_count__?.("ssg.pathRedirect");
1921
+ redirects.push({
1922
+ from: result.from,
1923
+ to: result.to,
1924
+ status: result.status
1925
+ });
1926
+ if (config.ssg?.redirectsAsHtml === "meta-refresh") {
1927
+ const filePath = resolveOutputPath(distDir, p);
1928
+ const resolvedOut = resolve(distDir);
1929
+ if (!resolve(filePath).startsWith(resolvedOut)) {
1930
+ errors.push({
1931
+ path: p,
1932
+ error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
1933
+ });
1934
+ return;
1935
+ }
1936
+ await mkdir(dirname(filePath), { recursive: true });
1937
+ await writeFile(filePath, renderMetaRefreshHtml(result.to), "utf-8");
1938
+ }
1939
+ return;
1940
+ }
1941
+ const html = injectIntoTemplate(template, result);
1942
+ const filePath = resolveOutputPath(distDir, p);
1943
+ const resolvedOut = resolve(distDir);
1944
+ if (!resolve(filePath).startsWith(resolvedOut)) {
1945
+ errors.push({
1946
+ path: p,
1947
+ error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
1948
+ });
1949
+ return;
1950
+ }
1951
+ await mkdir(dirname(filePath), { recursive: true });
1952
+ await writeFile(filePath, html, "utf-8");
1953
+ pages++;
1954
+ writtenPaths.push(p);
1955
+ if (__DEV__) _countSink.__pyreon_count__?.("ssg.pathWrite");
1956
+ } catch (error) {
1957
+ if (__DEV__) _countSink.__pyreon_count__?.("ssg.pathError");
1958
+ errors.push({
1959
+ path: p,
1960
+ error
1961
+ });
1962
+ if (config.ssg?.onPathError) try {
1963
+ const fallbackHtml = await config.ssg.onPathError(p, error);
1964
+ if (typeof fallbackHtml === "string") {
1965
+ const filePath = resolveOutputPath(distDir, p);
1966
+ const resolvedOut = resolve(distDir);
1967
+ if (!resolve(filePath).startsWith(resolvedOut)) {
1968
+ errors.push({
1969
+ path: p,
1970
+ error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
1971
+ });
1972
+ return;
1973
+ }
1974
+ await mkdir(dirname(filePath), { recursive: true });
1975
+ await writeFile(filePath, fallbackHtml, "utf-8");
1976
+ pages++;
1977
+ }
1978
+ } catch (callbackError) {
1979
+ errors.push({
1980
+ path: `${p} (onPathError)`,
1981
+ error: callbackError
1982
+ });
1983
+ }
1984
+ }
1985
+ };
1986
+ const concurrency = Math.max(1, config.ssg?.concurrency ?? 4);
1987
+ let completed = 0;
1988
+ await runWithConcurrency(paths, concurrency, renderOne, async (p) => {
1989
+ completed++;
1990
+ if (config.ssg?.onProgress) try {
1991
+ await config.ssg.onProgress({
1992
+ completed,
1993
+ total: paths.length,
1994
+ currentPath: p,
1995
+ elapsed: Date.now() - start
1996
+ });
1997
+ } catch (callbackError) {
1998
+ errors.push({
1999
+ path: `${p} (onProgress)`,
2000
+ error: callbackError
2001
+ });
2002
+ }
2003
+ });
2004
+ if (writtenPaths.length > 0) await writeFileAtomic(join(distDir, "_pyreon-ssg-paths.json"), `${JSON.stringify({
2005
+ paths: writtenPaths,
2006
+ ...config.i18n ? { i18n: config.i18n } : {}
2007
+ }, null, 2)}\n`);
2008
+ let revalidateCount = 0;
2009
+ if (existsSync(routesDir)) try {
2010
+ const revalidateManifest = buildRevalidateManifest(await scanRouteFilesWithExports(routesDir, config.mode), writtenPaths);
2011
+ revalidateCount = Object.keys(revalidateManifest).length;
2012
+ if (revalidateCount > 0) await writeFileAtomic(join(distDir, "_pyreon-revalidate.json"), `${JSON.stringify({ revalidate: revalidateManifest }, null, 2)}\n`);
2013
+ } catch (err) {
2014
+ errors.push({
2015
+ path: "(revalidate-manifest)",
2016
+ error: err
2017
+ });
2018
+ }
2019
+ let emitted404Count = 0;
2020
+ if (config.ssg?.emit404 !== false && handlerMod.__renderNotFound) {
2021
+ const localeEntries = handlerMod.__notFoundComponentsByLocale instanceof Map ? [...handlerMod.__notFoundComponentsByLocale.keys()] : handlerMod.__notFoundComponent ? [null] : [];
2022
+ for (const locale of localeEntries) try {
2023
+ const result = await Promise.race([handlerMod.__renderNotFound(locale), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`Prerender timeout for ${locale == null ? "404" : `${locale}/404`} (30s)`)), 3e4))]);
2024
+ if (result) {
2025
+ const html = injectIntoTemplate(template, result);
2026
+ const filePath = locale == null ? join(distDir, "404.html") : join(distDir, locale, "404.html");
2027
+ if (locale != null) await mkdir(join(distDir, locale), { recursive: true });
2028
+ await writeFile(filePath, html, "utf-8");
2029
+ emitted404Count++;
2030
+ if (__DEV__) _countSink.__pyreon_count__?.("ssg.404Emit");
2031
+ }
2032
+ } catch (error) {
2033
+ errors.push({
2034
+ path: locale == null ? "404.html" : `${locale}/404.html`,
2035
+ error
2036
+ });
2037
+ }
2038
+ }
2039
+ if (redirects.length > 0 && config.ssg?.emitRedirects !== false) {
2040
+ await writeFileAtomic(join(distDir, "_redirects"), renderNetlifyRedirects(redirects));
2041
+ await writeFileAtomic(join(distDir, "_redirects.json"), renderVercelRedirectsJson(redirects));
2042
+ }
2043
+ if (errors.length > 0 && config.ssg?.errorArtifact !== "none") await writeFileAtomic(join(distDir, "_pyreon-ssg-errors.json"), renderErrorArtifact(errors));
2044
+ const adapter = resolveAdapter(config);
2045
+ try {
2046
+ await adapter.build({
2047
+ kind: "ssg",
2048
+ outDir: distDir,
2049
+ config
2050
+ });
2051
+ } catch (adapterError) {
2052
+ errors.push({
2053
+ path: `(adapter:${adapter.name})`,
2054
+ error: adapterError
2055
+ });
2056
+ }
2057
+ await rm(ssrOutDir, {
2058
+ recursive: true,
2059
+ force: true
2060
+ });
2061
+ const elapsed = Date.now() - start;
2062
+ const redirectsSummary = redirects.length > 0 ? ` + ${redirects.length} redirect(s)` : "";
2063
+ const concurrencySummary = concurrency > 1 ? ` (concurrency: ${concurrency})` : "";
2064
+ const adapterSummary = adapter.name !== "node" ? ` [adapter: ${adapter.name}]` : "";
2065
+ const revalidateSummary = revalidateCount > 0 ? ` + ${revalidateCount} revalidate path(s)` : "";
2066
+ const localeSummary = config.i18n ? buildLocaleSummary(writtenPaths, config.i18n) : "";
2067
+ console.log(`[zero:ssg] Prerendered ${pages} page(s)${emitted404Count > 0 ? emitted404Count === 1 ? " + 404.html" : ` + ${emitted404Count} 404 pages` : ""}${redirectsSummary}${revalidateSummary} in ${elapsed}ms${concurrencySummary}${adapterSummary}${localeSummary}` + (errors.length > 0 ? ` (${errors.length} error(s))` : ""));
2068
+ for (const { path: errPath, error } of errors) console.error(`[zero:ssg] Failed to prerender "${errPath}":`, error);
2069
+ }
2070
+ };
2071
+ }
2072
+
2073
+ //#endregion
2074
+ //#region src/vite-plugin.ts
2075
+ var vite_plugin_exports = /* @__PURE__ */ __exportAll({
2076
+ argvHasPortFlag: () => argvHasPortFlag,
2077
+ getZeroPluginConfig: () => getZeroPluginConfig,
2078
+ zeroPlugin: () => zeroPlugin
2079
+ });
2080
+ /**
2081
+ * Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
2082
+ * Returns package names to exclude from Vite's dep optimizer.
2083
+ */
2084
+ function scanPyreonPackages(root) {
2085
+ const pyreonDir = join(root, "node_modules", "@pyreon");
2086
+ if (!existsSync(pyreonDir)) return [];
2087
+ try {
2088
+ return readdirSync(pyreonDir).filter((name) => !name.startsWith(".")).map((name) => `@pyreon/${name}`);
2089
+ } catch {
2090
+ return [];
2091
+ }
2092
+ }
2093
+ /**
2094
+ * Resolve a package that isn't at the app's top-level `node_modules` but is
2095
+ * nested under another `@pyreon/*` package. Used to alias `@pyreon/runtime-server`
2096
+ * to the copy under `node_modules/@pyreon/zero/node_modules/@pyreon/runtime-server`
2097
+ * so `ssrLoadModule` works without requiring the app to declare it as a
2098
+ * direct dep.
2099
+ */
2100
+ function resolveNestedPackage(root, name) {
2101
+ const direct = join(root, "node_modules", name);
2102
+ if (existsSync(direct)) return direct;
2103
+ const nested = join(root, "node_modules", "@pyreon", "zero", "node_modules", name);
2104
+ if (existsSync(nested)) return nested;
2105
+ }
2106
+ const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
2107
+ const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
2108
+ const VIRTUAL_MIDDLEWARE_ID = "virtual:zero/route-middleware";
2109
+ const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
2110
+ const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
2111
+ const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
2112
+ /**
2113
+ * Per-plugin-instance storage for the user-supplied ZeroConfig. Lets
2114
+ * downstream consumers (e.g. `@pyreon/zero-cli`'s `build` command, which
2115
+ * loads the user's `vite.config.ts` and inspects its plugin list)
2116
+ * recover the original config without us attaching internal state to
2117
+ * the public Plugin object via an underscore-prefixed property.
2118
+ *
2119
+ * Exported via `getZeroPluginConfig(plugin)` so the WeakMap itself
2120
+ * stays an implementation detail — callers can't enumerate or mutate
2121
+ * the table, only read by Plugin identity.
2122
+ */
2123
+ const zeroPluginConfigMap = /* @__PURE__ */ new WeakMap();
2124
+ /**
2125
+ * Retrieve the `ZeroConfig` that was passed to `zeroPlugin(userConfig)`
2126
+ * when the plugin was created. Returns `undefined` if the argument
2127
+ * isn't a recognized pyreon-zero main plugin instance.
2128
+ */
2129
+ function getZeroPluginConfig(plugin) {
2130
+ return zeroPluginConfigMap.get(plugin);
2131
+ }
2132
+ /**
2133
+ * Detects `--port` / `--port=N` / `-p N` / `-p=N` in `process.argv`.
2134
+ * Used by the plugin's `config()` hook to decide whether to apply the
2135
+ * default port — when the CLI was invoked with `--port`, the plugin
2136
+ * must skip its default so the CLI flag wins (see the comment at the
2137
+ * port-handling block in `zeroPlugin()` for the full precedence model).
2138
+ *
2139
+ * Exported for testing only (the plugin uses it internally).
2140
+ *
2141
+ * @internal
2142
+ */
2143
+ function argvHasPortFlag(argv = process.argv) {
2144
+ for (let i = 0; i < argv.length; i++) {
2145
+ const a = argv[i];
2146
+ if (a === "--port" || a === "-p") return true;
2147
+ if (a !== void 0 && (a.startsWith("--port=") || a.startsWith("-p="))) return true;
2148
+ }
2149
+ return false;
2150
+ }
2151
+ /**
2152
+ * Zero Vite plugin — adds file-based routing and zero-config conventions
2153
+ * on top of @pyreon/vite-plugin.
2154
+ *
2155
+ * @example
2156
+ * // vite.config.ts
2157
+ * import pyreon from "@pyreon/vite-plugin"
2158
+ * import zero from "@pyreon/zero"
2159
+ *
2160
+ * export default {
2161
+ * plugins: [pyreon(), zero()],
2162
+ * }
2163
+ */
2164
+ function zeroPlugin(userConfig = {}) {
2165
+ const config = resolveConfig(userConfig);
2166
+ let routesDir;
2167
+ let root;
2168
+ const mainPlugin = {
2169
+ name: "pyreon-zero",
2170
+ enforce: "pre",
2171
+ configResolved(resolvedConfig) {
2172
+ root = resolvedConfig.root;
2173
+ routesDir = `${root}/src/routes`;
2174
+ },
2175
+ resolveId(id) {
2176
+ if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID;
2177
+ if (id === VIRTUAL_MIDDLEWARE_ID) return RESOLVED_VIRTUAL_MIDDLEWARE_ID;
2178
+ if (id === VIRTUAL_API_ROUTES_ID) return RESOLVED_VIRTUAL_API_ROUTES_ID;
2179
+ },
2180
+ async load(id) {
2181
+ if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
2182
+ const baseRoutes = await scanRouteFilesWithExports(routesDir, config.mode);
2183
+ const routes = config.i18n ? expandRoutesForLocales(baseRoutes, config.i18n) : baseRoutes;
2184
+ const ssgSplitDisabled = config.mode === "ssg" && config.ssg?.splitChunks === false;
2185
+ return generateRouteModuleFromRoutes(routes, routesDir, { staticImports: ssgSplitDisabled });
2186
+ } catch (_err) {
2187
+ return `export const routes = []`;
2188
+ }
2189
+ if (id === RESOLVED_VIRTUAL_MIDDLEWARE_ID) try {
2190
+ return generateMiddlewareModule(await scanRouteFiles(routesDir), routesDir);
2191
+ } catch (_err) {
2192
+ return `export const routeMiddleware = []`;
2193
+ }
2194
+ if (id === RESOLVED_VIRTUAL_API_ROUTES_ID) try {
2195
+ return generateApiRouteModule(await scanRouteFiles(routesDir), routesDir);
2196
+ } catch (_err) {
2197
+ return `export const apiRoutes = []`;
2198
+ }
2199
+ },
2200
+ configureServer(server) {
2201
+ server.middlewares.use((req, res, next) => {
2202
+ const pathname = req.url?.split("?")[0] ?? "/";
2203
+ if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
2204
+ if (/\.\w+$/.test(pathname)) return next();
2205
+ dispatchApiRoute(server, req, res).then((handled) => {
2206
+ if (!handled) next();
2207
+ }, (err) => {
2208
+ console.error("[Pyreon] Error in dev API dispatcher:", err);
2209
+ next();
2210
+ });
2211
+ });
2212
+ if (config.mode === "ssr") server.middlewares.use((req, res, next) => {
2213
+ const accept = req.headers.accept ?? "";
2214
+ if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
2215
+ const pathname = req.url?.split("?")[0] ?? "/";
2216
+ if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
2217
+ if (/\.\w+$/.test(pathname)) return next();
2218
+ const reqHost = req.headers.host ?? "localhost";
2219
+ const reqUrl = new URL(req.url ?? "/", `http://${reqHost}`);
2220
+ const reqHeaders = new Headers();
2221
+ for (const [key, value] of Object.entries(req.headers)) if (value !== void 0) reqHeaders.set(key, Array.isArray(value) ? value.join(", ") : String(value));
2222
+ const webReq = new Request(reqUrl.href, {
2223
+ method: req.method ?? "GET",
2224
+ headers: reqHeaders
2225
+ });
2226
+ renderSsr(server, root, req.originalUrl ?? pathname, pathname, webReq).then((result) => {
2227
+ if (result === null) return next();
2228
+ res.statusCode = result.status;
2229
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
2230
+ res.setHeader("Content-Length", Buffer.byteLength(result.html));
2231
+ res.end(result.html);
2232
+ }, (err) => {
2233
+ const info = getRedirectInfo(err);
2234
+ if (info) {
2235
+ res.statusCode = info.status;
2236
+ res.setHeader("Location", info.url);
2237
+ res.end();
2238
+ return;
2239
+ }
2240
+ const error = err instanceof Error ? err : new Error(String(err));
2241
+ server.ssrFixStacktrace(error);
2242
+ const html = renderErrorOverlay(error);
2243
+ res.statusCode = 500;
2244
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
2245
+ res.setHeader("Content-Length", Buffer.byteLength(html));
2246
+ res.end(html);
2247
+ });
2248
+ });
2249
+ server.middlewares.use((req, res, next) => {
2250
+ const accept = req.headers.accept ?? "";
2251
+ if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
2252
+ const pathname = req.url?.split("?")[0] ?? "/";
2253
+ if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
2254
+ if (/\.\w+$/.test(pathname)) return next();
2255
+ handle404(server, routesDir, pathname, res).then((handled) => {
2256
+ if (!handled) next();
2257
+ }, (err) => {
2258
+ console.error("[Pyreon] Error in 404 handler:", err);
2259
+ next();
2260
+ });
2261
+ });
2262
+ server.middlewares.use((req, res, next) => {
2263
+ if (!(req.headers.accept ?? "").includes("text/html")) return next();
2264
+ const originalEnd = res.end.bind(res);
2265
+ let errored = false;
2266
+ const handleError = (err) => {
2267
+ if (errored) return;
2268
+ errored = true;
2269
+ const error = err instanceof Error ? err : new Error(String(err));
2270
+ server.ssrFixStacktrace(error);
2271
+ const html = renderErrorOverlay(error);
2272
+ res.statusCode = 500;
2273
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
2274
+ res.setHeader("Content-Length", Buffer.byteLength(html));
2275
+ originalEnd(html);
2276
+ };
2277
+ res.on("error", handleError);
2278
+ try {
2279
+ const result = next();
2280
+ if (result && typeof result.catch === "function") result.catch(handleError);
2281
+ } catch (err) {
2282
+ handleError(err);
2283
+ }
2284
+ });
2285
+ server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`);
2286
+ server.watcher.on("all", (event, path) => {
2287
+ if (path.startsWith(routesDir) && (event === "add" || event === "unlink")) {
2288
+ for (const resolvedId of [
2289
+ RESOLVED_VIRTUAL_ROUTES_ID,
2290
+ RESOLVED_VIRTUAL_MIDDLEWARE_ID,
2291
+ RESOLVED_VIRTUAL_API_ROUTES_ID
2292
+ ]) {
2293
+ const mod = server.moduleGraph.getModuleById(resolvedId);
2294
+ if (mod) server.moduleGraph.invalidateModule(mod);
2295
+ }
2296
+ server.ws.send({ type: "full-reload" });
2297
+ }
2298
+ });
2299
+ },
2300
+ config(viteUserConfig) {
2301
+ const cwd = viteUserConfig.root ?? process.cwd();
2302
+ const pyreonExclude = scanPyreonPackages(cwd);
2303
+ const runtimeServerAlias = resolveNestedPackage(cwd, "@pyreon/runtime-server");
2304
+ return {
2305
+ resolve: {
2306
+ conditions: ["bun"],
2307
+ ...runtimeServerAlias ? { alias: { "@pyreon/runtime-server": runtimeServerAlias } } : {}
2308
+ },
2309
+ ssr: { resolve: {
2310
+ conditions: ["bun"],
2311
+ ...runtimeServerAlias ? { alias: { "@pyreon/runtime-server": runtimeServerAlias } } : {}
2312
+ } },
2313
+ optimizeDeps: { exclude: pyreonExclude },
2314
+ ...userConfig.port === void 0 && argvHasPortFlag() ? {} : { server: { port: config.port } },
2315
+ base: config.base,
2316
+ define: {
2317
+ __ZERO_MODE__: JSON.stringify(config.mode),
2318
+ __ZERO_BASE__: JSON.stringify(config.base)
2319
+ }
2320
+ };
2321
+ }
2322
+ };
2323
+ zeroPluginConfigMap.set(mainPlugin, userConfig);
2324
+ return config.mode === "ssg" ? [mainPlugin, ssgPlugin(userConfig)] : [mainPlugin];
2325
+ }
2326
+ /**
2327
+ * Dev-mode API-route dispatcher. Loads the `virtual:zero/api-routes` virtual
2328
+ * module, builds a Web `Request` from the Node `IncomingMessage`, and invokes
2329
+ * the matching route's HTTP-method handler.
2330
+ *
2331
+ * Returns `true` if the API middleware handled the request (response written).
2332
+ * Returns `false` if no route matched (caller falls through to next middleware).
2333
+ *
2334
+ * Mirrors what `createServer` wires up in production via `createApiMiddleware`,
2335
+ * but adapted for Vite's connect-style middleware stack — needs a Node→Web
2336
+ * request adapter.
2337
+ */
2338
+ async function dispatchApiRoute(server, req, res) {
2339
+ let apiRoutes;
2340
+ try {
2341
+ apiRoutes = (await server.ssrLoadModule(VIRTUAL_API_ROUTES_ID)).apiRoutes ?? [];
2342
+ } catch {
2343
+ return false;
2344
+ }
2345
+ if (apiRoutes.length === 0) return false;
2346
+ const host = req.headers.host ?? "localhost";
2347
+ const url = new URL(req.url ?? "/", `http://${host}`);
2348
+ const pathname = url.pathname;
2349
+ if (!apiRoutes.some((r) => matchApiRoute(r.pattern, pathname) !== null)) return false;
2350
+ const method = (req.method ?? "GET").toUpperCase();
2351
+ const headers = new Headers();
2352
+ for (const [key, value] of Object.entries(req.headers)) if (value !== void 0) headers.set(key, Array.isArray(value) ? value.join(", ") : String(value));
2353
+ const requestInit = {
2354
+ method,
2355
+ headers
2356
+ };
2357
+ if (method !== "GET" && method !== "HEAD") {
2358
+ requestInit.body = Readable.toWeb(req);
2359
+ requestInit.duplex = "half";
2360
+ }
2361
+ const webReq = new Request(url.href, requestInit);
2362
+ const response = await createApiMiddleware(apiRoutes)({
2363
+ req: webReq,
2364
+ url,
2365
+ path: pathname + url.search,
2366
+ headers: new Headers(),
2367
+ locals: {}
2368
+ });
2369
+ if (!response) return false;
2370
+ res.statusCode = response.status;
2371
+ response.headers.forEach((v, k) => {
2372
+ res.setHeader(k, v);
2373
+ });
2374
+ if (response.body) Readable.fromWeb(response.body).pipe(res);
2375
+ else res.end();
2376
+ return true;
2377
+ }
2378
+ /**
2379
+ * Static-page 404 fallback for apps WITHOUT `_404.tsx` in the routes tree.
2380
+ *
2381
+ * For `mode: 'ssr'` apps with `_404.tsx`, the SSR middleware's `renderSsr`
2382
+ * routes unmatched URLs through the router-driven path (PR L5 + M1.2) — that
2383
+ * produces a layout-wrapped 404 with HTTP status 404, never reaching here.
2384
+ * This function is the LEGACY fallback that fires only when:
2385
+ * - The app is in `mode: 'spa'` / `mode: 'ssg'` (no dev SSR middleware), OR
2386
+ * - The app has no reachable `notFoundComponent` in its routes tree (so the
2387
+ * SSR middleware's `resolveRoute` returns matched: [] and falls through).
2388
+ *
2389
+ * Returns true if the 404 was handled (response sent), false if the path
2390
+ * actually matches a route (caller continues to next middleware).
2391
+ *
2392
+ * Pre-M1.2 a stale comment claimed `_404.tsx` "cannot be SSR-rendered because
2393
+ * the compiler emits _tpl() calls that require document". That was wrong — the
2394
+ * SSR runtime renders compiler-emitted components fine via `renderToString`
2395
+ * (no document needed). The static fallback exists for backward compat with
2396
+ * apps that don't ship `_404.tsx`, not because SSR-rendering it is impossible.
2397
+ */
2398
+ async function handle404(server, _routesDir, pathname, res) {
2399
+ const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
2400
+ if (flattenRoutePatterns(routes).some((pattern) => matchPattern(pattern, pathname))) return false;
2401
+ const html = await render404Page(void 0);
2402
+ res.statusCode = 404;
2403
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
2404
+ res.setHeader("Content-Length", Buffer.byteLength(html));
2405
+ res.end(html);
2406
+ return true;
2407
+ }
2408
+ /**
2409
+ * Dev-mode SSR render pipeline. Returns the composed HTML string, or `null`
2410
+ * if the URL doesn't match any known route (caller falls through to the 404
2411
+ * middleware). Mirrors the production `createServer` flow:
2412
+ * 1. Load virtual:zero/routes + app.ts via Vite's ssrLoadModule
2413
+ * 2. Create a per-request router bound to the request URL
2414
+ * 3. Pre-run loaders for the matched route(s)
2415
+ * 4. Render app tree with head tag collection
2416
+ * 5. Serialize loader data into `window.__PYREON_LOADER_DATA__`
2417
+ * 6. Inject everything into the user's transformed index.html (so Vite
2418
+ * still gets a chance to inject its HMR client + JSX runtime prelude)
2419
+ */
2420
+ async function renderSsr(server, root, originalUrl, pathname, req) {
2421
+ const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
2422
+ let template = await readFile(join(root, "index.html"), "utf-8");
2423
+ template = await server.transformIndexHtml(originalUrl, template);
2424
+ const [core, _headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all([
2425
+ server.ssrLoadModule("@pyreon/core"),
2426
+ server.ssrLoadModule("@pyreon/head"),
2427
+ server.ssrLoadModule("@pyreon/head/ssr"),
2428
+ server.ssrLoadModule("@pyreon/router"),
2429
+ server.ssrLoadModule("@pyreon/runtime-server")
2430
+ ]);
2431
+ const { App, router: routerInst } = (await server.ssrLoadModule("@pyreon/zero/server")).createApp({
2432
+ routes,
2433
+ routerMode: "history",
2434
+ url: pathname
2435
+ });
2436
+ await routerInst.preload(pathname, req);
2437
+ const resolved = routerInst.currentRoute();
2438
+ if (!resolved?.matched || resolved.matched.length === 0) return null;
2439
+ const status = resolved.isNotFound === true ? 404 : 200;
2440
+ return runtimeServer.runWithRequestContext(async () => {
2441
+ const app = core.h(App, null);
2442
+ const { html: appHtml, head } = await headSsr.renderWithHead(app);
2443
+ const loaderData = routerPkg.serializeLoaderData(routerInst);
2444
+ const loaderScript = loaderData && Object.keys(loaderData).length > 0 ? `<script>window.__PYREON_LOADER_DATA__=${routerPkg.stringifyLoaderData(loaderData)}<\/script>` : "";
2445
+ return {
2446
+ html: template.replace("<!--pyreon-head-->", head).replace("<!--pyreon-app-->", appHtml).replace("<!--pyreon-scripts-->", loaderScript),
2447
+ status
2448
+ };
2449
+ });
2450
+ }
2451
+ /**
2452
+ * Extract all URL patterns from a nested route tree.
2453
+ *
2454
+ * The fs-router emits ABSOLUTE paths for every route, including grandchildren —
2455
+ * `{ path: "/app/dashboard" }` not `{ path: "dashboard" }`. The matcher reads
2456
+ * each route's `path` as-is; no prefix accumulation. Pre-fix, this function
2457
+ * concatenated `${prefix}${route.path}` which produced patterns like
2458
+ * `///app/app/dashboard` (prefix `'/app'` + path `'/app/dashboard'`). After
2459
+ * `path.split('/').filter(Boolean)` those became `['app', 'app', 'dashboard']`
2460
+ * — which can't match a real `/app/dashboard` request — so dev-server returned
2461
+ * 404 for every nested-layout route. Re-enables PR #411 specs that rely on
2462
+ * `/app/*` routing.
2463
+ */
2464
+ function flattenRoutePatterns(routes) {
2465
+ const patterns = [];
2466
+ for (const route of routes) {
2467
+ if (!route.path) continue;
2468
+ patterns.push(route.path);
2469
+ if (route.children) patterns.push(...flattenRoutePatterns(route.children));
2470
+ }
2471
+ return patterns;
2472
+ }
2473
+
2474
+ //#endregion
2475
+ export { render404Page as _, detectLocaleFromHeader as a, vercelAdapter as c, netlifyAdapter as d, cloudflareAdapter as f, createServer as g, resolveConfig as h, createLocaleContext as i, staticAdapter as l, defineConfig as m, vite_plugin_exports as n, i18nRouting as o, bunAdapter as p, zeroPlugin as r, resolveAdapter as s, getZeroPluginConfig as t, nodeAdapter as u, createApp as v };
2476
+ //# sourceMappingURL=vite-plugin-y0NmCLJA.js.map