@pyreon/zero 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
  2. package/lib/client.js +4 -1
  3. package/lib/env.js +6 -6
  4. package/lib/font.js +3 -3
  5. package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
  6. package/lib/i18n-routing.js +112 -1
  7. package/lib/image.js +140 -58
  8. package/lib/index.js +252 -82
  9. package/lib/og-image.js +5 -5
  10. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  11. package/lib/script.js +114 -25
  12. package/lib/seo.js +186 -15
  13. package/lib/server.js +274 -564
  14. package/lib/types/config.d.ts +307 -3
  15. package/lib/types/env.d.ts +2 -2
  16. package/lib/types/i18n-routing.d.ts +193 -2
  17. package/lib/types/image.d.ts +105 -5
  18. package/lib/types/index.d.ts +666 -182
  19. package/lib/types/script.d.ts +78 -6
  20. package/lib/types/seo.d.ts +128 -4
  21. package/lib/types/server.d.ts +607 -72
  22. package/lib/vite-plugin-y0NmCLJA.js +2476 -0
  23. package/package.json +11 -10
  24. package/src/adapters/bun.ts +20 -1
  25. package/src/adapters/cloudflare.ts +78 -1
  26. package/src/adapters/index.ts +25 -3
  27. package/src/adapters/netlify.ts +63 -1
  28. package/src/adapters/node.ts +25 -1
  29. package/src/adapters/static.ts +26 -1
  30. package/src/adapters/validate.ts +8 -1
  31. package/src/adapters/vercel.ts +76 -1
  32. package/src/adapters/warn-missing-env.ts +49 -0
  33. package/src/app.ts +14 -0
  34. package/src/client.ts +18 -0
  35. package/src/entry-server.ts +55 -5
  36. package/src/env.ts +7 -7
  37. package/src/font.ts +3 -3
  38. package/src/fs-router.ts +72 -3
  39. package/src/i18n-routing.ts +246 -12
  40. package/src/image.tsx +242 -91
  41. package/src/index.ts +4 -4
  42. package/src/isr.ts +24 -6
  43. package/src/manifest.ts +675 -0
  44. package/src/og-image.ts +5 -5
  45. package/src/script.tsx +159 -36
  46. package/src/seo.ts +346 -15
  47. package/src/server.ts +10 -2
  48. package/src/ssg-plugin.ts +1211 -54
  49. package/src/types.ts +333 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +171 -41
  52. package/lib/vite-plugin-E4BHYvYW.js +0 -855
@@ -4,6 +4,7 @@ import * as _$_pyreon_router0 from "@pyreon/router";
4
4
  import { RouteRecord } from "@pyreon/router";
5
5
  import { Middleware, MiddlewareContext } from "@pyreon/server";
6
6
  import { Plugin } from "vite";
7
+
7
8
  //#region src/app.d.ts
8
9
  interface CreateAppOptions {
9
10
  /** Route definitions (from file-based routing or manual). */
@@ -16,6 +17,18 @@ interface CreateAppOptions {
16
17
  layout?: ComponentFn;
17
18
  /** Global error component. */
18
19
  errorComponent?: ComponentFn;
20
+ /**
21
+ * Base URL prefix for the deployed app (e.g. `/blog/`). Forwarded to
22
+ * `createRouter({ base })` so RouterLinks render correctly prefixed
23
+ * hrefs (`<a href="/blog/about">` instead of `<a href="/about">`) and
24
+ * the router strips the prefix from incoming URLs before matching.
25
+ *
26
+ * Default: `'/'`. Pre-fix this was disconnected from `zero({ base })`
27
+ * — RouterLinks rendered un-prefixed hrefs even when Vite's asset URL
28
+ * rewriting was correctly using the prefix, causing client-side
29
+ * navigation to break against subpath deploys.
30
+ */
31
+ base?: string;
19
32
  }
20
33
  /**
21
34
  * Create a full Zero app — assembles router, head provider, and root layout.
@@ -61,6 +74,66 @@ interface ApiRouteEntry {
61
74
  module: ApiRouteModule;
62
75
  }
63
76
  //#endregion
77
+ //#region src/i18n-routing.d.ts
78
+ interface I18nRoutingConfig {
79
+ /** Supported locales. e.g. ["en", "de", "cs"] */
80
+ locales: string[];
81
+ /** Default locale — served without prefix (/ instead of /en/). */
82
+ defaultLocale: string;
83
+ /** Redirect root to detected locale. Default: true */
84
+ detectLocale?: boolean;
85
+ /** Cookie name to persist locale preference. Default: "locale" */
86
+ cookieName?: string;
87
+ /** URL strategy. Default: "prefix-except-default" */
88
+ strategy?: 'prefix' | 'prefix-except-default';
89
+ }
90
+ interface LocaleContext {
91
+ /** Current locale code. e.g. "en", "de" */
92
+ locale: string;
93
+ /** All supported locales. */
94
+ locales: string[];
95
+ /** Default locale. */
96
+ defaultLocale: string;
97
+ /** Build a localized path. e.g. localePath("/about", "de") → "/de/about" */
98
+ localePath: (path: string, locale?: string) => string;
99
+ /** Get hreflang alternates for the current path. */
100
+ alternates: () => Array<{
101
+ locale: string;
102
+ url: string;
103
+ }>;
104
+ }
105
+ /**
106
+ * Detect preferred locale from Accept-Language header.
107
+ */
108
+ declare function detectLocaleFromHeader(acceptLanguage: string | null | undefined, locales: string[], defaultLocale: string): string;
109
+ /**
110
+ * Create a LocaleContext for use in components and loaders.
111
+ */
112
+ declare function createLocaleContext(locale: string, path: string, config: I18nRoutingConfig): LocaleContext;
113
+ /**
114
+ * I18n routing middleware for Zero's server.
115
+ *
116
+ * - Detects locale from URL prefix or Accept-Language header
117
+ * - Redirects root to preferred locale (when detectLocale is true)
118
+ * - Sets locale context for loaders and components
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * // zero.config.ts
123
+ * import { i18nRouting } from "@pyreon/zero"
124
+ *
125
+ * export default defineConfig({
126
+ * plugins: [
127
+ * i18nRouting({
128
+ * locales: ["en", "de", "cs"],
129
+ * defaultLocale: "en",
130
+ * }),
131
+ * ],
132
+ * })
133
+ * ```
134
+ */
135
+ declare function i18nRouting(config: I18nRoutingConfig): Plugin;
136
+ //#endregion
64
137
  //#region src/types.d.ts
