@pyreon/zero 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/lib/api-routes-Ci0kVmM4.js +146 -0
  2. package/lib/client.js +7 -2
  3. package/lib/csp.js +19 -9
  4. package/lib/env.js +6 -6
  5. package/lib/font.js +3 -3
  6. package/lib/{fs-router-CQ7Zxeca.js → fs-router-MewHc5SB.js} +56 -24
  7. package/lib/i18n-routing.js +112 -1
  8. package/lib/image-plugin.js +4 -0
  9. package/lib/image.js +141 -108
  10. package/lib/index.js +253 -132
  11. package/lib/link.js +1 -49
  12. package/lib/og-image.js +5 -5
  13. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  14. package/lib/script.js +115 -74
  15. package/lib/seo.js +186 -15
  16. package/lib/server.js +275 -1247
  17. package/lib/theme.js +1 -50
  18. package/lib/types/config.d.ts +275 -3
  19. package/lib/types/env.d.ts +2 -2
  20. package/lib/types/i18n-routing.d.ts +197 -6
  21. package/lib/types/image.d.ts +105 -5
  22. package/lib/types/index.d.ts +640 -178
  23. package/lib/types/link.d.ts +3 -3
  24. package/lib/types/script.d.ts +78 -6
  25. package/lib/types/seo.d.ts +128 -4
  26. package/lib/types/server.d.ts +603 -77
  27. package/lib/types/theme.d.ts +2 -2
  28. package/lib/vite-plugin-xjWZwudX.js +2454 -0
  29. package/package.json +16 -13
  30. package/src/adapters/bun.ts +20 -1
  31. package/src/adapters/cloudflare.ts +78 -1
  32. package/src/adapters/index.ts +25 -3
  33. package/src/adapters/netlify.ts +63 -1
  34. package/src/adapters/node.ts +25 -1
  35. package/src/adapters/static.ts +26 -1
  36. package/src/adapters/validate.ts +8 -1
  37. package/src/adapters/vercel.ts +76 -1
  38. package/src/adapters/warn-missing-env.ts +49 -0
  39. package/src/app.ts +35 -1
  40. package/src/client.ts +18 -0
  41. package/src/csp.ts +28 -12
  42. package/src/entry-server.ts +55 -5
  43. package/src/env.ts +7 -7
  44. package/src/font.ts +3 -3
  45. package/src/fs-router.ts +123 -4
  46. package/src/i18n-routing.ts +246 -12
  47. package/src/image.tsx +242 -91
  48. package/src/index.ts +4 -4
  49. package/src/isr.ts +24 -6
  50. package/src/manifest.ts +675 -0
  51. package/src/og-image.ts +5 -5
  52. package/src/script.tsx +159 -36
  53. package/src/seo.ts +346 -15
  54. package/src/server.ts +10 -2
  55. package/src/ssg-plugin.ts +1523 -0
  56. package/src/types.ts +329 -19
  57. package/src/vercel-revalidate-handler.ts +204 -0
  58. package/src/vite-plugin.ts +326 -68
  59. package/lib/actions.js.map +0 -1
  60. package/lib/ai.js.map +0 -1
  61. package/lib/api-routes.js.map +0 -1
  62. package/lib/cache.js.map +0 -1
  63. package/lib/client.js.map +0 -1
  64. package/lib/compression.js.map +0 -1
  65. package/lib/config.js.map +0 -1
  66. package/lib/cors.js.map +0 -1
  67. package/lib/csp.js.map +0 -1
  68. package/lib/env.js.map +0 -1
  69. package/lib/favicon.js.map +0 -1
  70. package/lib/font.js.map +0 -1
  71. package/lib/fs-router-3xzp-4Wj.js.map +0 -1
  72. package/lib/fs-router-CQ7Zxeca.js.map +0 -1
  73. package/lib/i18n-routing.js.map +0 -1
  74. package/lib/image-plugin.js.map +0 -1
  75. package/lib/image.js.map +0 -1
  76. package/lib/index.js.map +0 -1
  77. package/lib/link.js.map +0 -1
  78. package/lib/logger.js.map +0 -1
  79. package/lib/meta.js.map +0 -1
  80. package/lib/middleware.js.map +0 -1
  81. package/lib/og-image.js.map +0 -1
  82. package/lib/rate-limit.js.map +0 -1
  83. package/lib/script.js.map +0 -1
  84. package/lib/seo.js.map +0 -1
  85. package/lib/server.js.map +0 -1
  86. package/lib/testing.js.map +0 -1
  87. package/lib/theme.js.map +0 -1
  88. package/lib/types/actions.d.ts.map +0 -1
  89. package/lib/types/ai.d.ts.map +0 -1
  90. package/lib/types/api-routes.d.ts.map +0 -1
  91. package/lib/types/cache.d.ts.map +0 -1
  92. package/lib/types/client.d.ts.map +0 -1
  93. package/lib/types/compression.d.ts.map +0 -1
  94. package/lib/types/config.d.ts.map +0 -1
  95. package/lib/types/cors.d.ts.map +0 -1
  96. package/lib/types/csp.d.ts.map +0 -1
  97. package/lib/types/env.d.ts.map +0 -1
  98. package/lib/types/favicon.d.ts.map +0 -1
  99. package/lib/types/font.d.ts.map +0 -1
  100. package/lib/types/i18n-routing.d.ts.map +0 -1
  101. package/lib/types/image-plugin.d.ts.map +0 -1
  102. package/lib/types/image.d.ts.map +0 -1
  103. package/lib/types/index.d.ts.map +0 -1
  104. package/lib/types/link.d.ts.map +0 -1
  105. package/lib/types/logger.d.ts.map +0 -1
  106. package/lib/types/meta.d.ts.map +0 -1
  107. package/lib/types/middleware.d.ts.map +0 -1
  108. package/lib/types/og-image.d.ts.map +0 -1
  109. package/lib/types/rate-limit.d.ts.map +0 -1
  110. package/lib/types/script.d.ts.map +0 -1
  111. package/lib/types/seo.d.ts.map +0 -1
  112. package/lib/types/server.d.ts.map +0 -1
  113. package/lib/types/testing.d.ts.map +0 -1
  114. package/lib/types/theme.d.ts.map +0 -1
