@pyreon/zero 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
  2. package/lib/client.js +4 -1
  3. package/lib/env.js +6 -6
  4. package/lib/font.js +3 -3
  5. package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
  6. package/lib/i18n-routing.js +112 -1
  7. package/lib/image.js +140 -58
  8. package/lib/index.js +252 -82
  9. package/lib/og-image.js +5 -5
  10. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  11. package/lib/script.js +114 -25
  12. package/lib/seo.js +186 -15
  13. package/lib/server.js +274 -564
  14. package/lib/types/config.d.ts +275 -3
  15. package/lib/types/env.d.ts +2 -2
  16. package/lib/types/i18n-routing.d.ts +193 -2
  17. package/lib/types/image.d.ts +105 -5
  18. package/lib/types/index.d.ts +634 -182
  19. package/lib/types/script.d.ts +78 -6
  20. package/lib/types/seo.d.ts +128 -4
  21. package/lib/types/server.d.ts +575 -72
  22. package/lib/vite-plugin-xjWZwudX.js +2454 -0
  23. package/package.json +11 -10
  24. package/src/adapters/bun.ts +20 -1
  25. package/src/adapters/cloudflare.ts +78 -1
  26. package/src/adapters/index.ts +25 -3
  27. package/src/adapters/netlify.ts +63 -1
  28. package/src/adapters/node.ts +25 -1
  29. package/src/adapters/static.ts +26 -1
  30. package/src/adapters/validate.ts +8 -1
  31. package/src/adapters/vercel.ts +76 -1
  32. package/src/adapters/warn-missing-env.ts +49 -0
  33. package/src/app.ts +14 -0
  34. package/src/client.ts +18 -0
  35. package/src/entry-server.ts +55 -5
  36. package/src/env.ts +7 -7
  37. package/src/font.ts +3 -3
  38. package/src/fs-router.ts +72 -3
  39. package/src/i18n-routing.ts +246 -12
  40. package/src/image.tsx +242 -91
  41. package/src/index.ts +4 -4
  42. package/src/isr.ts +24 -6
  43. package/src/manifest.ts +675 -0
  44. package/src/og-image.ts +5 -5
  45. package/src/script.tsx +159 -36
  46. package/src/seo.ts +346 -15
  47. package/src/server.ts +10 -2
  48. package/src/ssg-plugin.ts +1211 -54
  49. package/src/types.ts +301 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +108 -30
  52. package/lib/vite-plugin-E4BHYvYW.js +0 -855