65
138
  type RenderMode = 'ssr' | 'ssg' | 'spa' | 'isr';
66
139
  interface ISRConfig {
@@ -73,6 +146,42 @@ interface ISRConfig {
73
146
  * space (e.g. `/user/:id` where `:id` is free-form).
74
147
  */
75
148
  maxEntries?: number;
149
+ /**
150
+ * Cache-key derivation function. The default keys cache entries by
151
+ * `url.pathname` ONLY — query strings, cookies, and headers are
152
+ * stripped.
153
+ *
154
+ * **⚠️ Auth-gated incompatibility.** The default behavior is
155
+ * unsafe for request-dependent loaders. A loader that reads
156
+ * `request.headers.get('cookie')` to gate auth will render ONCE
157
+ * with the first user's cookie, then serve that HTML to every
158
+ * subsequent user. To use ISR with personalized / auth-gated
159
+ * pages, supply a `cacheKey` that varies on the auth identifier
160
+ * (session cookie, user-id header, etc.), OR don't use ISR for
161
+ * such routes — use SSR instead.
162
+ *
163
+ * @example
164
+ * // Vary cache by session cookie:
165
+ * isr: {
166
+ * revalidate: 60,
167
+ * cacheKey: (req) => {
168
+ * const url = new URL(req.url)
169
+ * const session = req.headers.get('cookie')?.match(/session=([^;]+)/)?.[1] ?? 'anon'
170
+ * return `${url.pathname}::${session}`
171
+ * },
172
+ * }
173
+ *
174
+ * @example
175
+ * // Vary by a query parameter:
176
+ * isr: {
177
+ * revalidate: 60,
178
+ * cacheKey: (req) => {
179
+ * const url = new URL(req.url)
180
+ * return `${url.pathname}?sort=${url.searchParams.get('sort') ?? ''}`
181
+ * },
182
+ * }
183
+ */
184
+ cacheKey?: (req: Request) => string;
76
185
  }
77
186
  interface ZeroConfig {
78
187
  /** Default rendering mode. Default: "ssr" */
@@ -86,13 +195,199 @@ interface ZeroConfig {
86
195
  /** SSG options — only used when mode is "ssg". */
87
196
  ssg?: {
88
197
  /** Paths to prerender (or function returning paths). */paths?: string[] | (() => string[] | Promise<string[]>);
198
+ /**
199
+ * Auto-emit `dist/404.html` from the route tree's `_404.tsx` /
200
+ * `_not-found.tsx` convention. fs-router already wires `_404.tsx` as
201
+ * `notFoundComponent` on its parent layout route; the SSG plugin walks
202
+ * the tree, picks up the first one, renders it through the same SSR
203
+ * pipeline as regular paths (so styler CSS / @pyreon/head metadata land
204
+ * correctly), and writes the result to `dist/404.html`. Static hosts
205
+ * (Netlify, Cloudflare Pages, GitHub Pages, S3+CloudFront) serve this
206
+ * file automatically for unmatched URLs. Default: `true`. Set to
207
+ * `false` to opt out — the route tree is left alone.
208
+ */
209
+ emit404?: boolean;
210
+ /**
211
+ * When a route loader throws `redirect('/target')` during SSG, write
212
+ * a `dist/_redirects` file (Netlify / Cloudflare Pages convention)
213
+ * AND a `dist/_redirects.json` (Vercel convention) listing every
214
+ * redirected source path → target. Static hosts pick whichever
215
+ * format their platform supports automatically. The redirected
216
+ * path's HTML file is NOT emitted — the redirect is the response.
217
+ *
218
+ * Without this option, redirect-throwing loaders land in
219
+ * `errors[]` and the path silently disappears from the build —
220
+ * the user sees no output for `/old` AND no warning that the
221
+ * loader ran a redirect. Default: `true`. Set to `false` to
222
+ * restore the pre-PR-B behaviour (redirects treated as errors).
223
+ */
224
+ emitRedirects?: boolean;
225
+ /**
226
+ * Additionally emit a static HTML file at the source path with a
227
+ * `<meta http-equiv="refresh">` redirect — for adapters / hosts
228
+ * that don't read `_redirects` (plain S3, GitHub Pages, simple
229
+ * file servers). The meta-refresh fallback works on any HTTP
230
+ * server that serves static files.
231
+ *
232
+ * - `'none'` (default): only `_redirects` / `_redirects.json` are
233
+ * emitted; no per-redirect HTML file.
234
+ * - `'meta-refresh'`: emit `dist/<source>/index.html` containing
235
+ * `<meta http-equiv="refresh" content="0; url=<target>">` plus
236
+ * a canonical link tag for SEO. Status code information is
237
+ * lost (meta-refresh has no status equivalent), so 301/302/307/
238
+ * 308 all collapse to "client-side refresh".
239
+ */
240
+ redirectsAsHtml?: 'none' | 'meta-refresh';
241
+ /**
242
+ * Callback invoked when a path's render throws (loader-throw that
243
+ * isn't a `redirect()`, render exception, anything that lands in the
244
+ * `errors[]` collection). Returns either:
245
+ * - `string` → written as the path's HTML in place of the failed
246
+ * render. Use this to emit a per-path fallback page (e.g. a generic
247
+ * "this content is temporarily unavailable" template) so static
248
+ * hosts have something to serve at that URL instead of 404'ing.
249
+ * - `null` → skip; the path produces no HTML output. The error
250
+ * stays in `errors[]` for the post-build summary.
251
+ *
252
+ * The callback runs ONCE per failed path. Async callbacks are
253
+ * awaited. If the callback itself throws, the throw is captured as
254
+ * a separate error entry and the path is skipped (no fallback HTML).
255
+ * Default: `undefined` — failed paths just land in `errors[]`.
256
+ *
257
+ * @example
258
+ * ssg: {
259
+ * onPathError: async (path, error) => {
260
+ * console.error(\`SSG render failed for \${path}:\`, error)
261
+ * return \`<!DOCTYPE html><html><body><h1>Page unavailable</h1></body></html>\`
262
+ * },
263
+ * }
264
+ */
265
+ onPathError?: (path: string, error: unknown) => string | null | Promise<string | null>;
266
+ /**
267
+ * When `'json'` (default), write `dist/_pyreon-ssg-errors.json` after
268
+ * the render loop summarising every error encountered (path traversal,
269
+ * timeout, render exception, getStaticPaths throw, fallback callback
270
+ * throw). Each entry has `{ path, message, name, stack }`. The file
271
+ * is ONLY written when `errors.length > 0` — successful builds don't
272
+ * leak an empty manifest. Reading it lets CI gate on render failures
273
+ * without parsing console output (e.g.
274
+ * `cat dist/_pyreon-ssg-errors.json | jq '.errors | length' | grep -q 0`).
275
+ *
276
+ * Set to `'none'` to opt out entirely — errors stay in console-only,
277
+ * matching pre-PR-G behaviour.
278
+ */
279
+ errorArtifact?: 'json' | 'none';
280
+ /**
281
+ * Maximum number of paths rendered in parallel during the SSG closeBundle
282
+ * loop. Default: `4` — a sensible balance between speedup and the risk
283
+ * of exhausting downstream resources (DB connection pools, fetch
284
+ * rate-limits) inside loaders. Set to `1` to render fully sequentially
285
+ * (the pre-PR-D behaviour). Set to a higher value for faster builds
286
+ * on CI / multi-core hosts; the practical ceiling is the number of
287
+ * loader-side concurrent connections your app's data layer tolerates.
288
+ *
289
+ * The render-error pipeline (`onPathError` callback, `errors[]`
290
+ * collection, `_pyreon-ssg-errors.json` artifact) is unchanged —
291
+ * concurrency only affects how many paths are in flight at once,
292
+ * not how their successes / failures are recorded.
293
+ *
294
+ * @example
295
+ * ssg: {
296
+ * concurrency: 8, // Faster builds for static-content sites
297
+ * }
298
+ */
299
+ concurrency?: number;
300
+ /**
301
+ * Per-path progress callback. Invoked once per path AFTER its render
302
+ * settles (success, redirect, OR failure) — never during in-flight
303
+ * renders. Receives `{ completed, total, currentPath, elapsed }`
304
+ * where:
305
+ * - `completed` is the count of paths whose render has settled (1-indexed)
306
+ * - `total` is the full path count from `resolvePaths()`
307
+ * - `currentPath` is the path that just settled
308
+ * - `elapsed` is wall-clock ms since the loop started
309
+ *
310
+ * Use cases: build-tool progress bars (Vite picks up stdout), CI
311
+ * heartbeat lines on long builds (10k-path sites take minutes —
312
+ * silent stretches look hung), build-time perf instrumentation.
313
+ *
314
+ * Async callbacks are awaited before the next path's progress fires,
315
+ * so a slow callback can serialize progress reporting (it does NOT
316
+ * gate the worker pool — paths keep rendering in parallel; only
317
+ * the progress callbacks themselves are serialized). Throws are
318
+ * captured into `errors[]` with the path suffix `(onProgress)` so
319
+ * a buggy callback can't take down the build.
320
+ *
321
+ * @example
322
+ * ssg: {
323
+ * onProgress: ({ completed, total, currentPath, elapsed }) => {
324
+ * console.log(`[${completed}/${total}] ${currentPath} (${elapsed}ms)`)
325
+ * },
326
+ * }
327
+ */
328
+ onProgress?: (info: {
329
+ completed: number;
330
+ total: number;
331
+ currentPath: string;
332
+ elapsed: number;
333
+ }) => void | Promise<void>;
334
+ /**
335
+ * Route-level code splitting in SSG mode. Default `true`.
336
+ *
337
+ * When `true` (default), each route file becomes its own dynamic-import
338
+ * chunk via `lazy(() => import("..."))` — only the route the user
339
+ * lands on plus its dependencies ship in the initial bundle, the
340
+ * rest fetch on navigation. Matches the SSR/SPA-mode behaviour zero
341
+ * has always had; brings parity to SSG.
342
+ *
343
+ * When `false`, every route is bundled statically into the main
344
+ * client chunk (the pre-2026-Q3 SSG behaviour). Useful for tiny
345
+ * sites (2-5 pages) where the single-chunk-then-instant-nav trade
346
+ * is preferable — the chunk-fetch cost on navigation is gone, and
347
+ * the marginal bytes are negligible.
348
+ *
349
+ * Crossover point: ~5-8 routes. Below that, single-chunk is fine.
350
+ * Above that, lazy() shrinks the initial bundle by a meaningful
351
+ * amount (a 50-route docs site might drop from 200 KB to 80 KB on
352
+ * first paint).
353
+ *
354
+ * Underlying mechanism is the same 3-tier generator zero already
355
+ * uses for SSR/SPA mode (`fs-router.ts:generateRouteEntry`): lazy
356
+ * component + inlined metadata when possible, lazy + lazy-thunked
357
+ * function exports when not, namespace-import fallback for cases
358
+ * the literal-extractor can't reach.
359
+ *
360
+ * @example
361
+ * ssg: {
362
+ * splitChunks: false, // bundle-everything for a 3-page marketing site
363
+ * }
364
+ */
365
+ splitChunks?: boolean;
89
366
  };
90
367
  /** ISR config — only used when mode is "isr". */
91
368
  isr?: ISRConfig;
92
- /** Deploy adapter. Default: "node" */
93
- adapter?: 'node' | 'bun' | 'static' | 'vercel' | 'cloudflare' | 'netlify';
369
+ /**
370
+ * Deploy adapter. Default: `"node"`.
371
+ *
372
+ * Accepts either a built-in adapter name (string) OR a constructed
373
+ * `Adapter` instance (e.g. `vercelAdapter()`). The scaffolded templates
374
+ * emit the instance form (`adapter: vercelAdapter()`) by convention.
375
+ * `resolveAdapter` (see `adapters/index.ts`) accepts both shapes —
376
+ * strings go through a switch lookup, instances pass through
377
+ * unchanged.
378
+ */
379
+ adapter?: 'node' | 'bun' | 'static' | 'vercel' | 'cloudflare' | 'netlify' | Adapter;
94
380
  /** Base URL path. Default: "/" */
95
381
  base?: string;
382
+ /**
383
+ * i18n routing — locale-prefixed route variants generated at build time
384
+ * (PR H of the SSG roadmap). When set, every `FileRoute` is fanned into
385
+ * per-locale duplicates by `expandRoutesForLocales` from
386
+ * `@pyreon/zero`. Independent from the `i18nRouting()` Vite plugin
387
+ * (which only handles request-time locale detection); both can be used
388
+ * together. See `expandRoutesForLocales` JSDoc for strategy semantics.
389
+ */
390
+ i18n?: I18nRoutingConfig;
96
391
  /** App-level middleware applied to all routes. */
97
392
  middleware?: Middleware[];
98
393
  /** Server port for dev/preview. Default: 3000 */
@@ -135,6 +430,25 @@ interface RouteFileExports {
135
430
  * must validate session on every navigation rather than serve stale data.
136
431
  */
137
432
  hasGcTime: boolean;
433
+ /**
434
+ * Has `export function getStaticPaths` or `export const getStaticPaths`.
435
+ * Used at SSG build time to enumerate concrete values for dynamic routes
436
+ * (`/posts/[id].tsx` → `[/posts/1, /posts/2, …]`). The function returns
437
+ * `Array<{ params: Record<string, string> }>`. Mirrors Astro's per-route
438
+ * convention. Without it, dynamic routes are silently skipped during SSG
439
+ * auto-detect — the user must hand-list every value in `ssg.paths`.
440
+ */
441
+ hasGetStaticPaths: boolean;
442
+ /**
443
+ * Has `export const revalidate` (number, in seconds, or `false` for
444
+ * never-revalidate). PR I — build-time ISR. The SSG plugin emits a
445
+ * `dist/_pyreon-revalidate.json` manifest mapping `{ path: revalidate }`
446
+ * which the deploy adapter (Vercel / Cloudflare / Netlify) consumes
447
+ * to wire platform-specific ISR rebuild-on-stale. The route generator
448
+ * does NOT inline `revalidate` onto the route record — it's a
449
+ * build-time-only concern that never reaches the runtime router.
450
+ */
451
+ hasRevalidate: boolean;
138
452
  /**
139
453
  * Raw text of the `export const meta = …` initializer, captured as a
140
454
  * literal expression. When present, the route generator inlines this
@@ -154,6 +468,22 @@ interface RouteFileExports {
154
468
  * as a literal expression. Same inlining strategy as `metaLiteral`.
155
469
  */
156
470
  renderModeLiteral?: string;
471
+ /**
472
+ * Raw text of the `export const revalidate = …` initializer (e.g.
473
+ * `'60'`, `'false'`, `'3600'`). Captured at scan time so the SSG
474
+ * plugin can read the value to emit the build-time ISR manifest
475
+ * WITHOUT loading the route module — which is critical because the
476
+ * manifest is emitted from the synthetic SSR build's outer plugin
477
+ * context, where evaluating route modules would re-trigger the
478
+ * recursive sub-build env-flag guard.
479
+ *
480
+ * Only set when the revalidate export is a top-level
481
+ * `export const revalidate = <numeric|boolean literal>` that passes
482
+ * `isPureLiteral`. Anything else (function calls, references to
483
+ * other declarations) leaves this undefined and the manifest falls
484
+ * back to omitting the entry.
485
+ */
486
+ revalidateLiteral?: string;
157
487
  }
158
488
  /** Internal representation of a file-system route before conversion to RouteRecord. */
159
489
  interface FileRoute {
@@ -196,16 +526,71 @@ interface Adapter {
196
526
  name: string;
197
527
  /** Build the production server/output for this adapter. */
198
528
  build(options: AdapterBuildOptions): Promise<void>;
529
+ /**
530
+ * Revalidate a prerendered path on the deploy platform's ISR layer
531
+ * (PR I — build-time ISR). Called by user code (webhook handlers,
532
+ * cron jobs, CMS triggers, etc.) to trigger a rebuild-on-stale for
533
+ * the named path. Optional — adapters without platform ISR support
534
+ * (static, node, bun) implement a no-op. Returns `{ regenerated:
535
+ * boolean }` so user code can branch on whether the platform actually
536
+ * accepted the revalidation request.
537
+ *
538
+ * Distinct from runtime ISR (`mode: 'isr'`, on-demand LRU caching in
539
+ * `@pyreon/zero/server`'s `createISRHandler`). Build-time ISR is
540
+ * static prerender + platform-driven rebuild-on-stale; runtime ISR is
541
+ * SSR-cached-with-TTL. They can coexist.
542
+ *
543
+ * Per-route `revalidate` metadata flows from `export const revalidate
544
+ * = 60` in route files into a `dist/_pyreon-revalidate.json` manifest
545
+ * the adapter reads at deploy time. Adapters use that manifest to
546
+ * configure platform ISR (Vercel `output/config.json`, Cloudflare
547
+ * Cache API rules, Netlify revalidation headers).
548
+ */
549
+ revalidate?(path: string): Promise<AdapterRevalidateResult>;
199
550
  }
200
- interface AdapterBuildOptions {
201
- /** Path to the built server entry. */
202
- serverEntry: string;
203
- /** Path to the client build output. */
204
- clientOutDir: string;
205
- /** Final output directory. */
551
+ /**
552
+ * Result of `Adapter.revalidate(path)`. `regenerated: false` means the
553
+ * adapter does not support platform ISR (no-op fallback) OR the
554
+ * platform rejected the request. Adapters that throw on platform-API
555
+ * failure should let it propagate so user code can handle the rejection.
556
+ */
557
+ interface AdapterRevalidateResult {
558
+ regenerated: boolean;
559
+ }
560
+ /**
561
+ * Inputs the build pipeline passes to an adapter's `build()` method.
562
+ *
563
+ * The `kind` field discriminates the two shapes. **SSR mode** (`'ssr'`)
564
+ * carries `serverEntry` + `clientOutDir` so adapters can wrap the user's
565
+ * server bundle as a serverless function. **SSG mode** (`'ssg'`) carries
566
+ * only `outDir` (which IS the rendered dist/) — no serverEntry exists
567
+ * because every page is already prerendered. SSG-mode adapters write
568
+ * platform-specific routing config so the host knows the deploy is
569
+ * fully-static (no function invocation per request).
570
+ *
571
+ * Pre-PR-J this was a single SSR-shaped struct; the SSG path had no way
572
+ * to invoke `adapter.build()` because it couldn't supply `serverEntry`.
573
+ * Adding `kind` (with TS-narrowing per branch) lets `ssgPlugin`
574
+ * `closeBundle` call `adapter.build({ kind: 'ssg', outDir, config })`
575
+ * cleanly, AND keeps the SSR-mode adapter implementations unchanged.
576
+ */
577
+ type AdapterBuildOptions = {
578
+ kind: 'ssr'; /** Path to the built server entry. */
579
+ serverEntry: string; /** Path to the client build output. */
580
+ clientOutDir: string; /** Final output directory. */
206
581
  outDir: string;
207
582
  config: ZeroConfig;
208
- }
583
+ } | {
584
+ kind: 'ssg';
585
+ /**
586
+ * The rendered dist directory. For SSG, this directory IS the
587
+ * publishable output — adapters write platform-specific routing
588
+ * config alongside (e.g. `.vercel/output/config.json`,
589
+ * `_routes.json`, `netlify.toml`) but generally don't move files.
590
+ */
591
+ outDir: string;
592
+ config: ZeroConfig;
593
+ };
209
594
  //#endregion
210
595
  //#region src/entry-server.d.ts
211
596
  interface CreateServerOptions {
@@ -257,6 +642,33 @@ declare function defineConfig(config: ZeroConfig): ZeroConfig;
257
642
  declare function resolveConfig(userConfig?: ZeroConfig): Required<Pick<ZeroConfig, 'mode' | 'base' | 'port' | 'adapter'>> & ZeroConfig;
258
643
  //#endregion
259
644
  //#region src/fs-router.d.ts
645
+ /**
646
+ * Return type of a route file's `getStaticPaths()` export. Each entry
647
+ * supplies one set of concrete values for the route's dynamic segments;
648
+ * the SSG plugin expands the route's URL pattern with these params and
649
+ * renders one HTML file per entry.
650
+ *
651
+ * @example
652
+ * ```tsx
653
+ * // src/routes/posts/[id].tsx
654
+ * import type { GetStaticPaths } from '@pyreon/zero/server'
655
+ *
656
+ * export const getStaticPaths: GetStaticPaths<{ id: string }> = async () => {
657
+ * const posts = await fetch('https://api.example.com/posts').then(r => r.json())
658
+ * return posts.map((p) => ({ params: { id: p.slug } }))
659
+ * }
660
+ *
661
+ * export default function Post() { ... }
662
+ * ```
663
+ *
664
+ * For catch-all routes (`/blog/[...slug].tsx`), pass the full path through
665
+ * the catch-all param: `{ params: { slug: 'a/b' } }` → `/blog/a/b`.
666
+ */
667
+ type GetStaticPaths<TParams extends Record<string, string> = Record<string, string>> = () => Array<{
668
+ params: TParams;
669
+ }> | Promise<Array<{
670
+ params: TParams;
671
+ }>>;
260
672
  /**
261
673
  * Parse a set of file paths (relative to routes dir) into FileRoute objects.
262
674
  *
@@ -328,9 +740,97 @@ declare function scanRouteFiles(routesDir: string): Promise<string[]>;
328
740
  */
329
741
  declare function createISRHandler(handler: (req: Request) => Promise<Response>, config: ISRConfig): (req: Request) => Promise<Response>;
330
742
  //#endregion
743
+ //#region src/vercel-revalidate-handler.d.ts
744
+ /**
745
+ * M3.1 — Drop-in Vercel revalidate webhook handler.
746
+ *
747
+ * Pre-M3.1 the `vercelAdapter.revalidate(path)` (PR I) POSTed to
748
+ * `/api/_pyreon-revalidate?path=...&secret=...` — a CONVENTION that users
749
+ * had to implement themselves. This helper scaffolds the convention:
750
+ *
751
+ * // src/routes/api/_pyreon-revalidate.ts (or `pages/api/...` in
752
+ * // Next-style apps deployed to Vercel)
753
+ * export { vercelRevalidateHandler as default } from '@pyreon/zero/server'
754
+ *
755
+ * The handler validates the secret query param against
756
+ * `VERCEL_REVALIDATE_TOKEN`, validates the path is in the build-time
757
+ * revalidate manifest, and calls Vercel's `res.revalidate(path)` API.
758
+ *
759
+ * Returns a standard `(req: Request) => Response` Web API handler — works
760
+ * with Vercel Edge functions, Node serverless functions (via Vercel's
761
+ * `@vercel/node` adapter that bridges Node `req`/`res` to Web standard
762
+ * fetch shapes), and the in-process `mode: 'ssr'` runtime.
763
+ *
764
+ * @example
765
+ * // src/routes/api/_pyreon-revalidate.ts
766
+ * import { vercelRevalidateHandler } from '@pyreon/zero/server'
767
+ *
768
+ * export const POST = vercelRevalidateHandler({
769
+ * // Optional — defaults to reading `_pyreon-revalidate.json` from cwd.
770
+ * manifestPath: './dist/_pyreon-revalidate.json',
771
+ * })
772
+ *
773
+ * @example
774
+ * // Custom revalidate impl (e.g. for a self-hosted Pyreon SSR runtime
775
+ * // that wants build-time revalidate behavior without Vercel's
776
+ * // `res.revalidate()` API):
777
+ * export const POST = vercelRevalidateHandler({
778
+ * onRevalidate: async (path) => {
779
+ * // Clear your in-process ISR cache, emit a metrics event, etc.
780
+ * await myCache.invalidate(path)
781
+ * },
782
+ * })
783
+ */
784
+ interface VercelRevalidateHandlerOptions {
785
+ /**
786
+ * Absolute or cwd-relative path to the `_pyreon-revalidate.json` manifest.
787
+ * Defaults to `./dist/_pyreon-revalidate.json` (the standard SSG output).
788
+ *
789
+ * The handler refuses to revalidate paths NOT in this manifest — protects
790
+ * against arbitrary-path revalidation attacks even when the secret leaks.
791
+ */
792
+ manifestPath?: string;
793
+ /**
794
+ * Custom revalidation impl. Defaults to calling Vercel's `res.revalidate()`
795
+ * API via the dynamic `@vercel/node`-bridged response object on globalThis
796
+ * (Vercel injects it for serverless functions).
797
+ *
798
+ * Supply this when running OUTSIDE Vercel (self-hosted SSR with a custom
799
+ * in-process ISR cache, edge runtimes that have their own purge API, etc.).
800
+ * Receives the validated path; throw to signal failure (handler returns 500).
801
+ */
802
+ onRevalidate?: (path: string) => void | Promise<void>;
803
+ /**
804
+ * Override the env-var name the handler reads the secret from. Default
805
+ * `VERCEL_REVALIDATE_TOKEN` matches the adapter's `revalidate()` write.
806
+ * Useful when adopting the helper outside Vercel and the production
807
+ * webhook uses a different secret name.
808
+ */
809
+ secretEnvVar?: string;
810
+ }
811
+ /**
812
+ * Create the Web-standard request handler. Reads the manifest once on first
813
+ * invocation (cached in-process) so repeated revalidations don't re-read the
814
+ * file. Manifest read failures cache the failure too — until next process
815
+ * restart, all requests get the same 500 response (signals deploy-time misconfig).
816
+ */
817
+ declare function vercelRevalidateHandler(options?: VercelRevalidateHandlerOptions): (req: Request) => Promise<Response>;
818
+ /**
819
+ * Reset the in-process manifest cache. Test-only — production code never
820
+ * reaches this. Used by unit tests to exercise the "manifest changed
821
+ * between requests" path without spinning up a new handler.
822
+ * @internal
823
+ */
824
+ declare function _resetVercelRevalidateHandlerCache(handler: (req: Request) => Promise<Response>): void;
825
+ //#endregion
331
826
  //#region src/adapters/bun.d.ts
332
827
  /**
333
828
  * Bun adapter — generates a standalone Bun.serve() entry.
829
+ *
830
+ * **SSG mode (PR J)**: no-op. Bun adapter exists for serving the SSR
831
+ * runtime; SSG output is already complete static HTML — serve it with
832
+ * any static-file server (`bun preview` / `bunx serve` / nginx / Caddy).
833
+ * Use `staticAdapter()` if you want explicit SSG semantics.
334
834
  */
335
835
  declare function bunAdapter(): Adapter;
336
836
  //#endregion
@@ -384,6 +884,11 @@ declare function netlifyAdapter(): Adapter;
384
884
  //#region src/adapters/node.d.ts
385
885
  /**
386
886
  * Node.js adapter — generates a standalone server entry using node:http.
887
+ *
888
+ * **SSG mode (PR J)**: no-op. Node adapter exists for serving the SSR
889
+ * runtime; SSG output is already complete static HTML — serve it with
890
+ * any static-file server (`bun preview` / nginx / Caddy / `npx serve`).
891
+ * Use `staticAdapter()` if you want explicit SSG semantics.
387
892
  */
388
893
  declare function nodeAdapter(): Adapter;
389
894
  //#endregion
@@ -391,6 +896,15 @@ declare function nodeAdapter(): Adapter;
391
896
  /**
392
897
  * Static adapter — just copies the client build output.
393
898
  * Used with SSG mode where all pages are pre-rendered at build time.
899
+ *
900
+ * **SSG mode (PR J)**: no-op — `outDir` already IS the dist directory
901
+ * the SSG plugin produced. Copying it onto itself would only fail. The
902
+ * static adapter is the canonical zero-overhead deploy target for
903
+ * pure-static sites.
904
+ *
905
+ * **SSR mode**: copies clientOutDir → outDir. Calling `static` with SSR
906
+ * mode is unusual — the static adapter doesn't support server-side
907
+ * execution — but preserved as a "client-only output packager".
394
908
  */
395
909
  declare function staticAdapter(): Adapter;
396
910
  //#endregion
@@ -419,6 +933,16 @@ declare function vercelAdapter(): Adapter;
419
933
  /**
420
934
  * Resolve the adapter from config.
421
935
  * Returns a built-in adapter or throws if unknown.
936
+ *
937
+ * Accepts BOTH forms — the `ZeroConfig.adapter` type advertises string
938
+ * names (`'vercel'` / `'cloudflare'` / …) but the scaffolded templates
939
+ * historically emit `adapter: vercelAdapter()` (an Adapter instance via
940
+ * the named factory). Both work: a string goes through the switch lookup;
941
+ * an Adapter object (duck-typed via `name` + `build` fields) passes
942
+ * through. Pre-PR-J `resolveAdapter` was never called from production
943
+ * code so the string-vs-object mismatch was invisible; PR J wires the
944
+ * call into `ssgPlugin.closeBundle`, surfacing the contract divergence.
945
+ * The passthrough preserves both shapes without a breaking type change.
422
946
  */
423
947
  declare function resolveAdapter(config: ZeroConfig): Adapter;
424
948
  //#endregion
@@ -465,6 +989,12 @@ declare function compose(...middlewares: Middleware[]): Middleware;
465
989
  declare function getContext(ctx: MiddlewareContext): Record<string, unknown>;
466
990
  //#endregion
467
991
  //#region src/vite-plugin.d.ts
992
+ /**
993
+ * Retrieve the `ZeroConfig` that was passed to `zeroPlugin(userConfig)`
994
+ * when the plugin was created. Returns `undefined` if the argument
995
+ * isn't a recognized pyreon-zero main plugin instance.
996
+ */
997
+ declare function getZeroPluginConfig(plugin: Plugin): ZeroConfig | undefined;
468
998
  /**
469
999
  * Zero Vite plugin — adds file-based routing and zero-config conventions
470
1000
  * on top of @pyreon/vite-plugin.
@@ -588,6 +1118,62 @@ interface SitemapConfig {
588
1118
  priority?: number;
589
1119
  /** Additional URLs to include (for dynamic routes). */
590
1120
  additionalPaths?: SitemapEntry[];
1121
+ /**
1122
+ * When `true` AND the build is running in SSG mode, the sitemap reads
1123
+ * the resolved-paths manifest emitted by the SSG plugin
1124
+ * (`dist/_pyreon-ssg-paths.json`) and includes EVERY prerendered URL —
1125
+ * including dynamic routes enumerated via `getStaticPaths` (PR A) and
1126
+ * per-locale variants (PR H, when shipped). Without this flag the
1127
+ * sitemap walks the file-system route tree directly and silently
1128
+ * skips dynamic routes (`[id]` / `[...slug]`) because their concrete
1129
+ * values aren't knowable without running each route's enumerator.
1130
+ *
1131
+ * Sequencing: when `true`, sitemap.xml emission moves from Vite's
1132
+ * `generateBundle` hook (where the SSG plugin's path enumeration
1133
+ * hasn't run yet) to `closeBundle` with `enforce: 'post'` so it
1134
+ * runs AFTER the SSG plugin. The user must ensure `seoPlugin()` is
1135
+ * placed AFTER `zero()` in the Vite plugin array (the canonical
1136
+ * ordering — `closeBundle` hooks fire in plugin-registration order).
1137
+ *
1138
+ * Falls back gracefully: when the manifest doesn't exist (mode is
1139
+ * not `ssg`, or the SSG step was skipped), the sitemap still walks
1140
+ * the file-system routes — same shape as without this flag.
1141
+ *
1142
+ * Default: `false` (preserves prior behaviour). Set `true` for SSG
1143
+ * sites that ship dynamic-route enumerations.
1144
+ */
1145
+ useSsgPaths?: boolean;
1146
+ /**
1147
+ * Emit `<xhtml:link rel="alternate" hreflang="...">` cross-references
1148
+ * inside each `<url>` entry, declaring the locale variants of every
1149
+ * page (PR K — i18n follow-up).
1150
+ *
1151
+ * Accepts:
1152
+ * - `true` — read the i18n config from the SSG paths manifest
1153
+ * (which `zero({ i18n: ... })` automatically embeds when SSG runs).
1154
+ * Zero-config win — declare i18n once, sitemap picks it up.
1155
+ * - `I18nRoutingConfig` — pass the i18n config explicitly. Use when
1156
+ * the project doesn't run SSG (file-scan sitemap path) but still
1157
+ * wants hreflang in the emitted sitemap.
1158
+ * - `false` / omitted — no hreflang, plain `<url>` entries.
1159
+ *
1160
+ * The emitted shape per page-cluster is the Google-recommended form:
1161
+ *
1162
+ * <url>
1163
+ * <loc>https://example.com/about</loc>
1164
+ * <xhtml:link rel="alternate" hreflang="en" href="https://example.com/about"/>
1165
+ * <xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/about"/>
1166
+ * <xhtml:link rel="alternate" hreflang="cs" href="https://example.com/cs/about"/>
1167
+ * <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/about"/>
1168
+ * </url>
1169
+ *
1170
+ * The `x-default` entry points at the default-locale URL so search
1171
+ * engines have a fallback when the user's language doesn't match any
1172
+ * of the configured locales. URLs are clustered by their un-prefixed
1173
+ * (default-locale) form — `/about`, `/de/about`, `/cs/about` collapse
1174
+ * into ONE `<url>` entry with three `xhtml:link` siblings.
1175
+ */
1176
+ hreflang?: boolean | I18nRoutingConfig;
591
1177
  }
592
1178
  interface SitemapEntry {
593
1179
  path: string;
@@ -598,8 +1184,14 @@ interface SitemapEntry {
598
1184
  type ChangeFreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
599
1185
  /**
600
1186
  * Generate a sitemap.xml string from route file paths.
1187
+ *
1188
+ * When `i18n` is set (PR K — passed by `seoPlugin` after reading the
1189
+ * i18n config from `zero({ i18n: ... })`), URLs are clustered by their
1190
+ * un-prefixed (default-locale) form and each `<url>` carries
1191
+ * `<xhtml:link rel="alternate" hreflang="...">` siblings for every
1192
+ * locale variant + an `x-default` entry pointing at the default locale.
601
1193
  */
602
- declare function generateSitemap(routeFiles: string[], config: SitemapConfig): string;
1194
+ declare function generateSitemap(routeFiles: string[], config: SitemapConfig, i18n?: I18nRoutingConfig): string;
603
1195
  interface RobotsConfig {
604
1196
  /** Rules per user-agent. */
605
1197
  rules?: RobotsRule[];
@@ -649,7 +1241,10 @@ interface SeoPluginConfig {
649
1241
  * pyreon(),
650
1242
  * zero(),
651
1243
  * seoPlugin({
652
- * sitemap: { origin: "https://example.com" },
1244
+ * sitemap: {
1245
+ * origin: "https://example.com",
1246
+ * useSsgPaths: true, // include dynamic-route enumerations
1247
+ * },
653
1248
  * robots: { sitemap: "https://example.com/sitemap.xml" },
654
1249
  * }),
655
1250
  * ],
@@ -904,65 +1499,5 @@ declare function inferJsonLd(options: InferJsonLdOptions): Record<string, unknow
904
1499
  */
905
1500
  declare function aiPlugin(config: AiPluginConfig): Plugin;
906
1501
  //#endregion
907
- //#region src/i18n-routing.d.ts
908
- interface I18nRoutingConfig {
909
- /** Supported locales. e.g. ["en", "de", "cs"] */
910
- locales: string[];
911
- /** Default locale — served without prefix (/ instead of /en/). */
912
- defaultLocale: string;
913
- /** Redirect root to detected locale. Default: true */
914
- detectLocale?: boolean;
915
- /** Cookie name to persist locale preference. Default: "locale" */
916
- cookieName?: string;
917
- /** URL strategy. Default: "prefix-except-default" */
918
- strategy?: 'prefix' | 'prefix-except-default';
919
- }
920
- interface LocaleContext {
921
- /** Current locale code. e.g. "en", "de" */
922
- locale: string;
923
- /** All supported locales. */
924
- locales: string[];
925
- /** Default locale. */
926
- defaultLocale: string;
927
- /** Build a localized path. e.g. localePath("/about", "de") → "/de/about" */
928
- localePath: (path: string, locale?: string) => string;
929
- /** Get hreflang alternates for the current path. */
930
- alternates: () => Array<{
931
- locale: string;
932
- url: string;
933
- }>;
934
- }
935
- /**
936
- * Detect preferred locale from Accept-Language header.
937
- */
938
- declare function detectLocaleFromHeader(acceptLanguage: string | null | undefined, locales: string[], defaultLocale: string): string;
939
- /**
940
- * Create a LocaleContext for use in components and loaders.
941
- */
942
- declare function createLocaleContext(locale: string, path: string, config: I18nRoutingConfig): LocaleContext;
943
- /**
944
- * I18n routing middleware for Zero's server.
945
- *
946
- * - Detects locale from URL prefix or Accept-Language header
947
- * - Redirects root to preferred locale (when detectLocale is true)
948
- * - Sets locale context for loaders and components
949
- *
950
- * @example
951
- * ```ts
952
- * // zero.config.ts
953
- * import { i18nRouting } from "@pyreon/zero"
954
- *
955
- * export default defineConfig({
956
- * plugins: [
957
- * i18nRouting({
958
- * locales: ["en", "de", "cs"],
959
- * defaultLocale: "en",
960
- * }),
961
- * ],
962
- * })
963
- * ```
964
- */
965
- declare function i18nRouting(config: I18nRoutingConfig): Plugin;
966
- //#endregion
967
- export { type AiPluginConfig, type CreateAppOptions, type CreateServerOptions, type FaviconLocaleConfig, type FaviconPluginConfig, type GenerateRouteModuleOptions, type InferJsonLdOptions, type OgImageLayer, type OgImagePluginConfig, type OgImageTemplate, type RobotsConfig, type SeoPluginConfig, type SitemapConfig, 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 };
1502
+ export { type AiPluginConfig, type CreateAppOptions, type CreateServerOptions, type FaviconLocaleConfig, type FaviconPluginConfig, type GenerateRouteModuleOptions, type GetStaticPaths, type InferJsonLdOptions, type OgImageLayer, type OgImagePluginConfig, type OgImageTemplate, type RobotsConfig, type SeoPluginConfig, type SitemapConfig, type VercelRevalidateHandlerOptions, _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 };
968
1503
  //# sourceMappingURL=server2.d.ts.map