@@ -0,0 +1,1523 @@
1
+ /**
2
+ * SSG (Static Site Generation) build hook for `@pyreon/zero`.
3
+ *
4
+ * Activates when `mode: "ssg"` is set in zero's config. After Vite's client
5
+ * build finishes, this plugin:
6
+ *
7
+ * 1. Triggers a programmatic SSR build via Vite's `build()` API, producing
8
+ * a server bundle in `dist/.zero-ssg-server/` from a synthetic entry
9
+ * that imports `virtual:zero/routes` and `createServer`.
10
+ * 2. Loads the built handler with dynamic `import()`.
11
+ * 3. Resolves the path list from `config.ssg.paths` (string[], async fn,
12
+ * or auto-detected from the static-only routes in the route tree).
13
+ * 4. Calls `prerender()` from `@pyreon/server` to render each path.
14
+ * 5. Cleans up the temporary SSR build directory.
15
+ *
16
+ * Before this PR, `mode: "ssg"` and `ssg.paths` were typed in
17
+ * `types.ts` but had no runtime implementation — the plugin file had zero
18
+ * Rollup build hooks. Apps configured for SSG silently shipped a bare SPA
19
+ * shell with no per-route HTML files, which broke direct-URL deploys to
20
+ * static hosts (no `dist/<path>/index.html`, every URL falls back to the
21
+ * SPA index).
22
+ */
23
+
24
+ import { existsSync } from 'node:fs'
25
+ import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
26
+ import { dirname, join, resolve } from 'node:path'
27
+ import { pathToFileURL } from 'node:url'
28
+ import type { Plugin } from 'vite'
29
+ import { resolveAdapter } from './adapters'
30
+ import { resolveConfig } from './config'
31
+ import { parseFileRoutes, scanRouteFiles, scanRouteFilesWithExports } from './fs-router'
32
+ import { expandRoutesForLocales, type I18nRoutingConfig } from './i18n-routing'
33
+ import type { ZeroConfig } from './types'
34
+
35
+ // M2.3 — Server-side perf-harness counter sink (same shape as
36
+ // runtime-server). `__DEV__` is gated at the call site so prod builds with
37
+ // `NODE_ENV=production` skip the optional-chain entirely. Counter strings
38
+ // remain in the bundle (few bytes) but the runtime cost is zero.
39
+ //
40
+ // Consumers run the build under a process that has installed a sink via
41
+ // `@pyreon/perf-harness`'s `install()` / `enable()` API. Without a sink
42
+ // installed, the optional chaining short-circuits and emission is free.
43
+ // Useful for: CI plugins tracking SSG perf over time, dev profiling, or
44
+ // the future `vite build --profile` flag.
45
+ const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
46
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
47
+
48
+ // Marker env var used to skip the SSG hook on the recursive SSR sub-build —
49
+ // the SSR pass loads the same vite config + same plugin chain, so without
50
+ // this guard the SSG hook would re-trigger an infinite build loop.
51
+ const SSG_BUILD_FLAG = 'PYREON_ZERO_SSG_INNER_BUILD'
52
+
53
+ // Synthetic SSR entry source. Imports the user's route tree via the virtual
54
+ // module that zero's main plugin already registers, then exports a default
55
+ // `(path: string) => Promise<string>` renderer that returns the full HTML
56
+ // for a single path.
57
+ //
58
+ // The entry is materialized to disk (not registered as a virtual module)
59
+ // because Rolldown's `rollupOptions.input` phase doesn't reliably resolve
60
+ // `\0`-prefixed virtual ids when used as build entries — a virtual id
61
+ // returned from `resolveId` works fine for downstream imports but fails
62
+ // the entry-resolution stage with `Cannot resolve entry module`.
63
+ //
64
+ // We do NOT use zero's `createServer` because it wraps the user's App with
65
+ // a router whose URL is baked in at App-creation time. SSG needs a fresh
66
+ // router per path, so we mirror the dev SSR pipeline (`renderSsr` in
67
+ // vite-plugin.ts): per request → new createApp({ url: path }) → preload
68
+ // loaders → renderWithHead → serialize loader data → done.
69
+ // SSG entry template — see notes above for the design rationale.
70
+ //
71
+ // **Styler CSS flush.** `@pyreon/styler` accumulates server-rendered CSS
72
+ // rules into its singleton `sheet.ssrBuffer` as components render, then
73
+ // emits them via `sheet.getStyleTag()`. Before this was wired into the SSG
74
+ // path, prerendered HTML carried styler-generated class names (`pyr-1abc23`)
75
+ // on every element but had ZERO `<style>` tags in the head — meaning every
76
+ // SSG page rendered un-styled until the client JS ran and re-emitted the
77
+ // CSS. The fix lazy-imports `@pyreon/styler` so projects that don't use it
78
+ // pay nothing, calls `sheet.reset()` per request to start clean (singleton
79
+ // state would leak across paths in the same SSG sub-build), and injects
80
+ // the resulting `<style>` tag into the head ahead of @pyreon/head's tags.
81
+ //
82
+ // `@pyreon/server`'s `createHandler` exposes the same hook via a
83
+ // `collectStyles` option (handler.ts:84-91); the SSG path used to bypass
84
+ // that entirely because it builds its own renderer rather than going
85
+ // through createHandler.
86
+ // PR K (i18n follow-up): the SSG entry needs the configured locales list
87
+ // baked in at build time so the per-locale 404 walker can detect which
88
+ // RouteRecord serves which locale. Locale info isn't on the runtime
89
+ // route records (they're path patterns, not metadata), so the walker
90
+ // has to compare paths against the known locale list. Wrapping the
91
+ // source template in a function lets the outer plugin pass
92
+ // `config.i18n?.locales ?? []` per build.
93
+ const renderSsrEntrySource = (locales: readonly string[] = []): string => {
94
+ const i18nLocalesLiteral = JSON.stringify(locales)
95
+ return `
96
+ import { routes } from "virtual:zero/routes"
97
+ import { h } from "@pyreon/core"
98
+ import { renderWithHead } from "@pyreon/head/ssr"
99
+ import { getRedirectInfo, serializeLoaderData, stringifyLoaderData } from "@pyreon/router"
100
+ import { runWithRequestContext } from "@pyreon/runtime-server"
101
+ import { createApp } from "@pyreon/zero/server"
102
+
103
+ // Lazy-imported styler integration. Projects that don't depend on
104
+ // @pyreon/styler skip this entirely (the import fails silently, the
105
+ // helper stays a no-op). Hot path: an awaited dynamic import resolved
106
+ // once at entry-module evaluation, then sync calls per request.
107
+ //
108
+ // **No reset between paths.** @pyreon/styler's styled() inserts CSS
109
+ // rules into sheet.ssrBuffer at MODULE-EVAL TIME (top-level of styled.ts:95),
110
+ // not per-render. After that initial insert, each render of a styled
111
+ // component just attaches the cached class name to props — no new buffer
112
+ // push. Calling sheet.reset() between SSG paths would WIPE all rules and
113
+ // leave subsequent pages style-less. For SSG this is acceptable: the
114
+ // generated CSS is identical across all pages (same module-eval cache),
115
+ // and shipping the full rule set in every page's <style> tag matches
116
+ // how static SSG sites handle CSS — every page is self-contained,
117
+ // cacheable by the browser, no per-route CSS code splitting needed.
118
+ let __pyreonGetStylerTag = () => ""
119
+ try {
120
+ const stylerMod = await import("@pyreon/styler")
121
+ if (stylerMod && stylerMod.sheet && typeof stylerMod.sheet.getStyleTag === "function") {
122
+ __pyreonGetStylerTag = () => stylerMod.sheet.getStyleTag()
123
+ }
124
+ } catch {
125
+ // No @pyreon/styler in the project — leave the no-op stub in place.
126
+ }
127
+
128
+ // PR E — \`__ZERO_BASE__\` is the Vite-defined build-time constant
129
+ // carrying the value of \`zero({ base })\`. Read it once at module
130
+ // eval and forward to createRouter via createApp so SSG-rendered
131
+ // pages have correctly-prefixed RouterLink hrefs that match the
132
+ // asset URLs Vite already prefixed in the built HTML template.
133
+ const __ssgBase = typeof __ZERO_BASE__ !== "undefined" && __ZERO_BASE__ !== "/"
134
+ ? __ZERO_BASE__
135
+ : undefined
136
+
137
+ export default async function renderPath(path, options) {
138
+ const { App, router } = createApp({
139
+ routes,
140
+ routerMode: "history",
141
+ url: path,
142
+ ...(__ssgBase ? { base: __ssgBase } : {}),
143
+ })
144
+
145
+ // PR B — redirect handling. \`router.preload\` runs every loader in the
146
+ // matched chain and surfaces \`redirect()\` throws as the rejection
147
+ // reason. We catch BEFORE the render: rendering past a redirect would
148
+ // produce HTML for the wrong page AND leak the auth-gated layout
149
+ // structure for unauthenticated users. The runtime SSR handler
150
+ // (createHandler in @pyreon/server) already does this same catch and
151
+ // returns a 302/307 Location response; SSG mirrors that — return
152
+ // \`{ kind: 'redirect', from, to, status }\` instead of HTML, and the
153
+ // outer plugin emits a redirect manifest entry instead of an
154
+ // \`index.html\`. Any non-redirect error rethrows and lands in the
155
+ // existing \`errors[]\` collection.
156
+ //
157
+ // PR C — \`isNotFound\` skips parent-layout loaders during the 404 build.
158
+ // Layout loaders that hit auth resources (cookies, session tokens,
159
+ // private APIs) shouldn't fire when generating a static 404 page —
160
+ // the build has no real request context. Lazy components still
161
+ // resolve so the synthetic chain renders cleanly; only the
162
+ // \`r.loader()\` invocations are skipped. \`__renderNotFound\` below
163
+ // forwards \`{ isNotFound: true }\` for this path.
164
+ try {
165
+ await router.preload(path, undefined, {
166
+ skipLoaders: options?.isNotFound === true,
167
+ })
168
+ } catch (err) {
169
+ const info = getRedirectInfo(err)
170
+ if (info) {
171
+ return { kind: "redirect", from: path, to: info.url, status: info.status }
172
+ }
173
+ throw err
174
+ }
175
+
176
+ return runWithRequestContext(async () => {
177
+ const app = h(App, null)
178
+ const { html: appHtml, head } = await renderWithHead(app)
179
+
180
+ // Inject styler's <style data-pyreon-styler="..."> tag into the head
181
+ // BEFORE @pyreon/head's tags so the CSS cascade orders correctly with
182
+ // any meta/link tags the user added. Empty buffer emits a benign
183
+ // empty <style></style> — detected via the literal closing pair to
184
+ // avoid polluting the head when no styler is in use.
185
+ const styleTag = __pyreonGetStylerTag()
186
+ const isEmpty = !styleTag || styleTag.indexOf("></style>") !== -1
187
+ const finalHead = isEmpty ? head : styleTag + "\\n" + head
188
+
189
+ const loaderData = serializeLoaderData(router)
190
+ const hasData = loaderData && Object.keys(loaderData).length > 0
191
+ // M2.2 — safe serializer drops function/symbol values, throws a clear
192
+ // Pyreon-prefixed error on circular refs, escapes </script> uniformly.
193
+ const loaderScript = hasData
194
+ ? \`<script>window.__PYREON_LOADER_DATA__=\${stringifyLoaderData(loaderData)}</script>\`
195
+ : ""
196
+ return { kind: "html", appHtml, head: finalHead, loaderScript }
197
+ })
198
+ }
199
+
200
+ // ─── getStaticPaths enumeration (PR A) ──────────────────────────────────────
201
+ //
202
+ // Walks the generated routes tree and collects every dynamic route's
203
+ // \`getStaticPaths\` function alongside its URL pattern. The SSG plugin
204
+ // calls this once before rendering and uses the returned map to expand
205
+ // dynamic routes (\`/posts/:id\` × \`[{id:'a'},{id:'b'}]\` → \`/posts/a\`,
206
+ // \`/posts/b\`). Routes without \`getStaticPaths\` are absent from the map.
207
+ //
208
+ // Why we collect ALL routes here instead of resolving on-demand: the
209
+ // SSG plugin's \`resolvePaths\` runs in the OUTER Vite plugin context (no
210
+ // access to the bundled routes module). The SSR sub-build is the only
211
+ // place where the user's compiled route exports are reachable, so we
212
+ // expose a sync collector that lets the plugin call user functions
213
+ // indirectly via the entry's exports.
214
+ function collectStaticPathsRegistry(rs, out) {
215
+ for (const r of rs) {
216
+ if (typeof r.getStaticPaths === "function" && typeof r.path === "string") {
217
+ out.set(r.path, r.getStaticPaths)
218
+ }
219
+ if (Array.isArray(r.children)) collectStaticPathsRegistry(r.children, out)
220
+ }
221
+ return out
222
+ }
223
+
224
+ /** Map of \`urlPath → getStaticPaths function\` for every dynamic route. */
225
+ export const __getStaticPathsRegistry = collectStaticPathsRegistry(routes, new Map())
226
+
227
+ // ─── 404 emission (PR C) ────────────────────────────────────────────────────
228
+ //
229
+ // Locales the build was configured for (PR H: \`zero({ i18n: { locales } })\`).
230
+ // Injected as a JSON-literal by the outer plugin so the walker can detect
231
+ // which RouteRecord serves which locale by matching its \`path\` against
232
+ // the \`/\${locale}\` / \`/\${locale}/*\` prefix. Empty array = no i18n,
233
+ // single-default-locale shape, walker collects exactly one entry keyed
234
+ // by \`null\` and the closeBundle writes a single \`dist/404.html\`.
235
+ const __i18nLocales = ${i18nLocalesLiteral}
236
+
237
+ // Walk the route tree and return ALL \`notFoundComponent\` references,
238
+ // keyed by which locale subtree they were found in (or \`null\` for the
239
+ // default / no-i18n case). fs-router attaches \`_404.tsx\` to its parent
240
+ // layout's RouteRecord (or to each page record when no wrapping layout
241
+ // exists — which is the per-locale subtree shape under PR H's root-
242
+ // layout-skip). The walker collects the FIRST match per locale via
243
+ // depth-first traversal: the per-locale subtree's \`notFoundComponent\`
244
+ // wins over the root's for that locale.
245
+ //
246
+ // Locale detection: a RouteRecord serves locale \`X\` if its \`path\`
247
+ // matches \`/X\` or starts with \`/X/\`. The default-locale entry
248
+ // (under \`prefix-except-default\` strategy) is keyed by \`null\` since
249
+ // its path doesn't carry a locale prefix.
250
+ function findNotFoundComponentsByLocale(rs, currentLocale) {
251
+ const result = new Map()
252
+ function walk(records, ambient) {
253
+ for (const r of records) {
254
+ const path = typeof r.path === "string" ? r.path : ""
255
+ let locale = ambient
256
+ for (const l of __i18nLocales) {
257
+ if (path === \`/\${l}\` || path.startsWith(\`/\${l}/\`)) {
258
+ locale = l
259
+ break
260
+ }
261
+ }
262
+ if (typeof r.notFoundComponent === "function") {
263
+ if (!result.has(locale)) result.set(locale, r.notFoundComponent)
264
+ }
265
+ if (Array.isArray(r.children)) walk(r.children, locale)
266
+ }
267
+ }
268
+ walk(rs, currentLocale)
269
+ return result
270
+ }
271
+ export const __notFoundComponentsByLocale = findNotFoundComponentsByLocale(routes, null)
272
+
273
+ // Back-compat: legacy single-export, picks up whatever the walker
274
+ // classified as \`null\`-locale (default-locale or non-i18n root 404).
275
+ // External callers (none currently in main, but downstream consumers
276
+ // that imported this export pre-PR) keep working.
277
+ export const __notFoundComponent = __notFoundComponentsByLocale.get(null) ?? null
278
+
279
+ // Render the not-found component THROUGH the router (PR L5). We navigate
280
+ // to a synthetic non-matching probe URL per locale — \`resolveRoute\`
281
+ // (post-L5) walks the route tree finding the deepest parent
282
+ // \`notFoundComponent\` and builds a matched chain
283
+ // \`[...ancestorLayouts, syntheticLeaf]\`. The leaf carries the
284
+ // not-found component; rendering through the normal pipeline produces
285
+ // HTML WITH layout chrome — same headers, footers, navigation as
286
+ // regular pages. The locale prefix in the probe URL ensures the right
287
+ // per-locale layout subtree matches (under PR H's prefix strategy).
288
+ //
289
+ // Pre-L5 behavior (\`h(component, null)\` standalone) is preserved as a
290
+ // fallback when the route tree has \`notFoundComponent\` at the root
291
+ // but no \`isNotFound\` chain forms (older route shapes). The outer
292
+ // plugin checks \`__notFoundComponentsByLocale\` first to gate emission
293
+ // — if no notFoundComponent exists anywhere, \`__renderNotFound\` is
294
+ // never called.
295
+ export async function __renderNotFound(locale) {
296
+ // Probe URL chosen to be highly improbable as a real route. The
297
+ // suffix is deliberately literal (no \`Math.random\`) so build
298
+ // outputs are deterministic across runs.
299
+ const probePath = locale == null
300
+ ? "/__pyreon_not_found_probe__"
301
+ : \`/\${locale}/__pyreon_not_found_probe__\`
302
+
303
+ // Try the router-driven path first. If the route tree has a parent
304
+ // \`notFoundComponent\` reachable from the probe URL, resolveRoute's
305
+ // fallback builds the chain through the layout and the normal render
306
+ // pipeline produces 404 HTML wrapped in layout chrome.
307
+ //
308
+ // PR C — pass \`isNotFound: true\` so renderPath skips parent-layout
309
+ // loaders. Layout loaders that hit auth resources / external APIs
310
+ // shouldn't fire when generating a static 404 page (the build has
311
+ // no real request context). Lazy components still resolve; only
312
+ // \`r.loader()\` invocations are skipped.
313
+ const result = await renderPath(probePath, { isNotFound: true })
314
+ if (result && result.kind === "html") {
315
+ return {
316
+ appHtml: result.appHtml,
317
+ head: result.head,
318
+ loaderScript: result.loaderScript,
319
+ }
320
+ }
321
+
322
+ // Fallback for tree shapes where the resolver returns empty matched
323
+ // (no notFoundComponent walkable from this probe path). Render the
324
+ // component standalone — same shape pre-L5.
325
+ const component = locale == null
326
+ ? (__notFoundComponentsByLocale.get(null) ?? __notFoundComponent)
327
+ : __notFoundComponentsByLocale.get(locale)
328
+ if (typeof component !== "function") return null
329
+
330
+ return runWithRequestContext(async () => {
331
+ const vnode = h(component, null)
332
+ const { html: appHtml, head } = await renderWithHead(vnode)
333
+
334
+ const styleTag = __pyreonGetStylerTag()
335
+ const isEmpty = !styleTag || styleTag.indexOf("></style>") !== -1
336
+ const finalHead = isEmpty ? head : styleTag + "\\n" + head
337
+
338
+ return { appHtml, head: finalHead, loaderScript: "" }
339
+ })
340
+ }
341
+ `.trimStart()
342
+ }
343
+
344
+ const SSR_ENTRY_FILENAME = '__pyreon-zero-ssg-entry.js'
345
+
346
+ /** Per-route enumerator. URL pattern (`/posts/:id`) → params list. */
347
+ export type GetStaticPathsRegistry = Map<
348
+ string,
349
+ () => Promise<Array<{ params: Record<string, string> }>> | Array<{ params: Record<string, string> }>
350
+ >
351
+
352
+ /**
353
+ * Substitute concrete values for `:param` / `:param*` segments in a URL
354
+ * pattern. Mirrors the inverse of `filePathToUrlPath`.
355
+ *
356
+ * /posts/:id × { id: 'a' } → /posts/a
357
+ * /posts/:id/:slug × { id: 'a', slug: 'b' } → /posts/a/b
358
+ * /blog/:rest* × { rest: 'a/b' } → /blog/a/b (catch-all preserves slashes)
359
+ *
360
+ * Missing params or empty values throw — the SSG plugin treats this as a
361
+ * `getStaticPaths` error and records it in the per-path errors array.
362
+ */
363
+ export function expandUrlPattern(pattern: string, params: Record<string, string>): string {
364
+ return pattern
365
+ .split('/')
366
+ .map((seg) => {
367
+ if (!seg.startsWith(':')) return seg
368
+ const isCatchAll = seg.endsWith('*')
369
+ const name = isCatchAll ? seg.slice(1, -1) : seg.slice(1)
370
+ const value = params[name]
371
+ if (value === undefined || value === '') {
372
+ throw new Error(
373
+ `[zero:ssg] getStaticPaths for "${pattern}" returned params without "${name}"`,
374
+ )
375
+ }
376
+ return value
377
+ })
378
+ .join('/')
379
+ }
380
+
381
+ /**
382
+ * Auto-detect static paths from the route tree AND expand dynamic routes
383
+ * via each route's `getStaticPaths` export (when present). A "static" path
384
+ * is one with NO dynamic segments (`[id]`, `[...rest]`); a "dynamic" path
385
+ * with `getStaticPaths` is expanded via the registry; remaining dynamic
386
+ * routes are silently skipped (the user must hand-list them in
387
+ * `ssg.paths`).
388
+ */
389
+ async function autoDetectStaticPaths(
390
+ routesDir: string,
391
+ registry?: GetStaticPathsRegistry,
392
+ errors: { path: string; error: unknown }[] = [],
393
+ i18n?: I18nRoutingConfig,
394
+ ): Promise<string[]> {
395
+ // Routes dir missing → fall back to "/" anyway. A project that doesn't
396
+ // expose routes via fs-routing (custom routes module, single-page app
397
+ // shell, etc.) still needs at least an index.html so static hosts have
398
+ // a default response. The user can always set explicit `ssg.paths` to
399
+ // override this floor.
400
+ if (!existsSync(routesDir)) return ['/']
401
+ const files = await scanRouteFiles(routesDir)
402
+ // PR H — fan routes into per-locale variants when `i18n` is configured.
403
+ // Each duplicated FileRoute carries the same `getStaticPaths` enumerator
404
+ // (via `exports`) so dynamic + i18n cardinality compounds naturally:
405
+ // `/blog/[slug]` × `[en, de]` × 3 slugs → 6 paths under
406
+ // `prefix-except-default`.
407
+ const baseRoutes = parseFileRoutes(files)
408
+ const fileRoutes = i18n ? expandRoutesForLocales(baseRoutes, i18n) : baseRoutes
409
+
410
+ const out: string[] = []
411
+ for (const r of fileRoutes) {
412
+ if (r.isLayout || r.isError || r.isLoading || r.isNotFound) continue
413
+ const path = r.urlPath
414
+ if (!path) continue
415
+
416
+ // Static path — emit as-is.
417
+ if (!/[:*]/.test(path)) {
418
+ out.push(path)
419
+ continue
420
+ }
421
+
422
+ // Dynamic path — expand via getStaticPaths if available.
423
+ const enumerator = registry?.get(path)
424
+ if (!enumerator) continue // no getStaticPaths → skip silently
425
+
426
+ try {
427
+ const result = await enumerator()
428
+ if (!Array.isArray(result)) {
429
+ throw new Error(
430
+ `getStaticPaths for "${path}" must return an array, got ${typeof result}`,
431
+ )
432
+ }
433
+ for (const entry of result) {
434
+ if (!entry || typeof entry !== 'object' || !entry.params) {
435
+ throw new Error(
436
+ `getStaticPaths for "${path}" returned an entry without "params"`,
437
+ )
438
+ }
439
+ out.push(expandUrlPattern(path, entry.params))
440
+ }
441
+ } catch (error) {
442
+ errors.push({ path, error })
443
+ }
444
+ }
445
+
446
+ // Always include "/" as a fallback if no static routes were found —
447
+ // a project with only dynamic routes still needs an index.html for the
448
+ // host to know where to send unmatched URLs.
449
+ return out.length > 0 ? out : ['/']
450
+ }
451
+
452
+ async function resolvePaths(
453
+ config: ZeroConfig,
454
+ routesDir: string,
455
+ registry?: GetStaticPathsRegistry,
456
+ errors: { path: string; error: unknown }[] = [],
457
+ ): Promise<string[]> {
458
+ const explicit = config.ssg?.paths
459
+ if (typeof explicit === 'function') {
460
+ const result = await explicit()
461
+ return Array.isArray(result) ? result : []
462
+ }
463
+ if (Array.isArray(explicit)) return explicit
464
+ return autoDetectStaticPaths(routesDir, registry, errors, config.i18n)
465
+ }
466
+
467
+ /**
468
+ * Write `content` to `target` atomically: write to a sibling temp file first,
469
+ * then `rename` into place. Rename is an atomic syscall on POSIX (and Windows
470
+ * for same-volume renames) — readers either see the OLD content or the FULL
471
+ * new content, never a half-written file.
472
+ *
473
+ * M2.1 — Use this for manifests that adapters consume (`_redirects`,
474
+ * `_pyreon-ssg-paths.json`, `_pyreon-revalidate.json`, etc.). A SIGINT during
475
+ * a sequential plain-`writeFile` chain in `closeBundle` would leave partial
476
+ * state: half the manifests pointing at the new render, half the old. Atomic
477
+ * writes mean each manifest is independently consistent — readers see either
478
+ * the old build's manifest or the new build's, never a mix.
479
+ *
480
+ * Per-page HTML writes (`dist/<path>/index.html`) intentionally do NOT use
481
+ * this — they're individually-readable files (no cross-file invariants), and
482
+ * the rename-per-page cost on 10k-path sites would be significant.
483
+ *
484
+ * Temp filename embeds `pid + perf-counter` so concurrent runs (e.g. CI
485
+ * pipelines that fight over the same dist) don't collide. The tmp file is
486
+ * cleaned up in a finally block — even if the rename fails, no orphaned
487
+ * `.tmp.*` files leak.
488
+ *
489
+ * @internal Exposed via `_internal.writeFileAtomic` for unit tests.
490
+ */
491
+ let _atomicSeq = 0
492
+ async function writeFileAtomic(target: string, content: string | Uint8Array): Promise<void> {
493
+ const tmp = `${target}.tmp.${process.pid}.${++_atomicSeq}`
494
+ try {
495
+ await writeFile(tmp, content)
496
+ await rename(tmp, target)
497
+ } catch (err) {
498
+ // Best-effort cleanup — if rename succeeded the tmp file is gone; if it
499
+ // failed (or writeFile failed), unlink it. unlink-on-missing is fine.
500
+ try {
501
+ await unlink(tmp)
502
+ } catch {
503
+ // Already gone (rename succeeded, or writeFile never produced it).
504
+ }
505
+ throw err
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Build the per-locale breakdown summary string for the closeBundle log.
511
+ *
512
+ * M2.5 — Computes per-locale path counts from `writtenPaths` by checking
513
+ * each path's leading segment against the configured locale list. Paths
514
+ * with no locale prefix go to the default locale (under
515
+ * `prefix-except-default`) or are skipped (under `prefix`, where every
516
+ * locale carries an explicit prefix — unprefixed paths are unexpected).
517
+ *
518
+ * Returns `` ` [en: 100, de: 100, cs: 100]` `` (with leading space) for
519
+ * pretty concatenation into the summary line, or empty string when i18n
520
+ * is unconfigured / writtenPaths is empty.
521
+ *
522
+ * @internal Exposed via `_internal.buildLocaleSummary` for unit tests.
523
+ */
524
+ function buildLocaleSummary(
525
+ writtenPaths: readonly string[],
526
+ i18n: I18nRoutingConfig,
527
+ ): string {
528
+ if (writtenPaths.length === 0 || i18n.locales.length === 0) return ''
529
+ const counts = new Map<string, number>()
530
+ for (const locale of i18n.locales) counts.set(locale, 0)
531
+ const defaultLocale = i18n.defaultLocale ?? i18n.locales[0] ?? ''
532
+ const strategy = i18n.strategy ?? 'prefix-except-default'
533
+ for (const p of writtenPaths) {
534
+ // Split on '/' — `/de/about` → ['', 'de', 'about']; `/about` → ['', 'about']
535
+ const firstSeg = p.split('/')[1]
536
+ if (firstSeg && counts.has(firstSeg)) {
537
+ counts.set(firstSeg, (counts.get(firstSeg) ?? 0) + 1)
538
+ } else if (strategy === 'prefix-except-default' && defaultLocale) {
539
+ // Unprefixed path under prefix-except-default belongs to the default locale.
540
+ counts.set(defaultLocale, (counts.get(defaultLocale) ?? 0) + 1)
541
+ }
542
+ // Under `prefix` strategy: unprefixed paths are unexpected (every
543
+ // locale should carry an explicit prefix). Silently skip — the
544
+ // path-collision detector (M1.4) would have caught structurally
545
+ // invalid duplicates anyway.
546
+ }
547
+ const parts: string[] = []
548
+ for (const locale of i18n.locales) parts.push(`${locale}: ${counts.get(locale) ?? 0}`)
549
+ return ` [${parts.join(', ')}]`
550
+ }
551
+
552
+ /**
553
+ * Detect duplicate URLs in the resolved-paths list. Returns the duplicates
554
+ * (sorted, unique). Empty array = no collisions.
555
+ *
556
+ * The render loop's `writtenPaths.push(p)` would silently last-wins on
557
+ * duplicates — two routes producing the same URL would have one's HTML
558
+ * overwrite the other's with no error. Catching the collision before
559
+ * render makes the conflict visible at the source-of-truth (the routes
560
+ * tree), not at the symptom (mysterious HTML drift between rebuilds).
561
+ *
562
+ * @internal Exposed via `_internal.detectPathCollisions` for unit tests.
563
+ */
564
+ function detectPathCollisions(paths: readonly string[]): string[] {
565
+ const seen = new Set<string>()
566
+ const duplicates = new Set<string>()
567
+ for (const p of paths) {
568
+ if (seen.has(p)) duplicates.add(p)
569
+ seen.add(p)
570
+ }
571
+ return [...duplicates].sort()
572
+ }
573
+
574
+ /** Format a path-collision error message with actionable guidance. */
575
+ /**
576
+ * Wiring helper: run the collision detector + throw with the formatted error
577
+ * when any collisions are found. The closeBundle handler calls this between
578
+ * `resolvePaths` and the render loop. Factored out so unit tests can exercise
579
+ * the full "detect → throw" path without spinning up a Vite SSR sub-build.
580
+ *
581
+ * @internal Exposed via `_internal.assertNoPathCollisions` for unit tests.
582
+ */
583
+ function assertNoPathCollisions(paths: readonly string[]): void {
584
+ const collisions = detectPathCollisions(paths)
585
+ if (collisions.length > 0) {
586
+ throw new Error(formatPathCollisionError(collisions))
587
+ }
588
+ }
589
+
590
+ function formatPathCollisionError(duplicates: readonly string[]): string {
591
+ const list = duplicates.map((p) => ` - ${p}`).join('\n')
592
+ 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.`
593
+ }
594
+
595
+ function resolveOutputPath(distDir: string, path: string): string {
596
+ if (path === '/') return join(distDir, 'index.html')
597
+ if (path.endsWith('.html')) return join(distDir, path)
598
+ return join(distDir, path, 'index.html')
599
+ }
600
+
601
+ // ─── Redirect emission (PR B) ──────────────────────────────────────────────
602
+ //
603
+ // The shape returned by the SSG entry's renderPath when a loader throws
604
+ // `redirect()`. The `kind` discriminator lets the closeBundle loop branch
605
+ // to the right writer (HTML file vs. redirect-manifest entry).
606
+ export interface RedirectEntry {
607
+ from: string
608
+ to: string
609
+ status: number
610
+ }
611
+
612
+ /**
613
+ * Render Netlify / Cloudflare Pages `_redirects` file content. One line
614
+ * per redirect, format: `<from> <to> <status>`. Both platforms parse this
615
+ * format identically; Vercel ignores it (use the JSON below). Lines with
616
+ * leading `#` are comments — included so the file is self-documenting in
617
+ * a deploy log.
618
+ */
619
+ function renderNetlifyRedirects(entries: RedirectEntry[]): string {
620
+ if (entries.length === 0) return ''
621
+ const lines = ['# Auto-generated by @pyreon/zero SSG. Do not edit.']
622
+ for (const e of entries) {
623
+ lines.push(`${e.from} ${e.to} ${e.status}`)
624
+ }
625
+ return `${lines.join('\n')}\n`
626
+ }
627
+
628
+ /**
629
+ * Render Vercel `_redirects.json` content. Vercel reads this from the
630
+ * `vercel.json` `redirects` array shape — but the bare `_redirects.json`
631
+ * file ships alongside as documentation / fallback for adapters that
632
+ * read either format. The 308/301/302/307 status maps to Vercel's
633
+ * `permanent: true|false` boolean (308/301 → permanent; 302/307 →
634
+ * temporary).
635
+ */
636
+ function renderVercelRedirectsJson(entries: RedirectEntry[]): string {
637
+ return `${JSON.stringify(
638
+ {
639
+ redirects: entries.map((e) => ({
640
+ source: e.from,
641
+ destination: e.to,
642
+ permanent: e.status === 301 || e.status === 308,
643
+ statusCode: e.status,
644
+ })),
645
+ },
646
+ null,
647
+ 2,
648
+ )}\n`
649
+ }
650
+
651
+ /**
652
+ * Render a meta-refresh HTML stub for static hosts that don't read
653
+ * `_redirects` (plain S3, GitHub Pages, simple file servers). The
654
+ * `<meta http-equiv="refresh" content="0; url=…">` triggers a
655
+ * client-side refresh; the canonical link is for SEO so search
656
+ * engines de-dupe the source path against the target. Status code
657
+ * has no HTML equivalent — a meta-refresh is always "client-side."
658
+ */
659
+ function renderMetaRefreshHtml(target: string): string {
660
+ // Escape the target for HTML attribute context. Targets are typically
661
+ // absolute paths (`/login`) or absolute URLs — HTML special chars are
662
+ // rare but possible (`?q=a&b=c`). Always escape `&`, `<`, `>`, `"`,
663
+ // `'` to be safe.
664
+ const escaped = target
665
+ .replace(/&/g, '&amp;')
666
+ .replace(/</g, '&lt;')
667
+ .replace(/>/g, '&gt;')
668
+ .replace(/"/g, '&quot;')
669
+ .replace(/'/g, '&#39;')
670
+ return `<!DOCTYPE html>
671
+ <html>
672
+ <head>
673
+ <meta charset="utf-8">
674
+ <meta http-equiv="refresh" content="0; url=${escaped}">
675
+ <link rel="canonical" href="${escaped}">
676
+ <title>Redirecting to ${escaped}</title>
677
+ </head>
678
+ <body>
679
+ <p>Redirecting to <a href="${escaped}">${escaped}</a>...</p>
680
+ </body>
681
+ </html>
682
+ `
683
+ }
684
+
685
+ /**
686
+ * Serialize the captured render-loop errors as a stable JSON artifact.
687
+ * Each entry has `{ path, message, name, stack }`. Errors that aren't
688
+ * `Error` instances (e.g. a loader that threw a string) are coerced via
689
+ * `String()` for `message`; `name` falls back to `'Error'`; `stack` is
690
+ * `undefined` (omitted from JSON output).
691
+ *
692
+ * Wrapped in `{ errors: [...] }` rather than emitted as a bare array so
693
+ * future fields (timing, build metadata) can be added without breaking
694
+ * existing CI consumers. Pretty-printed with 2-space indent — the file
695
+ * is meant to be read both by tooling AND humans diagnosing a failed
696
+ * build, so byte-density is not the priority.
697
+ */
698
+ /**
699
+ * Drain `items` through `concurrency` parallel workers, calling
700
+ * `processItem(item)` on each and `onSettled(item, idx)` once each
701
+ * settles (regardless of resolve/reject). The work-stealing pattern
702
+ * (each worker pulls from a shared `nextIdx++` cursor) keeps load
703
+ * balanced even when individual `processItem` calls vary widely in
704
+ * duration — a fast item doesn't make its worker idle until the slowest
705
+ * peer finishes.
706
+ *
707
+ * Settle ordering: `onSettled` fires in the order items finish, NOT in
708
+ * input order. `idx` is the index into `items` (same identity across
709
+ * `processItem` and `onSettled`), useful for "completed N of M"
710
+ * progress reporting.
711
+ *
712
+ * Concurrency clamping: ≤ 0 inputs ARE clamped to 1 — the worker pool
713
+ * is meaningless without at least one worker, but a value of `0` from
714
+ * a misconfiguration shouldn't silently hang. The actual worker count
715
+ * is `min(concurrency, items.length)` so a 2-item list with concurrency
716
+ * 10 only spawns 2 workers (no idle workers spawned).
717
+ *
718
+ * Errors from `processItem` are NOT caught here — callers must handle
719
+ * exceptions inside `processItem` (the SSG path does so via try/catch
720
+ * in `renderOne`). Errors from `onSettled` likewise propagate; in the
721
+ * SSG path the caller wraps it to record into `errors[]`. We don't
722
+ * silently swallow because that would hide real bugs.
723
+ *
724
+ * Atomic operations under Node's single-threaded JS: `nextIdx++` is
725
+ * atomic — workers never observe a partial increment, so two workers
726
+ * never claim the same index. The pool relies on this invariant; do
727
+ * NOT port to a multi-threaded runtime without revisiting.
728
+ */
729
+ async function runWithConcurrency<T>(
730
+ items: readonly T[],
731
+ concurrency: number,
732
+ processItem: (item: T, idx: number) => Promise<void>,
733
+ onSettled?: (item: T, idx: number) => Promise<void> | void,
734
+ ): Promise<void> {
735
+ const cap = Math.max(1, concurrency)
736
+ const workerCount = Math.min(cap, items.length)
737
+ if (workerCount === 0) return
738
+
739
+ let nextIdx = 0
740
+
741
+ const worker = async (): Promise<void> => {
742
+ while (true) {
743
+ const idx = nextIdx++
744
+ if (idx >= items.length) return
745
+ const item = items[idx]!
746
+ await processItem(item, idx)
747
+ if (onSettled) await onSettled(item, idx)
748
+ }
749
+ }
750
+
751
+ await Promise.all(Array.from({ length: workerCount }, () => worker()))
752
+ }
753
+
754
+ /**
755
+ * PR I — build the revalidate manifest from scanned FileRoutes + the
756
+ * list of paths that successfully rendered.
757
+ *
758
+ * For each FileRoute with a `revalidateLiteral` (captured at scan time
759
+ * via `detectRouteExports`), parse the literal as JSON (numbers and
760
+ * `false` are valid JSON tokens), build a regex from the route's
761
+ * urlPath pattern, and match against `writtenPaths`. Each matching
762
+ * concrete path goes into the manifest under the route's revalidate
763
+ * value.
764
+ *
765
+ * Returns `{}` when no routes have a revalidate literal — the caller
766
+ * checks `Object.keys(...).length > 0` before writing the manifest.
767
+ *
768
+ * Static routes match exactly (urlPath === concretePath). Dynamic
769
+ * routes (`/posts/:id`) compile to `^\/posts\/[^/]+$`. Catch-alls
770
+ * (`/blog/:slug*`) compile to `^\/blog\/.*$`. Layout / error / loading
771
+ * / not-found routes are skipped — they don't appear in writtenPaths
772
+ * anyway, but the explicit guard keeps the helper stand-alone-testable.
773
+ *
774
+ * Exposed via `_internal.buildRevalidateManifest` so it can be unit-
775
+ * tested without a full SSG round-trip.
776
+ */
777
+ export function buildRevalidateManifest(
778
+ fileRoutes: ReadonlyArray<{
779
+ urlPath: string
780
+ isLayout: boolean
781
+ isError: boolean
782
+ isLoading: boolean
783
+ isNotFound: boolean
784
+ exports?: { revalidateLiteral?: string }
785
+ }>,
786
+ writtenPaths: readonly string[],
787
+ ): Record<string, number | false> {
788
+ const manifest: Record<string, number | false> = {}
789
+ for (const route of fileRoutes) {
790
+ if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue
791
+ const literal = route.exports?.revalidateLiteral
792
+ if (literal === undefined) continue
793
+ let parsed: unknown
794
+ try {
795
+ // The literal text is a number (`60`, `3600`) or boolean
796
+ // (`false`). JSON.parse handles both. Other values (`true`,
797
+ // strings, objects) aren't valid `revalidate` shapes — skip
798
+ // silently rather than throw.
799
+ parsed = JSON.parse(literal)
800
+ } catch {
801
+ continue
802
+ }
803
+ if (typeof parsed !== 'number' && parsed !== false) continue
804
+ const value = parsed as number | false
805
+ const matcher = compileUrlPatternMatcher(route.urlPath)
806
+ for (const concretePath of writtenPaths) {
807
+ if (matcher(concretePath)) {
808
+ manifest[concretePath] = value
809
+ }
810
+ }
811
+ }
812
+ return manifest
813
+ }
814
+
815
+ /**
816
+ * Compile a route's urlPath pattern (`/posts/:id`, `/blog/:slug*`,
817
+ * `/about`) into a predicate that returns `true` for any concrete
818
+ * path that matches. Static patterns return a `===` comparator.
819
+ * Dynamic / catch-all patterns return a regex predicate.
820
+ *
821
+ * Internal helper to `buildRevalidateManifest`. Mirrors the routing
822
+ * matcher's behaviour for `:param` (single segment) and `:param*`
823
+ * (catch-all, zero-or-more segments). Doesn't need to handle every
824
+ * router edge case — the writtenPaths it matches against are already
825
+ * concrete (no params, no wildcards).
826
+ */
827
+ function compileUrlPatternMatcher(urlPath: string): (concrete: string) => boolean {
828
+ if (!urlPath.includes(':') && !urlPath.includes('*')) {
829
+ return (concrete) => concrete === urlPath
830
+ }
831
+ // Escape regex metachars (except `:` and `*` which we handle
832
+ // explicitly), then substitute `:name*` → `.*` and `:name` → `[^/]+`.
833
+ // Order matters — `:name*` MUST be replaced before `:name`.
834
+ const regex = urlPath
835
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
836
+ .replace(/:([A-Za-z_$][\w$]*)\*/g, '.*')
837
+ .replace(/:([A-Za-z_$][\w$]*)/g, '[^/]+')
838
+ const re = new RegExp(`^${regex}$`)
839
+ return (concrete) => re.test(concrete)
840
+ }
841
+
842
+ function renderErrorArtifact(entries: { path: string; error: unknown }[]): string {
843
+ const errors = entries.map(({ path, error }) => ({
844
+ path,
845
+ message: error instanceof Error ? error.message : String(error),
846
+ name: error instanceof Error ? error.name : 'Error',
847
+ stack: error instanceof Error ? error.stack : undefined,
848
+ }))
849
+ return `${JSON.stringify({ errors }, null, 2)}\n`
850
+ }
851
+
852
+ /**
853
+ * Inject a rendered SSR result into the index.html template. Prefers
854
+ * Pyreon's `<!--pyreon-head-->` / `<!--pyreon-app-->` /
855
+ * `<!--pyreon-scripts-->` placeholders; falls back to inserting before
856
+ * `</head>` / inside `<div id="app">` / before `</body>` so a bare
857
+ * Vite-style `index.html` (no Pyreon comments) still receives content.
858
+ *
859
+ * Factored out of the per-path render loop so the 404 emission path can
860
+ * reuse the exact same injection rules — keeps the rendered _404.tsx
861
+ * subject to the same head/body/scripts pipeline as regular pages
862
+ * (styler tag, @pyreon/head meta, hashed asset preload links).
863
+ */
864
+ function injectIntoTemplate(
865
+ template: string,
866
+ result: { appHtml: string; head: string; loaderScript: string },
867
+ ): string {
868
+ let html = template
869
+ if (html.includes('<!--pyreon-head-->')) {
870
+ html = html.replace('<!--pyreon-head-->', result.head)
871
+ } else if (result.head) {
872
+ html = html.replace('</head>', `${result.head}</head>`)
873
+ }
874
+ if (html.includes('<!--pyreon-app-->')) {
875
+ html = html.replace('<!--pyreon-app-->', result.appHtml)
876
+ } else if (result.appHtml) {
877
+ const appDivMatch = html.match(/<div\s+id=["']app["']\s*>([\s\S]*?)<\/div>/)
878
+ if (appDivMatch) {
879
+ html = html.replace(appDivMatch[0], `<div id="app">${result.appHtml}</div>`)
880
+ } else {
881
+ html = html.replace('</body>', `<div id="app">${result.appHtml}</div></body>`)
882
+ }
883
+ }
884
+ if (html.includes('<!--pyreon-scripts-->')) {
885
+ html = html.replace('<!--pyreon-scripts-->', result.loaderScript)
886
+ } else if (result.loaderScript) {
887
+ html = html.replace('</body>', `${result.loaderScript}</body>`)
888
+ }
889
+ return html
890
+ }
891
+
892
+ /**
893
+ * Plugin that performs SSG when `mode: "ssg"` is configured. Wires into
894
+ * Vite's `closeBundle` hook so it runs once after the main client build
895
+ * completes. The recursive SSR sub-build is gated by an env flag.
896
+ */
897
+ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
898
+ const config = resolveConfig(userConfig)
899
+ let root = ''
900
+ let distDir = ''
901
+ // Track whether this plugin instance is running inside the inner SSR
902
+ // sub-build (where it must be a no-op) vs. the outer client build.
903
+ const isInnerBuild = process.env[SSG_BUILD_FLAG] === '1'
904
+
905
+ return {
906
+ name: 'pyreon-zero-ssg',
907
+ apply: 'build',
908
+ enforce: 'post',
909
+
910
+ configResolved(resolved) {
911
+ root = resolved.root
912
+ distDir = resolve(root, resolved.build.outDir)
913
+ },
914
+
915
+ async closeBundle() {
916
+ if (config.mode !== 'ssg') return
917
+ if (isInnerBuild) return
918
+
919
+ const ssrOutDir = join(distDir, '.zero-ssg-server')
920
+ const indexHtmlPath = join(distDir, 'index.html')
921
+
922
+ if (!existsSync(indexHtmlPath)) {
923
+ // Client build hasn't produced index.html — nothing we can wrap.
924
+ // Most likely: user is running `vite build --ssr` directly, in
925
+ // which case this plugin shouldn't be active anyway.
926
+ // oxlint-disable-next-line no-console
927
+ console.warn(
928
+ `[zero:ssg] Skipping SSG — ${indexHtmlPath} not found. Did the client build complete?`,
929
+ )
930
+ return
931
+ }
932
+
933
+ // Materialize the SSR entry to disk inside the routes directory so
934
+ // its imports resolve relative to the user's source tree. Doing this
935
+ // INSIDE node_modules-equivalent paths breaks Vite's plugin-resolution
936
+ // semantics; placing it next to the user's routes lets zero's main
937
+ // plugin pick it up identically to user code. Cleaned up after the
938
+ // build.
939
+ const entryPath = join(root, SSR_ENTRY_FILENAME)
940
+ // PR K: bake the configured locales into the SSG entry source so the
941
+ // per-locale 404 walker can detect which RouteRecord serves which
942
+ // locale at module-eval time inside the SSR sub-build.
943
+ const i18nLocales = config.i18n?.locales ?? []
944
+ await writeFile(entryPath, renderSsrEntrySource(i18nLocales), 'utf-8')
945
+
946
+ // Vite's programmatic build API. Loaded lazily so the plugin doesn't
947
+ // pull `vite` into the runtime dep graph at module-evaluation time.
948
+ const { build } = await import('vite')
949
+
950
+ // Inner SSR sub-build. Re-assembles zero's plugin chain plus
951
+ // `@pyreon/vite-plugin` (JSX compiler) — every Pyreon app already
952
+ // has both because zero is built on top of pyreon. Loading both
953
+ // lazily keeps the SSG plugin off the module-eval critical path.
954
+ // Env-flag gate prevents the inner ssgPlugin instance from
955
+ // re-triggering itself.
956
+ process.env[SSG_BUILD_FLAG] = '1'
957
+ try {
958
+ const [{ zeroPlugin }, pyreonModule] = await Promise.all([
959
+ import('./vite-plugin'),
960
+ import('@pyreon/vite-plugin'),
961
+ ])
962
+ const pyreon = (pyreonModule as { default: () => unknown }).default
963
+
964
+ await build({
965
+ root,
966
+ mode: 'production',
967
+ logLevel: 'error',
968
+ configFile: false,
969
+ publicDir: false,
970
+ plugins: [pyreon(), zeroPlugin(userConfig)] as Plugin[],
971
+ resolve: { conditions: ['bun'] },
972
+ build: {
973
+ ssr: entryPath,
974
+ outDir: ssrOutDir,
975
+ emptyOutDir: true,
976
+ target: 'esnext',
977
+ rollupOptions: {
978
+ input: entryPath,
979
+ output: {
980
+ format: 'es',
981
+ entryFileNames: 'entry-server.mjs',
982
+ },
983
+ external: [/^node:/],
984
+ },
985
+ },
986
+ })
987
+ } finally {
988
+ delete process.env[SSG_BUILD_FLAG]
989
+ // Remove the synthetic entry file so it never lands in user's
990
+ // working tree.
991
+ try {
992
+ await rm(entryPath, { force: true })
993
+ } catch {
994
+ // best-effort cleanup
995
+ }
996
+ }
997
+
998
+ // Load the built renderer. Use a file:// URL to avoid Node import
999
+ // cache collisions across multiple builds within the same process.
1000
+ const handlerPath = join(ssrOutDir, 'entry-server.mjs')
1001
+ if (!existsSync(handlerPath)) {
1002
+ // oxlint-disable-next-line no-console
1003
+ console.warn(`[zero:ssg] SSR build did not produce ${handlerPath} — skipping prerender`)
1004
+ return
1005
+ }
1006
+ // The path is computed at runtime from a freshly-built SSR artifact
1007
+ // — Vite's `dynamic-import-vars` plugin can't statically analyze the
1008
+ // import. Without the `@vite-ignore` hint, Vite emits a console
1009
+ // warning on every consumer's dev server boot ("The above dynamic
1010
+ // import cannot be analyzed by Vite"), which looks alarming but is
1011
+ // expected here. Suppress per Vite's own recommendation.
1012
+ const handlerMod = (await import(/* @vite-ignore */ pathToFileURL(handlerPath).href)) as {
1013
+ // PR B — return shape is a discriminated union: regular paths
1014
+ // produce HTML, redirect-throwing loaders produce a redirect
1015
+ // descriptor for the manifest writer.
1016
+ default: (
1017
+ path: string,
1018
+ ) => Promise<
1019
+ | { kind: 'html'; appHtml: string; head: string; loaderScript: string }
1020
+ | { kind: 'redirect'; from: string; to: string; status: number }
1021
+ >
1022
+ // PR A — getStaticPaths registry collected from the routes tree.
1023
+ __getStaticPathsRegistry?: GetStaticPathsRegistry
1024
+ // PR C — 404 emission. The synthetic SSG entry walks the routes
1025
+ // tree at module-eval time and exports the first
1026
+ // `notFoundComponent` it finds (root-level `_404.tsx` in the
1027
+ // common case), plus an async `__renderNotFound()` that pushes
1028
+ // it through the same renderWithHead pipeline as regular paths.
1029
+ // The outer plugin reads both: presence gates the emission,
1030
+ // the renderer produces the same `{ appHtml, head, loaderScript }`
1031
+ // shape so `injectIntoTemplate` can reuse the same injection
1032
+ // rules. The `?` keeps zero forward-compatible — an entry built
1033
+ // before PR C just doesn't expose these and emit404 silently
1034
+ // no-ops.
1035
+ __notFoundComponent?: unknown
1036
+ __notFoundComponentsByLocale?: Map<string | null, unknown>
1037
+ __renderNotFound?: (
1038
+ locale?: string | null,
1039
+ ) => Promise<{ appHtml: string; head: string; loaderScript: string } | null>
1040
+ }
1041
+ const renderPath = handlerMod.default
1042
+ const registry = handlerMod.__getStaticPathsRegistry
1043
+
1044
+ // Read the user's built index.html template. Vite has just produced it
1045
+ // with hashed asset URLs (`/assets/index-XYZ.js`), preload links, etc.
1046
+ // We inject the rendered head/body/loader-data into placeholder
1047
+ // comments — same convention as zero's dev SSR. If the template lacks
1048
+ // the placeholders, we fall back to inserting before `</head>` and
1049
+ // `</body>` respectively so a bare `index.html` still works.
1050
+ const template = await readFile(indexHtmlPath, 'utf-8')
1051
+
1052
+ // Errors from getStaticPaths run AND per-page render are collected
1053
+ // into the same array so the post-render summary catches both. The
1054
+ // SSG plugin completes either way — a single bad route shouldn't
1055
+ // abort the whole site build.
1056
+ const errors: { path: string; error: unknown }[] = []
1057
+
1058
+ // Resolve paths and render.
1059
+ const routesDir = join(root, 'src', 'routes')
1060
+ const paths = await resolvePaths(config, routesDir, registry, errors)
1061
+
1062
+ if (paths.length === 0) {
1063
+ // oxlint-disable-next-line no-console
1064
+ console.warn('[zero:ssg] No static paths to prerender — set ssg.paths in zero config')
1065
+ await rm(ssrOutDir, { recursive: true, force: true })
1066
+ return
1067
+ }
1068
+
1069
+ // M1.4 — Detect duplicate paths BEFORE the render loop. Two routes
1070
+ // producing the same URL (a static `/posts/foo.tsx` + a dynamic
1071
+ // `[id].tsx` with `getStaticPaths: [{id:'foo'}]`) would silently
1072
+ // last-wins under `writtenPaths.push(p)`. Surface as an actionable
1073
+ // error with the duplicate URL listed so users can fix the source
1074
+ // route conflict instead of wondering why HTML mysteriously changes
1075
+ // between rebuilds. Bisect-verified via `assertNoPathCollisions` unit
1076
+ // tests: removing the call → fixture with dupes proceeds to render
1077
+ // and silently overwrites; restoring → throws with the formatted
1078
+ // error listing every duplicate URL.
1079
+ assertNoPathCollisions(paths)
1080
+
1081
+ let pages = 0
1082
+ // PR B — collect redirects from loader-throws so the post-render
1083
+ // step can write `_redirects` / `_redirects.json` / meta-refresh
1084
+ // HTML files. Loader redirects DON'T produce a per-path index.html
1085
+ // — the redirect IS the response.
1086
+ const redirects: RedirectEntry[] = []
1087
+ // PR F — track every path that produced a `dist/<path>/index.html` so
1088
+ // the post-loop step can emit `dist/_pyreon-ssg-paths.json` for
1089
+ // `seoPlugin({ sitemap: { useSsgPaths: true } })` to read at its
1090
+ // own `closeBundle`. We track the resolved URL paths (post-
1091
+ // getStaticPaths expansion + per-locale duplication when PR H
1092
+ // ships) — exactly what the sitemap.xml needs. Paths that errored
1093
+ // OR redirected are intentionally absent: errored pages have no
1094
+ // HTML to link to, and redirect sources go to `_redirects`, not
1095
+ // sitemap.xml (linking to a redirect source confuses crawlers).
1096
+ const writtenPaths: string[] = []
1097
+ const start = Date.now()
1098
+
1099
+ // PR D — render a single path. Extracted from the inline loop body
1100
+ // so the worker-pool below can call it concurrently. All side
1101
+ // effects (redirects.push, errors.push, writtenPaths.push, pages++)
1102
+ // are append-only mutations on shared arrays/counter — safe under
1103
+ // the concurrent worker pattern because Node is single-threaded
1104
+ // (each worker yields at every `await`, never in mid-statement).
1105
+ //
1106
+ // Returns the path on settle so the worker can fire onProgress with
1107
+ // it. Throws are NOT propagated — they're caught here and recorded
1108
+ // in `errors[]`, so a single failed path can't take down the worker.
1109
+ const renderOne = async (p: string): Promise<void> => {
1110
+ // M2.3 — emit `ssg.pathRender` per attempted render. Pair with
1111
+ // `ssg.pathWrite` / `ssg.pathRedirect` / `ssg.pathError` to see
1112
+ // per-path settle distribution.
1113
+ if (__DEV__) _countSink.__pyreon_count__?.('ssg.pathRender')
1114
+ try {
1115
+ const result = await Promise.race([
1116
+ renderPath(p),
1117
+ new Promise<never>((_, reject) =>
1118
+ setTimeout(() => reject(new Error(`Prerender timeout for "${p}" (30s)`)), 30_000),
1119
+ ),
1120
+ ])
1121
+
1122
+ if (result.kind === 'redirect') {
1123
+ // M2.3 — track redirect outcomes separately from successful renders.
1124
+ if (__DEV__) _countSink.__pyreon_count__?.('ssg.pathRedirect')
1125
+ // PR B — loader threw `redirect()`. Record for the manifest;
1126
+ // optionally emit a meta-refresh HTML stub at the source path.
1127
+ redirects.push({ from: result.from, to: result.to, status: result.status })
1128
+
1129
+ if (config.ssg?.redirectsAsHtml === 'meta-refresh') {
1130
+ const filePath = resolveOutputPath(distDir, p)
1131
+ const resolvedOut = resolve(distDir)
1132
+ if (!resolve(filePath).startsWith(resolvedOut)) {
1133
+ errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1134
+ return
1135
+ }
1136
+ await mkdir(dirname(filePath), { recursive: true })
1137
+ await writeFile(filePath, renderMetaRefreshHtml(result.to), 'utf-8')
1138
+ }
1139
+ return
1140
+ }
1141
+
1142
+ const html = injectIntoTemplate(template, result)
1143
+
1144
+ const filePath = resolveOutputPath(distDir, p)
1145
+
1146
+ // Path-traversal guard — same as @pyreon/server's prerender.
1147
+ const resolvedOut = resolve(distDir)
1148
+ if (!resolve(filePath).startsWith(resolvedOut)) {
1149
+ errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1150
+ return
1151
+ }
1152
+
1153
+ await mkdir(dirname(filePath), { recursive: true })
1154
+ await writeFile(filePath, html, 'utf-8')
1155
+ pages++
1156
+ writtenPaths.push(p)
1157
+ // M2.3 — track successful HTML emits. `ssg.pathRender - ssg.pathWrite
1158
+ // - ssg.pathRedirect - ssg.pathError` should sum to roughly zero;
1159
+ // non-zero residual = paths swallowed silently somewhere.
1160
+ if (__DEV__) _countSink.__pyreon_count__?.('ssg.pathWrite')
1161
+ } catch (error) {
1162
+ // M2.3 — track render-error outcomes.
1163
+ if (__DEV__) _countSink.__pyreon_count__?.('ssg.pathError')
1164
+ errors.push({ path: p, error })
1165
+ // PR G — onPathError fallback hook. The user-supplied callback
1166
+ // can return HTML to write at the path's URL, OR null to skip.
1167
+ // The error is ALREADY recorded in `errors[]` before this call
1168
+ // so the post-build summary catches it regardless of what the
1169
+ // callback does. The callback's own throws are caught + recorded
1170
+ // as a separate entry — a buggy callback shouldn't take down
1171
+ // the whole build, AND we surface the bug instead of swallowing.
1172
+ if (config.ssg?.onPathError) {
1173
+ try {
1174
+ const fallbackHtml = await config.ssg.onPathError(p, error)
1175
+ if (typeof fallbackHtml === 'string') {
1176
+ const filePath = resolveOutputPath(distDir, p)
1177
+ const resolvedOut = resolve(distDir)
1178
+ if (!resolve(filePath).startsWith(resolvedOut)) {
1179
+ errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1180
+ return
1181
+ }
1182
+ await mkdir(dirname(filePath), { recursive: true })
1183
+ await writeFile(filePath, fallbackHtml, 'utf-8')
1184
+ pages++
1185
+ }
1186
+ } catch (callbackError) {
1187
+ errors.push({ path: `${p} (onPathError)`, error: callbackError })
1188
+ }
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ // PR D — worker-pool concurrency. Default 4 workers in flight; the
1194
+ // user can opt into more via `ssg.concurrency` (CI multi-core) or
1195
+ // back to 1 for fully sequential (the pre-PR-D shape). Per-path
1196
+ // logic lives in `renderOne()`; the pool primitive lives in
1197
+ // `runWithConcurrency()` (testable in isolation).
1198
+ //
1199
+ // Progress ordering: `onProgress` fires in path-SETTLE order, NOT
1200
+ // input order. Across workers, callbacks may run in parallel —
1201
+ // the runtime doesn't serialize them. If you need strict serial
1202
+ // output (e.g. a progress bar painting one line at a time), wrap
1203
+ // the callback to push to a single-consumer queue.
1204
+ const concurrency = Math.max(1, config.ssg?.concurrency ?? 4)
1205
+ let completed = 0
1206
+
1207
+ await runWithConcurrency(paths, concurrency, renderOne, async (p) => {
1208
+ completed++
1209
+ if (config.ssg?.onProgress) {
1210
+ try {
1211
+ await config.ssg.onProgress({
1212
+ completed,
1213
+ total: paths.length,
1214
+ currentPath: p,
1215
+ elapsed: Date.now() - start,
1216
+ })
1217
+ } catch (callbackError) {
1218
+ errors.push({ path: `${p} (onProgress)`, error: callbackError })
1219
+ }
1220
+ }
1221
+ })
1222
+
1223
+ // PR F — Sitemap path manifest.
1224
+ //
1225
+ // Write the resolved URL paths to `dist/_pyreon-ssg-paths.json` so
1226
+ // `seoPlugin({ sitemap: { useSsgPaths: true } })` can read them at
1227
+ // its own `closeBundle` and emit a sitemap that includes dynamic-
1228
+ // route enumerations (PR A's `getStaticPaths`) AND per-locale
1229
+ // variants (PR H, when shipped). Without this manifest, `seoPlugin`
1230
+ // walks the file-system route tree directly and silently skips
1231
+ // dynamic routes (`[id]`) because their concrete values aren't
1232
+ // knowable at file-scan time.
1233
+ //
1234
+ // The 404 path is omitted intentionally — error pages don't belong
1235
+ // in sitemap.xml. Redirected sources are ALSO omitted (they're
1236
+ // already absent from `writtenPaths` because the loop hits the
1237
+ // redirect branch + `continue` before the push).
1238
+ //
1239
+ // Always emit when SSG ran. Filename starts with `_` so static
1240
+ // hosts that publish the dist root don't ALSO publish this internal
1241
+ // manifest as a public asset — convention matches `_redirects` /
1242
+ // `_redirects.json`. The seoPlugin reads + cleans it up after use.
1243
+ if (writtenPaths.length > 0) {
1244
+ // PR K (i18n follow-up): also embed the i18n config in the manifest
1245
+ // when present so `seoPlugin({ sitemap: { useSsgPaths: true } })`
1246
+ // can emit hreflang cross-references without duplicating the i18n
1247
+ // declaration. Zero-config win — users opt into i18n routing via
1248
+ // `zero({ i18n: { ... } })` once and both the route duplication
1249
+ // (PR H) AND the sitemap hreflang automatically pick it up.
1250
+ // M2.1 — atomic write so the seoPlugin (or any consumer) never reads
1251
+ // a half-written manifest if the build is interrupted mid-flush.
1252
+ await writeFileAtomic(
1253
+ join(distDir, '_pyreon-ssg-paths.json'),
1254
+ `${JSON.stringify(
1255
+ {
1256
+ paths: writtenPaths,
1257
+ ...(config.i18n ? { i18n: config.i18n } : {}),
1258
+ },
1259
+ null,
1260
+ 2,
1261
+ )}\n`,
1262
+ )
1263
+ }
1264
+
1265
+ // PR I — Build-time ISR revalidate manifest.
1266
+ //
1267
+ // Walk the route files for `export const revalidate = <n|false>`
1268
+ // declarations (captured as a literal at scan time), match each
1269
+ // declaration's url-pattern against the rendered written paths,
1270
+ // and emit `dist/_pyreon-revalidate.json` mapping concrete path
1271
+ // → revalidate value. Adapters (vercel/cloudflare/netlify) read
1272
+ // this manifest at deploy time to wire platform-specific ISR
1273
+ // (Vercel `output/config.json`, Cloudflare cache rules, Netlify
1274
+ // redirect headers).
1275
+ //
1276
+ // Path matching: static routes (`/about`) match writtenPaths[i]
1277
+ // exactly. Dynamic routes (`/posts/:id`) and catch-all
1278
+ // (`/blog/:slug*`) compile to a regex and match every concrete
1279
+ // child. Same revalidate value applies to all enumerated children
1280
+ // — `posts/[id].tsx` with `export const revalidate = 60` gives
1281
+ // `/posts/1`, `/posts/2`, `/posts/3` ALL `60` in the manifest.
1282
+ //
1283
+ // Filename starts with `_` so the static-host publish step
1284
+ // doesn't expose it as a public asset (matches `_redirects` /
1285
+ // `_redirects.json` / `_pyreon-ssg-paths.json` convention).
1286
+ // ONLY emit when at least one route has a revalidate literal —
1287
+ // empty manifests aren't useful, and absence is a meaningful
1288
+ // signal to adapters ("no per-route ISR config, fall through to
1289
+ // platform defaults").
1290
+ // M2.5 — track the count for the build summary breakdown.
1291
+ let revalidateCount = 0
1292
+ if (existsSync(routesDir)) {
1293
+ try {
1294
+ const fileRoutesWithExports = await scanRouteFilesWithExports(routesDir, config.mode)
1295
+ const revalidateManifest = buildRevalidateManifest(
1296
+ fileRoutesWithExports,
1297
+ writtenPaths,
1298
+ )
1299
+ revalidateCount = Object.keys(revalidateManifest).length
1300
+ if (revalidateCount > 0) {
1301
+ // M2.1 — atomic write. Adapters polling for the manifest at
1302
+ // deploy time should see the full document or nothing.
1303
+ await writeFileAtomic(
1304
+ join(distDir, '_pyreon-revalidate.json'),
1305
+ `${JSON.stringify({ revalidate: revalidateManifest }, null, 2)}\n`,
1306
+ )
1307
+ }
1308
+ } catch (err) {
1309
+ // Manifest emission is opt-in via the `revalidate` export.
1310
+ // A scan failure shouldn't abort the build — just record it
1311
+ // alongside the other SSG errors so CI surfaces it.
1312
+ errors.push({ path: '(revalidate-manifest)', error: err })
1313
+ }
1314
+ }
1315
+
1316
+ // PR C — Auto-emit dist/404.html from _404.tsx convention.
1317
+ // PR K (i18n follow-up) — also emit dist/{locale}/404.html for every
1318
+ // locale subtree that has its own _404 (i.e. every non-default locale
1319
+ // under `prefix-except-default`, every locale under `prefix`).
1320
+ //
1321
+ // fs-router scans `_404.tsx` / `_not-found.tsx` and attaches it as
1322
+ // `notFoundComponent` on its parent layout RouteRecord (or directly
1323
+ // on the page record when no wrapping layout exists — which is the
1324
+ // per-locale subtree shape after PR H's root-layout-skip). The
1325
+ // synthetic SSG entry walks the routes tree at module-eval time,
1326
+ // collects per-locale 404 components into a Map keyed by locale
1327
+ // (or `null` for the default / no-i18n case), and exposes an
1328
+ // async `__renderNotFound(locale)` that renders the matching one.
1329
+ //
1330
+ // We iterate that map here and write each to the right path:
1331
+ // - `null` locale → `dist/404.html` (static-host convention —
1332
+ // Netlify / Cloudflare Pages / GitHub Pages / S3+CloudFront
1333
+ // serve this for unmatched URLs by default)
1334
+ // - non-null locale `de` → `dist/de/404.html` (mirrors how those
1335
+ // same hosts serve per-prefix 404s when configured with the
1336
+ // `errors_404` field per directory in `netlify.toml` / etc.)
1337
+ //
1338
+ // Per-locale 404 lets search engines + users see a 404 page in the
1339
+ // right language with the right navigation chrome, not the
1340
+ // default-locale page bolted onto a German URL.
1341
+ //
1342
+ // Gated by `config.ssg.emit404 !== false` (default true). Skipped
1343
+ // silently when no `_404.tsx` exists anywhere — the Map is empty
1344
+ // and the loop body never runs.
1345
+ let emitted404Count = 0
1346
+ if (config.ssg?.emit404 !== false && handlerMod.__renderNotFound) {
1347
+ // Back-compat: old SSG entries (pre-PR-K) expose only the singular
1348
+ // `__notFoundComponent`; new entries expose the Map. Build a synthetic
1349
+ // single-entry iterator from the singular when the Map isn't there
1350
+ // so users with a stale SSR_ENTRY cache or downstream consumers that
1351
+ // never rebuilt their lib/ keep emitting one 404.html.
1352
+ const localeEntries: (string | null)[]
1353
+ = handlerMod.__notFoundComponentsByLocale instanceof Map
1354
+ ? [...handlerMod.__notFoundComponentsByLocale.keys()]
1355
+ : handlerMod.__notFoundComponent
1356
+ ? [null]
1357
+ : []
1358
+
1359
+ for (const locale of localeEntries) {
1360
+ try {
1361
+ const result = await Promise.race([
1362
+ handlerMod.__renderNotFound(locale),
1363
+ new Promise<never>((_, reject) =>
1364
+ setTimeout(
1365
+ () =>
1366
+ reject(
1367
+ new Error(
1368
+ `Prerender timeout for ${locale == null ? '404' : `${locale}/404`} (30s)`,
1369
+ ),
1370
+ ),
1371
+ 30_000,
1372
+ ),
1373
+ ),
1374
+ ])
1375
+ if (result) {
1376
+ const html = injectIntoTemplate(template, result)
1377
+ const filePath
1378
+ = locale == null
1379
+ ? join(distDir, '404.html')
1380
+ : join(distDir, locale, '404.html')
1381
+ // Ensure the locale subdirectory exists before writeFile —
1382
+ // dist/de/ may not have been created yet if no static pages
1383
+ // landed under that locale, but most apps will have at
1384
+ // least the locale-root index.html so this is usually a no-op.
1385
+ if (locale != null) {
1386
+ await mkdir(join(distDir, locale), { recursive: true })
1387
+ }
1388
+ await writeFile(filePath, html, 'utf-8')
1389
+ emitted404Count++
1390
+ // M2.3 — track per-locale 404 emits.
1391
+ if (__DEV__) _countSink.__pyreon_count__?.('ssg.404Emit')
1392
+ }
1393
+ } catch (error) {
1394
+ errors.push({
1395
+ path: locale == null ? '404.html' : `${locale}/404.html`,
1396
+ error,
1397
+ })
1398
+ }
1399
+ }
1400
+ }
1401
+
1402
+ // PR B — emit redirect manifests when loaders threw `redirect()`.
1403
+ // Both Netlify (`_redirects`) and Vercel (`_redirects.json`)
1404
+ // formats ship together so the user doesn't have to pick at SSG
1405
+ // time. The file is empty / absent when no redirects fired.
1406
+ if (redirects.length > 0 && config.ssg?.emitRedirects !== false) {
1407
+ // M2.1 — atomic so an interrupted build doesn't leave a half-
1408
+ // written `_redirects` file that adapters / static hosts misparse.
1409
+ await writeFileAtomic(
1410
+ join(distDir, '_redirects'),
1411
+ renderNetlifyRedirects(redirects),
1412
+ )
1413
+ await writeFileAtomic(
1414
+ join(distDir, '_redirects.json'),
1415
+ renderVercelRedirectsJson(redirects),
1416
+ )
1417
+ }
1418
+
1419
+ // PR G — emit `dist/_pyreon-ssg-errors.json` summarising every error
1420
+ // captured during the render loop (path traversal, render exception,
1421
+ // getStaticPaths throw, onPathError throw, 404 render fail). The file
1422
+ // is ONLY written when `errors.length > 0` — successful builds don't
1423
+ // leak an empty manifest. Reading it lets CI gate on render failures
1424
+ // without parsing console output.
1425
+ //
1426
+ // Default: 'json' (write the artifact). Set `errorArtifact: 'none'`
1427
+ // to opt out — errors stay console-only, matching pre-PR-G behaviour.
1428
+ if (errors.length > 0 && config.ssg?.errorArtifact !== 'none') {
1429
+ // M2.1 — atomic so CI that reads this manifest (`jq '.errors | length'`)
1430
+ // never sees a partial-JSON parse error from an interrupted build.
1431
+ await writeFileAtomic(
1432
+ join(distDir, '_pyreon-ssg-errors.json'),
1433
+ renderErrorArtifact(errors),
1434
+ )
1435
+ }
1436
+
1437
+ // PR J — adapter.build() invocation for SSG mode. Each platform
1438
+ // adapter (vercel/cloudflare/netlify) writes its routing config
1439
+ // for the prerendered dist; static / node / bun adapters no-op
1440
+ // for SSG (they're SSR runners or trivial).
1441
+ //
1442
+ // Pre-PR-J: Adapter.build() was implemented per-platform but
1443
+ // never invoked from any build pipeline — the methods existed
1444
+ // but no production code path called them. SSG is the natural
1445
+ // first hook because the dist/ shape is final at this point
1446
+ // (all paths rendered, redirects + sitemap manifests written).
1447
+ // Adapter throws are caught here so a buggy adapter can't take
1448
+ // down the rest of the build (sitemap, error artifact, summary
1449
+ // log all already ran). The error lands in the error log + the
1450
+ // _pyreon-ssg-errors.json if errorArtifact was set, but we
1451
+ // emit it AFTER the artifact write so the path-render errors
1452
+ // already in the file aren't displaced.
1453
+ const adapter = resolveAdapter(config)
1454
+ try {
1455
+ await adapter.build({ kind: 'ssg', outDir: distDir, config })
1456
+ } catch (adapterError) {
1457
+ errors.push({ path: `(adapter:${adapter.name})`, error: adapterError })
1458
+ }
1459
+
1460
+ // Cleanup the SSR build artifacts — they're an implementation detail
1461
+ // and shouldn't ship to the static host.
1462
+ await rm(ssrOutDir, { recursive: true, force: true })
1463
+
1464
+ const elapsed = Date.now() - start
1465
+ const redirectsSummary = redirects.length > 0 ? ` + ${redirects.length} redirect(s)` : ''
1466
+ const concurrencySummary = concurrency > 1 ? ` (concurrency: ${concurrency})` : ''
1467
+ const adapterSummary = adapter.name !== 'node' ? ` [adapter: ${adapter.name}]` : ''
1468
+ // M2.5 — revalidate-manifest entry count surfaces in the summary so
1469
+ // users see at a glance whether per-route ISR config landed in
1470
+ // `_pyreon-revalidate.json` (vs silently empty because no route
1471
+ // exported a `revalidate` literal).
1472
+ const revalidateSummary =
1473
+ revalidateCount > 0 ? ` + ${revalidateCount} revalidate path(s)` : ''
1474
+ // M2.5 — per-locale breakdown when i18n config is active. The
1475
+ // breakdown is computed from `writtenPaths` — each path either has a
1476
+ // locale prefix (`/de/...`) or belongs to the default locale (under
1477
+ // `prefix-except-default`) / is locale-less (no i18n). Surfaces
1478
+ // shape mismatches: `[en: 100, de: 90, cs: 100]` flags that de had
1479
+ // 10 paths skipped relative to the others.
1480
+ const localeSummary = config.i18n ? buildLocaleSummary(writtenPaths, config.i18n) : ''
1481
+ // oxlint-disable-next-line no-console
1482
+ console.log(
1483
+ `[zero:ssg] Prerendered ${pages} page(s)${
1484
+ emitted404Count > 0
1485
+ ? emitted404Count === 1
1486
+ ? ' + 404.html'
1487
+ : ` + ${emitted404Count} 404 pages`
1488
+ : ''
1489
+ }${redirectsSummary}${revalidateSummary} in ${elapsed}ms${concurrencySummary}${adapterSummary}${localeSummary}` +
1490
+ (errors.length > 0 ? ` (${errors.length} error(s))` : ''),
1491
+ )
1492
+ for (const { path: errPath, error } of errors) {
1493
+ // oxlint-disable-next-line no-console
1494
+ console.error(`[zero:ssg] Failed to prerender "${errPath}":`, error)
1495
+ }
1496
+ },
1497
+ } satisfies Plugin
1498
+ }
1499
+
1500
+ // ─── Test exports ─────────────────────────────────────────────────────────────
1501
+ //
1502
+ // Internal helpers exposed for unit tests. Not part of the public API.
1503
+
1504
+ export const _internal = {
1505
+ resolvePaths,
1506
+ autoDetectStaticPaths,
1507
+ resolveOutputPath,
1508
+ expandUrlPattern,
1509
+ injectIntoTemplate,
1510
+ renderNetlifyRedirects,
1511
+ renderVercelRedirectsJson,
1512
+ renderMetaRefreshHtml,
1513
+ renderErrorArtifact,
1514
+ runWithConcurrency,
1515
+ buildRevalidateManifest,
1516
+ renderSsrEntrySource,
1517
+ SSR_ENTRY_FILENAME,
1518
+ detectPathCollisions,
1519
+ formatPathCollisionError,
1520
+ assertNoPathCollisions,
1521
+ writeFileAtomic,
1522
+ buildLocaleSummary,
1523
+ }