package/lib/server.js CHANGED
@@ -1,10 +1,8 @@
1
- import { i as generateRouteModule, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles, t as filePathToUrlPath } from "./fs-router-ZebyutPa.js";
2
- import { a as createServer, i as resolveConfig, n as zeroPlugin, o as render404Page, r as defineConfig, s as createApp } from "./vite-plugin-E4BHYvYW.js";
3
- import { createContext } from "@pyreon/core";
1
+ import { _ as render404Page, a as detectLocaleFromHeader, c as vercelAdapter, d as netlifyAdapter, f as cloudflareAdapter, g as createServer, h as resolveConfig, i as createLocaleContext, l as staticAdapter, m as defineConfig, o as i18nRouting, p as bunAdapter, r as zeroPlugin, s as resolveAdapter, t as getZeroPluginConfig, u as nodeAdapter, v as createApp } from "./vite-plugin-xjWZwudX.js";
2
+ import { i as generateRouteModule, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles, t as filePathToUrlPath } from "./fs-router-MewHc5SB.js";
4
3
  import { existsSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { readFile } from "node:fs/promises";
7
- import { signal } from "@pyreon/reactivity";
4
+ import { join, resolve } from "node:path";
5
+ import { readFile, rm, writeFile } from "node:fs/promises";
8
6
 
9
7
  //#region src/isr.ts
10
8
  /**
@@ -25,6 +23,7 @@ function createISRHandler(handler, config) {
25
23
  const revalidating = /* @__PURE__ */ new Set();
26
24
  const revalidateMs = config.revalidate * 1e3;
27
25
  const maxEntries = Math.max(1, config.maxEntries ?? 1e3);
26
+ const deriveKey = typeof config.cacheKey === "function" ? (req, _url) => config.cacheKey(req) : (_req, url) => url.pathname;
28
27
  function set(key, entry) {
29
28
  if (cache.has(key)) cache.delete(key);
30
29
  cache.set(key, entry);
@@ -42,12 +41,15 @@ function createISRHandler(handler, config) {
42
41
  }
43
42
  return entry;
44
43
  }
45
- async function revalidate(url) {
46
- const key = url.pathname;
44
+ async function revalidate(url, originalReq) {
45
+ const key = deriveKey(originalReq, url);
47
46
  if (revalidating.has(key)) return;
48
47
  revalidating.add(key);
49
48
  try {
50
- const res = await handler(new Request(url.href, { method: "GET" }));
49
+ const res = await handler(new Request(url.href, {
50
+ method: "GET",
51
+ headers: originalReq.headers
52
+ }));
51
53
  const html = await res.text();
52
54
  const headers = {};
53
55
  res.headers.forEach((v, k) => {
@@ -65,11 +67,11 @@ function createISRHandler(handler, config) {
65
67
  return async (req) => {
66
68
  if (req.method !== "GET") return handler(req);
67
69
  const url = new URL(req.url);
68
- const key = url.pathname;
70
+ const key = deriveKey(req, url);
69
71
  const entry = touch(key);
70
72
  if (entry) {
71
73
  const age = Date.now() - entry.timestamp;
72
- if (age > revalidateMs) revalidate(url);
74
+ if (age > revalidateMs) revalidate(url, req);
73
75
  return new Response(entry.html, {
74
76
  status: 200,
75
77
  headers: {
@@ -86,7 +88,7 @@ function createISRHandler(handler, config) {
86
88
  res.headers.forEach((v, k) => {
87
89
  headers[k] = v;
88
90
  });
89
- cache.set(key, {
91
+ set(key, {
90
92
  html,
91
93
  headers,
92
94
  timestamp: Date.now()
@@ -103,428 +105,97 @@ function createISRHandler(handler, config) {
103
105
  }
104
106
 
105
107
  //#endregion
106
- //#region src/adapters/validate.ts
107
- /**
108
- * Validate that adapter build inputs exist before copying.
109
- * Throws with a clear error message if directories are missing.
110
- * @internal
111
- */
112
- async function validateBuildInputs(options) {
113
- const { existsSync } = await import("node:fs");
114
- if (!existsSync(options.clientOutDir)) throw new Error(`[Pyreon] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`);
115
- if (!existsSync(options.serverEntry)) throw new Error(`[Pyreon] Server entry not found: ${options.serverEntry}. Run "vite build --ssr" first.`);
116
- }
117
-
118
- //#endregion
119
- //#region src/adapters/bun.ts
120
- /**
121
- * Bun adapter — generates a standalone Bun.serve() entry.
122
- */
123
- function bunAdapter() {
124
- return {
125
- name: "bun",
126
- async build(options) {
127
- await validateBuildInputs(options);
128
- const { writeFile, cp, mkdir } = await import("node:fs/promises");
129
- const { join } = await import("node:path");
130
- const outDir = options.outDir;
131
- await mkdir(outDir, { recursive: true });
132
- await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
133
- await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
134
- const port = options.config.port ?? 3e3;
135
- const serverEntry = `
136
- const handler = (await import("./server/entry-server.js")).default
137
- const clientDir = new URL("./client/", import.meta.url).pathname
138
-
139
- Bun.serve({
140
- port: ${port},
141
- async fetch(req) {
142
- const url = new URL(req.url)
143
-
144
- // Try static files first
145
- if (req.method === "GET") {
146
- const filePath = clientDir + (url.pathname === "/" ? "index.html" : url.pathname)
147
- // Prevent path traversal — ensure resolved path stays within clientDir
148
- const resolved = Bun.resolveSync(filePath, ".")
149
- if (!resolved.startsWith(Bun.resolveSync(clientDir, "."))) {
150
- return new Response("Forbidden", { status: 403 })
151
- }
152
- const file = Bun.file(filePath)
153
- if (await file.exists()) {
154
- return new Response(file, {
155
- headers: {
156
- "cache-control": filePath.endsWith(".js") || filePath.endsWith(".css")
157
- ? "public, max-age=31536000, immutable"
158
- : "public, max-age=3600",
159
- },
160
- })
161
- }
162
- }
163
-
164
- // Fall through to SSR handler
165
- return handler(req)
166
- },
167
- })
168
-
169
- console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
170
- `.trimStart();
171
- await writeFile(join(outDir, "index.ts"), serverEntry);
172
- }
173
- };
174
- }
175
-
176
- //#endregion
177
- //#region src/adapters/cloudflare.ts
108
+ //#region src/vercel-revalidate-handler.ts
178
109
  /**
179
- * Cloudflare Pages adapter generates output for Cloudflare Pages with Functions.
110
+ * M3.1Drop-in Vercel revalidate webhook handler.
111
+ *
112
+ * Pre-M3.1 the `vercelAdapter.revalidate(path)` (PR I) POSTed to
113
+ * `/api/_pyreon-revalidate?path=...&secret=...` — a CONVENTION that users
114
+ * had to implement themselves. This helper scaffolds the convention:
180
115
  *
181
- * Produces:
182
- * - Client assets in the output directory root (served as static)
183
- * - `_worker.js` Cloudflare Pages Function for SSR
116
+ * // src/routes/api/_pyreon-revalidate.ts (or `pages/api/...` in
117
+ * // Next-style apps deployed to Vercel)
118
+ * export { vercelRevalidateHandler as default } from '@pyreon/zero/server'
184
119
  *
185
- * Note: Cloudflare Pages Functions have a ~1MB module size limit.
186
- * For large apps, configure Vite's SSR build to bundle server code:
187
- * `ssr: { noExternal: true }` in vite.config.ts.
120
+ * The handler validates the secret query param against
121
+ * `VERCEL_REVALIDATE_TOKEN`, validates the path is in the build-time
122
+ * revalidate manifest, and calls Vercel's `res.revalidate(path)` API.
188
123
  *
189
- * Deploy with: `npx wrangler pages deploy ./dist`
124
+ * Returns a standard `(req: Request) => Response` Web API handler — works
125
+ * with Vercel Edge functions, Node serverless functions (via Vercel's
126
+ * `@vercel/node` adapter that bridges Node `req`/`res` to Web standard
127
+ * fetch shapes), and the in-process `mode: 'ssr'` runtime.
190
128
  *
191
129
  * @example
192
- * ```ts
193
- * // zero.config.ts
194
- * import { defineConfig } from "@pyreon/zero"
130
+ * // src/routes/api/_pyreon-revalidate.ts
131
+ * import { vercelRevalidateHandler } from '@pyreon/zero/server'
195
132
  *
196
- * export default defineConfig({
197
- * adapter: "cloudflare",
133
+ * export const POST = vercelRevalidateHandler({
134
+ * // Optional — defaults to reading `_pyreon-revalidate.json` from cwd.
135
+ * manifestPath: './dist/_pyreon-revalidate.json',
198
136
  * })
199
- * ```
200
- */
201
- function cloudflareAdapter() {
202
- return {
203
- name: "cloudflare",
204
- async build(options) {
205
- await validateBuildInputs(options);
206
- const { writeFile, cp, mkdir } = await import("node:fs/promises");
207
- const { join } = await import("node:path");
208
- const outDir = options.outDir;
209
- await mkdir(outDir, { recursive: true });
210
- await cp(options.clientOutDir, outDir, { recursive: true });
211
- await cp(join(options.serverEntry, ".."), join(outDir, "_server"), { recursive: true });
212
- const workerEntry = `
213
- import handler from "./_server/entry-server.js"
214
-
215
- export default {
216
- async fetch(request, env, ctx) {
217
- const url = new URL(request.url)
218
-
219
- // Let Cloudflare serve static assets (files with extensions)
220
- // This check is a fallback — Pages routes static files automatically
221
- const ext = url.pathname.split(".").pop()
222
- if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
223
- // Cloudflare Pages handles static assets automatically via its asset binding
224
- // Only reach here if the file doesn't exist — fall through to SSR
225
- }
226
-
227
- // SSR handler
228
- try {
229
- return await handler(request)
230
- } catch (err) {
231
- return new Response("Internal Server Error", { status: 500 })
232
- }
233
- },
234
- }
235
- `.trimStart();
236
- await writeFile(join(outDir, "_worker.js"), workerEntry);
237
- await writeFile(join(outDir, "_routes.json"), JSON.stringify({
238
- version: 1,
239
- include: ["/*"],
240
- exclude: [
241
- "/assets/*",
242
- "/favicon.*",
243
- "/site.webmanifest",
244
- "/robots.txt",
245
- "/sitemap.xml"
246
- ]
247
- }, null, 2));
248
- }
249
- };
250
- }
251
-
252
- //#endregion
253
- //#region src/adapters/netlify.ts
254
- /**
255
- * Netlify adapter — generates output for Netlify Functions (v2).
256
- *
257
- * Produces:
258
- * - Client assets in `publish/` directory
259
- * - `netlify/functions/ssr.mjs` — Netlify Function for SSR
260
- * - `netlify.toml` — routing configuration
261
137
  *
262
138
  * @example
263
- * ```ts
264
- * // zero.config.ts
265
- * import { defineConfig } from "@pyreon/zero"
266
- *
267
- * export default defineConfig({
268
- * adapter: "netlify",
139
+ * // Custom revalidate impl (e.g. for a self-hosted Pyreon SSR runtime
140
+ * // that wants build-time revalidate behavior without Vercel's
141
+ * // `res.revalidate()` API):
142
+ * export const POST = vercelRevalidateHandler({
143
+ * onRevalidate: async (path) => {
144
+ * // Clear your in-process ISR cache, emit a metrics event, etc.
145
+ * await myCache.invalidate(path)
146
+ * },
269
147
  * })
270
- * ```
271
- */
272
- function netlifyAdapter() {
273
- return {
274
- name: "netlify",
275
- async build(options) {
276
- await validateBuildInputs(options);
277
- const { writeFile, cp, mkdir } = await import("node:fs/promises");
278
- const { join } = await import("node:path");
279
- const outDir = options.outDir;
280
- const publishDir = join(outDir, "publish");
281
- const functionsDir = join(outDir, "netlify", "functions");
282
- await mkdir(publishDir, { recursive: true });
283
- await mkdir(functionsDir, { recursive: true });
284
- await cp(options.clientOutDir, publishDir, { recursive: true });
285
- await cp(join(options.serverEntry, ".."), join(functionsDir, "_server"), { recursive: true });
286
- const funcEntry = `
287
- import handler from "./_server/entry-server.js"
288
-
289
- export default async function(req, context) {
290
- try {
291
- return await handler(req)
292
- } catch (err) {
293
- return new Response("Internal Server Error", { status: 500 })
294
- }
295
- }
296
-
297
- export const config = {
298
- path: "/*",
299
- preferStatic: true,
300
- }
301
- `.trimStart();
302
- await writeFile(join(functionsDir, "ssr.mjs"), funcEntry);
303
- const toml = `
304
- [build]
305
- publish = "publish"
306
- functions = "netlify/functions"
307
-
308
- [[headers]]
309
- for = "/assets/*"
310
- [headers.values]
311
- Cache-Control = "public, max-age=31536000, immutable"
312
-
313
- [[redirects]]
314
- from = "/*"
315
- to = "/.netlify/functions/ssr"
316
- status = 200
317
- conditions = {Role = ["admin", "user", ""]}
318
- `.trimStart();
319
- await writeFile(join(outDir, "netlify.toml"), toml);
320
- }
321
- };
322
- }
323
-
324
- //#endregion
325
- //#region src/adapters/node.ts
326
- /**
327
- * Node.js adapter — generates a standalone server entry using node:http.
328
148
  */
329
- function nodeAdapter() {
330
- return {
331
- name: "node",
332
- async build(options) {
333
- await validateBuildInputs(options);
334
- const { writeFile, cp, mkdir } = await import("node:fs/promises");
335
- const { join } = await import("node:path");
336
- const outDir = options.outDir;
337
- await mkdir(outDir, { recursive: true });
338
- await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
339
- await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
340
- const port = options.config.port ?? 3e3;
341
- const serverEntry = `
342
- import { createServer } from "node:http"
343
- import { readFile } from "node:fs/promises"
344
- import { join, extname } from "node:path"
345
- import { fileURLToPath } from "node:url"
346
-
347
- const __dirname = fileURLToPath(new URL(".", import.meta.url))
348
- const handler = (await import("./server/entry-server.js")).default
349
- const clientDir = join(__dirname, "client")
350
-
351
- const MIME_TYPES = {
352
- ".html": "text/html",
353
- ".js": "application/javascript",
354
- ".css": "text/css",
355
- ".json": "application/json",
356
- ".png": "image/png",
357
- ".jpg": "image/jpeg",
358
- ".svg": "image/svg+xml",
359
- ".woff2": "font/woff2",
360
- ".woff": "font/woff",
361
- ".ico": "image/x-icon",
362
- }
363
-
364
- const server = createServer(async (req, res) => {
365
- const url = new URL(req.url ?? "/", "http://localhost")
366
-
367
- // Try to serve static files first
368
- if (req.method === "GET") {
369
- try {
370
- const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
371
- // Prevent path traversal — ensure resolved path stays within clientDir
372
- const { resolve } = await import("node:path")
373
- const resolved = resolve(filePath)
374
- if (!resolved.startsWith(resolve(clientDir))) {
375
- res.writeHead(403)
376
- res.end("Forbidden")
377
- return
378
- }
379
- const ext = extname(filePath)
380
- if (ext && ext !== ".html") {
381
- const data = await readFile(filePath)
382
- const mime = MIME_TYPES[ext] || "application/octet-stream"
383
- res.writeHead(200, {
384
- "content-type": mime,
385
- "cache-control": ext === ".js" || ext === ".css"
386
- ? "public, max-age=31536000, immutable"
387
- : "public, max-age=3600",
388
- })
389
- res.end(data)
390
- return
391
- }
392
- } catch {}
393
- }
394
-
395
- // Fall through to SSR handler
396
- const headers = {}
397
- for (const [key, value] of Object.entries(req.headers)) {
398
- if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
399
- }
400
-
401
- const request = new Request(url.href, {
402
- method: req.method,
403
- headers,
404
- })
405
-
406
- const response = await handler(request)
407
- const body = await response.text()
408
-
409
- const responseHeaders = {}
410
- response.headers.forEach((v, k) => { responseHeaders[k] = v })
411
-
412
- res.writeHead(response.status, responseHeaders)
413
- res.end(body)
414
- })
415
-
416
- server.listen(${port}, () => {
417
- console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
418
- })
419
- `.trimStart();
420
- await writeFile(join(outDir, "index.js"), serverEntry);
421
- await writeFile(join(outDir, "package.json"), JSON.stringify({ type: "module" }, null, 2));
422
- }
423
- };
424
- }
425
-
426
- //#endregion
427
- //#region src/adapters/static.ts
428
149
  /**
429
- * Static adapter just copies the client build output.
430
- * Used with SSG mode where all pages are pre-rendered at build time.
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).
431
154
  */
432
- function staticAdapter() {
433
- return {
434
- name: "static",
435
- async build(options) {
436
- const { cp, mkdir } = await import("node:fs/promises");
437
- await mkdir(options.outDir, { recursive: true });
438
- await cp(options.clientOutDir, options.outDir, { recursive: true });
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 };
439
175
  }
440
- };
441
- }
442
-
443
- //#endregion
444
- //#region src/adapters/vercel.ts
445
- /**
446
- * Vercel adapter — generates output for Vercel's Build Output API v3.
447
- *
448
- * Produces a `.vercel/output` directory with:
449
- * - `static/` — client-side assets (JS, CSS, images)
450
- * - `functions/ssr.func/` — serverless function for SSR
451
- * - `config.json` — routing configuration
452
- *
453
- * @example
454
- * ```ts
455
- * // zero.config.ts
456
- * import { defineConfig } from "@pyreon/zero"
457
- *
458
- * export default defineConfig({
459
- * adapter: "vercel",
460
- * })
461
- * ```
462
- */
463
- function vercelAdapter() {
464
- return {
465
- name: "vercel",
466
- async build(options) {
467
- await validateBuildInputs(options);
468
- const { writeFile, cp, mkdir } = await import("node:fs/promises");
469
- const { join } = await import("node:path");
470
- const vercelDir = join(options.outDir, ".vercel", "output");
471
- const staticDir = join(vercelDir, "static");
472
- const funcDir = join(vercelDir, "functions", "ssr.func");
473
- await mkdir(staticDir, { recursive: true });
474
- await mkdir(funcDir, { recursive: true });
475
- await cp(options.clientOutDir, staticDir, { recursive: true });
476
- await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
477
- const funcEntry = `
478
- export default async function handler(req) {
479
- const handler = (await import("./entry-server.js")).default
480
- return handler(req)
481
- }
482
- `.trimStart();
483
- await writeFile(join(funcDir, "index.js"), funcEntry);
484
- await writeFile(join(funcDir, ".vc-config.json"), JSON.stringify({
485
- runtime: "nodejs20.x",
486
- handler: "index.js",
487
- launcherType: "Nodejs"
488
- }, null, 2));
489
- await writeFile(join(vercelDir, "config.json"), JSON.stringify({
490
- version: 3,
491
- routes: [
492
- {
493
- src: "/assets/(.*)",
494
- headers: { "Cache-Control": "public, max-age=31536000, immutable" }
495
- },
496
- {
497
- src: "/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)",
498
- dest: "/$1"
499
- },
500
- {
501
- src: "/(.*)",
502
- dest: "/ssr"
503
- }
504
- ]
505
- }, null, 2));
176
+ if ("error" in cache) return new Response(`Server misconfigured: revalidate manifest at ${manifestPath} unreadable or malformed`, { status: 500 });
177
+ if (!Object.prototype.hasOwnProperty.call(cache.manifest.revalidate, path)) return new Response(`Path "${path}" not in revalidate manifest`, { status: 404 });
178
+ if (options.onRevalidate) try {
179
+ await options.onRevalidate(path);
180
+ } catch (err) {
181
+ return new Response(`Revalidation failed for "${path}": ${err instanceof Error ? err.message : String(err)}`, { status: 500 });
506
182
  }
183
+ return new Response(JSON.stringify({
184
+ revalidated: true,
185
+ path
186
+ }), {
187
+ status: 200,
188
+ headers: { "Content-Type": "application/json" }
189
+ });
507
190
  };
508
191
  }
509
-
510
- //#endregion
511
- //#region src/adapters/index.ts
512
192
  /**
513
- * Resolve the adapter from config.
514
- * Returns a built-in adapter or throws if unknown.
193
+ * Reset the in-process manifest cache. Test-only — production code never
194
+ * reaches this. Used by unit tests to exercise the "manifest changed
195
+ * between requests" path without spinning up a new handler.
196
+ * @internal
515
197
  */
516
- function resolveAdapter(config) {
517
- const name = config.adapter ?? "node";
518
- switch (name) {
519
- case "node": return nodeAdapter();
520
- case "bun": return bunAdapter();
521
- case "static": return staticAdapter();
522
- case "vercel": return vercelAdapter();
523
- case "cloudflare": return cloudflareAdapter();
524
- case "netlify": return netlifyAdapter();
525
- default: throw new Error(`[Pyreon] Unknown adapter: "${name}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`);
526
- }
527
- }
198
+ function _resetVercelRevalidateHandlerCache(handler) {}
528
199
 
529
200
  //#endregion
530
201
  //#region src/middleware.ts
@@ -1150,12 +821,16 @@ async function addDevBadgeToPng(pngBuffer, size) {
1150
821
  //#region src/seo.ts
1151
822
  /**
1152
823
  * Generate a sitemap.xml string from route file paths.
824
+ *
825
+ * When `i18n` is set (PR K — passed by `seoPlugin` after reading the
826
+ * i18n config from `zero({ i18n: ... })`), URLs are clustered by their
827
+ * un-prefixed (default-locale) form and each `<url>` carries
828
+ * `<xhtml:link rel="alternate" hreflang="...">` siblings for every
829
+ * locale variant + an `x-default` entry pointing at the default locale.
1153
830
  */
1154
- function generateSitemap(routeFiles, config) {
831
+ function generateSitemap(routeFiles, config, i18n) {
1155
832
  const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
1156
- return `<?xml version="1.0" encoding="UTF-8"?>
1157
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
1158
- ${[...routeFiles.filter((f) => {
833
+ const clusters = clusterPathsByLocale([...routeFiles.filter((f) => {
1159
834
  const name = f.split("/").pop()?.replace(/\.\w+$/, "");
1160
835
  return name !== "_layout" && name !== "_error" && name !== "_loading";
1161
836
  }).map((f) => {
@@ -1168,19 +843,141 @@ ${[...routeFiles.filter((f) => {
1168
843
  path: p,
1169
844
  changefreq,
1170
845
  priority
1171
- })), ...config.additionalPaths ?? []].map((entry) => {
1172
- return ` <url>
1173
- <loc>${escapeXml$1(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
1174
- <changefreq>${entry.changefreq ?? changefreq}</changefreq>
1175
- <priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
1176
- </url>`;
1177
- }).join("\n")}
846
+ })), ...config.additionalPaths ?? []], i18n);
847
+ return `<?xml version="1.0" encoding="UTF-8"?>
848
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${i18n != null && i18n.locales.length > 0 ? " xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"" : ""}>
849
+ ${clusters.map((cluster) => renderClusterEntry(cluster, origin, changefreq, priority, i18n)).join("\n")}
1178
850
  </urlset>`;
1179
851
  }
852
+ /**
853
+ * Cluster URL entries by their un-prefixed (default-locale) form.
854
+ *
855
+ * Each output cluster has:
856
+ * - `canonical`: the SitemapEntry that should be used as the `<url>`
857
+ * payload (default-locale variant; falls back to the first variant
858
+ * if no default-locale entry exists in the cluster).
859
+ * - `variantsByLocale`: Map of locale → SitemapEntry for the cluster.
860
+ *
861
+ * Without i18n, every entry becomes its own single-variant cluster.
862
+ *
863
+ * @internal — exported for unit testing.
864
+ */
865
+ function clusterPathsByLocale(entries, i18n) {
866
+ if (i18n == null || i18n.locales.length === 0) return entries.map((entry) => ({
867
+ canonical: entry,
868
+ variantsByLocale: new Map([[null, entry]])
869
+ }));
870
+ const strategy = i18n.strategy ?? "prefix-except-default";
871
+ const { defaultLocale, locales } = i18n;
872
+ const byUnPrefixed = /* @__PURE__ */ new Map();
873
+ for (const entry of entries) {
874
+ const { unPrefixed, locale } = stripLocalePrefix(entry.path, locales, defaultLocale, strategy);
875
+ let cluster = byUnPrefixed.get(unPrefixed);
876
+ if (!cluster) {
877
+ cluster = /* @__PURE__ */ new Map();
878
+ byUnPrefixed.set(unPrefixed, cluster);
879
+ }
880
+ cluster.set(locale, entry);
881
+ }
882
+ const out = [];
883
+ for (const variantsByLocale of byUnPrefixed.values()) {
884
+ const canonical = variantsByLocale.get(defaultLocale) ?? variantsByLocale.get(null) ?? [...variantsByLocale.values()][0];
885
+ out.push({
886
+ canonical,
887
+ variantsByLocale
888
+ });
889
+ }
890
+ return out;
891
+ }
892
+ /**
893
+ * Strip the locale prefix from a path under the i18n strategy.
894
+ *
895
+ * Returns `{ unPrefixed, locale }`:
896
+ * - `/about` under `prefix-except-default` (default=en) → `{ unPrefixed: '/about', locale: 'en' }`
897
+ * - `/de/about` under either strategy → `{ unPrefixed: '/about', locale: 'de' }`
898
+ * - `/de` (locale root) → `{ unPrefixed: '/', locale: 'de' }`
899
+ * - `/about` under `prefix` → no locale match, returns `{ unPrefixed: '/about', locale: null }`
900
+ * (the URL doesn't fit any locale subtree — sitemap treats it as standalone).
901
+ *
902
+ * @internal — exported for unit testing.
903
+ */
904
+ function stripLocalePrefix(path, locales, defaultLocale, strategy) {
905
+ for (const locale of locales) {
906
+ if (path === `/${locale}`) return {
907
+ unPrefixed: "/",
908
+ locale
909
+ };
910
+ if (path.startsWith(`/${locale}/`)) return {
911
+ unPrefixed: path.slice(`/${locale}`.length),
912
+ locale
913
+ };
914
+ }
915
+ if (strategy === "prefix-except-default") return {
916
+ unPrefixed: path,
917
+ locale: defaultLocale
918
+ };
919
+ return {
920
+ unPrefixed: path,
921
+ locale: null
922
+ };
923
+ }
924
+ function renderClusterEntry(cluster, origin, changefreq, priority, i18n) {
925
+ const { canonical, variantsByLocale } = cluster;
926
+ const lines = [
927
+ " <url>",
928
+ ` <loc>${escapeXml$1(`${origin}${canonical.path === "/" ? "" : canonical.path}`)}</loc>`,
929
+ ` <changefreq>${canonical.changefreq ?? changefreq}</changefreq>`,
930
+ ` <priority>${canonical.priority ?? priority}</priority>`
931
+ ];
932
+ if (canonical.lastmod) lines.push(` <lastmod>${canonical.lastmod}</lastmod>`);
933
+ if (i18n != null && i18n.locales.length > 0 && variantsByLocale.size > 1) {
934
+ for (const locale of i18n.locales) {
935
+ const variant = variantsByLocale.get(locale);
936
+ if (!variant) continue;
937
+ const variantLoc = `${origin}${variant.path === "/" ? "" : variant.path}`;
938
+ lines.push(` <xhtml:link rel="alternate" hreflang="${escapeXml$1(locale)}" href="${escapeXml$1(variantLoc)}"/>`);
939
+ }
940
+ const defaultVariant = variantsByLocale.get(i18n.defaultLocale);
941
+ if (defaultVariant) {
942
+ const defaultLoc = `${origin}${defaultVariant.path === "/" ? "" : defaultVariant.path}`;
943
+ lines.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml$1(defaultLoc)}"/>`);
944
+ }
945
+ }
946
+ lines.push(" </url>");
947
+ return lines.join("\n");
948
+ }
1180
949
  function escapeXml$1(str) {
1181
950
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1182
951
  }
1183
952
  /**
953
+ * Resolve the i18n config to feed `generateSitemap` for hreflang
954
+ * emission. Priority order:
955
+ * 1. Explicit user config — `hreflang: I18nRoutingConfig` (object)
956
+ * 2. Auto-detect from SSG manifest — `hreflang: true` + `manifestI18n`
957
+ * present (only happens in SSG mode where the manifest exists)
958
+ * 3. Nothing — emit plain sitemap without xhtml:link siblings
959
+ *
960
+ * @internal — exported for unit testing.
961
+ */
962
+ function resolveHreflangI18n(hreflang, manifestI18n) {
963
+ if (hreflang == null || hreflang === false) return void 0;
964
+ if (hreflang === true) return manifestI18n;
965
+ return hreflang;
966
+ }
967
+ /**
968
+ * Duck-type guard for `I18nRoutingConfig`. The SSG manifest is JSON,
969
+ * so the embedded i18n field could in principle be malformed if a
970
+ * downstream user hand-edits the manifest (don't). Validate the shape
971
+ * before trusting it.
972
+ *
973
+ * @internal
974
+ */
975
+ function isI18nRoutingConfig(value) {
976
+ if (value == null || typeof value !== "object") return false;
977
+ const v = value;
978
+ return Array.isArray(v.locales) && v.locales.every((l) => typeof l === "string") && typeof v.defaultLocale === "string";
979
+ }
980
+ /**
1184
981
  * Generate a robots.txt string.
1185
982
  */
1186
983
  function generateRobots(config = {}) {
@@ -1231,22 +1028,33 @@ function jsonLd(data) {
1231
1028
  * pyreon(),
1232
1029
  * zero(),
1233
1030
  * seoPlugin({
1234
- * sitemap: { origin: "https://example.com" },
1031
+ * sitemap: {
1032
+ * origin: "https://example.com",
1033
+ * useSsgPaths: true, // include dynamic-route enumerations
1034
+ * },
1235
1035
  * robots: { sitemap: "https://example.com/sitemap.xml" },
1236
1036
  * }),
1237
1037
  * ],
1238
1038
  * }
1239
1039
  */
1240
1040
  function seoPlugin(config = {}) {
1041
+ const useSsgPaths = config.sitemap?.useSsgPaths === true;
1042
+ let distDir = "";
1241
1043
  return {
1242
1044
  name: "pyreon-zero-seo",
1243
1045
  apply: "build",
1046
+ ...useSsgPaths ? { enforce: "post" } : {},
1047
+ configResolved(resolved) {
1048
+ distDir = resolve(resolved.root, resolved.build.outDir);
1049
+ },
1244
1050
  async generateBundle(_, _bundle) {
1245
- if (config.sitemap) {
1246
- const { scanRouteFiles } = await import("./fs-router-ZebyutPa.js").then((n) => n.n);
1051
+ if (config.sitemap && !useSsgPaths) {
1052
+ const { scanRouteFiles } = await import("./fs-router-MewHc5SB.js").then((n) => n.n);
1247
1053
  const routesDir = `${process.cwd()}/src/routes`;
1248
1054
  try {
1249
- const sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
1055
+ const files = await scanRouteFiles(routesDir);
1056
+ const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, void 0);
1057
+ const sitemap = generateSitemap(files, config.sitemap, hreflangI18n);
1250
1058
  this.emitFile({
1251
1059
  type: "asset",
1252
1060
  fileName: "sitemap.xml",
@@ -1262,6 +1070,36 @@ function seoPlugin(config = {}) {
1262
1070
  source: robots
1263
1071
  });
1264
1072
  }
1073
+ },
1074
+ async closeBundle() {
1075
+ if (!config.sitemap || !useSsgPaths) return;
1076
+ const { scanRouteFiles } = await import("./fs-router-MewHc5SB.js").then((n) => n.n);
1077
+ const routesDir = `${process.cwd()}/src/routes`;
1078
+ const manifestPath = join(distDir, "_pyreon-ssg-paths.json");
1079
+ try {
1080
+ let ssgPaths = [];
1081
+ let manifestI18n;
1082
+ if (existsSync(manifestPath)) {
1083
+ const raw = await readFile(manifestPath, "utf-8");
1084
+ const parsed = JSON.parse(raw);
1085
+ if (Array.isArray(parsed.paths)) ssgPaths = parsed.paths.filter((p) => typeof p === "string").map((path) => ({ path }));
1086
+ if (isI18nRoutingConfig(parsed.i18n)) manifestI18n = parsed.i18n;
1087
+ try {
1088
+ await rm(manifestPath, { force: true });
1089
+ } catch {}
1090
+ }
1091
+ let files = [];
1092
+ try {
1093
+ files = await scanRouteFiles(routesDir);
1094
+ } catch {}
1095
+ const merged = {
1096
+ ...config.sitemap,
1097
+ additionalPaths: [...ssgPaths, ...config.sitemap.additionalPaths ?? []]
1098
+ };
1099
+ const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, manifestI18n);
1100
+ const sitemap = generateSitemap(files, merged, hreflangI18n);
1101
+ await writeFile(join(distDir, "sitemap.xml"), sitemap, "utf-8");
1102
+ } catch {}
1265
1103
  }
1266
1104
  };
1267
1105
  }
@@ -1273,7 +1111,7 @@ function seoMiddleware(config = {}) {
1273
1111
  return async (ctx) => {
1274
1112
  if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
1275
1113
  if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
1276
- const { scanRouteFiles } = await import("./fs-router-ZebyutPa.js").then((n) => n.n);
1114
+ const { scanRouteFiles } = await import("./fs-router-MewHc5SB.js").then((n) => n.n);
1277
1115
  const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
1278
1116
  return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
1279
1117
  } catch {}
@@ -1351,14 +1189,14 @@ function buildTextOverlaySvg(layers, width, height, locale) {
1351
1189
  const lines = [];
1352
1190
  let currentLine = "";
1353
1191
  const estimateWidth = (s) => {
1354
- let width = 0;
1192
+ let w = 0;
1355
1193
  for (let i = 0; i < s.length; i++) {
1356
1194
  const code = s.charCodeAt(i);
1357
- if (code >= 12288 && code <= 40959) width += fontSize * 1;
1358
- else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) width += fontSize * .35;
1359
- else width += fontSize * .55;
1195
+ if (code >= 12288 && code <= 40959) w += fontSize * 1;
1196
+ else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) w += fontSize * .35;
1197
+ else w += fontSize * .55;
1360
1198
  }
1361
- return width;
1199
+ return w;
1362
1200
  };
1363
1201
  for (const word of words) {
1364
1202
  const testLine = currentLine ? `${currentLine} ${word}` : word;
@@ -1918,133 +1756,5 @@ function capitalize(s) {
1918
1756
  }
1919
1757
 
1920
1758
  //#endregion
1921
- //#region src/i18n-routing.ts
1922
- /**
1923
- * Detect preferred locale from Accept-Language header.
1924
- */
1925
- function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
1926
- if (!acceptLanguage) return defaultLocale;
1927
- const preferred = acceptLanguage.split(",").map((part) => {
1928
- const [lang, q] = part.trim().split(";q=");
1929
- return {
1930
- lang: lang?.split("-")[0]?.toLowerCase() ?? "",
1931
- quality: q ? Number.parseFloat(q) : 1
1932
- };
1933
- }).sort((a, b) => b.quality - a.quality);
1934
- for (const { lang } of preferred) if (locales.includes(lang)) return lang;
1935
- return defaultLocale;
1936
- }
1937
- /**
1938
- * Extract locale from a URL path.
1939
- * Returns { locale, pathWithoutLocale }.
1940
- */
1941
- function extractLocaleFromPath(path, locales, defaultLocale) {
1942
- const segments = path.split("/").filter(Boolean);
1943
- const firstSegment = segments[0]?.toLowerCase();
1944
- if (firstSegment && locales.includes(firstSegment)) return {
1945
- locale: firstSegment,
1946
- pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
1947
- };
1948
- return {
1949
- locale: defaultLocale,
1950
- pathWithoutLocale: path
1951
- };
1952
- }
1953
- /**
1954
- * Build a localized path.
1955
- */
1956
- function buildLocalePath(path, locale, defaultLocale, strategy) {
1957
- const clean = path === "/" ? "" : path;
1958
- if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
1959
- return `/${locale}${clean}`;
1960
- }
1961
- /**
1962
- * Create a LocaleContext for use in components and loaders.
1963
- */
1964
- function createLocaleContext(locale, path, config) {
1965
- const strategy = config.strategy ?? "prefix-except-default";
1966
- return {
1967
- locale,
1968
- locales: config.locales,
1969
- defaultLocale: config.defaultLocale,
1970
- localePath(targetPath, targetLocale) {
1971
- return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
1972
- },
1973
- alternates() {
1974
- const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
1975
- return config.locales.map((loc) => ({
1976
- locale: loc,
1977
- url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
1978
- }));
1979
- }
1980
- };
1981
- }
1982
- /**
1983
- * I18n routing middleware for Zero's server.
1984
- *
1985
- * - Detects locale from URL prefix or Accept-Language header
1986
- * - Redirects root to preferred locale (when detectLocale is true)
1987
- * - Sets locale context for loaders and components
1988
- *
1989
- * @example
1990
- * ```ts
1991
- * // zero.config.ts
1992
- * import { i18nRouting } from "@pyreon/zero"
1993
- *
1994
- * export default defineConfig({
1995
- * plugins: [
1996
- * i18nRouting({
1997
- * locales: ["en", "de", "cs"],
1998
- * defaultLocale: "en",
1999
- * }),
2000
- * ],
2001
- * })
2002
- * ```
2003
- */
2004
- function i18nRouting(config) {
2005
- const strategy = config.strategy ?? "prefix-except-default";
2006
- const detectEnabled = config.detectLocale !== false;
2007
- const cookieName = config.cookieName ?? "locale";
2008
- return {
2009
- name: "pyreon-zero-i18n-routing",
2010
- configResolved() {},
2011
- configureServer(server) {
2012
- server.middlewares.use((req, res, next) => {
2013
- const url = req.url ?? "/";
2014
- if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
2015
- const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
2016
- if (detectEnabled && url === "/") {
2017
- const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
2018
- const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
2019
- const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
2020
- if (strategy === "prefix" || preferred !== config.defaultLocale) {
2021
- res.writeHead(302, { Location: `/${preferred}/` });
2022
- res.end();
2023
- return;
2024
- }
2025
- }
2026
- req.__locale = locale;
2027
- req.__localeContext = createLocaleContext(locale, url, config);
2028
- localeSignal.set(locale);
2029
- next();
2030
- });
2031
- }
2032
- };
2033
- }
2034
- function parseCookies(header) {
2035
- if (!header) return {};
2036
- const result = {};
2037
- for (const pair of header.split(";")) {
2038
- const [key, value] = pair.trim().split("=");
2039
- if (key && value) result[key] = decodeURIComponent(value);
2040
- }
2041
- return result;
2042
- }
2043
- /** @internal Context for the current locale. */
2044
- const LocaleCtx = createContext("en");
2045
- /** Current locale signal — set by the server middleware or client-side detection. */
2046
- const localeSignal = signal("en");
2047
-
2048
- //#endregion
2049
- export { aiPlugin, bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter };
1759
+ export { _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
2050
1760
  //# sourceMappingURL=server.js.map