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