@pyreon/zero 0.24.4 → 0.24.6

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 (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. package/src/vite-plugin.ts +0 -848
package/src/ssg-plugin.ts DELETED
@@ -1,1582 +0,0 @@
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, sep } 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
- // Path-escape guard. The value is substituted verbatim into the
377
- // URL that becomes a `dist/<path>/index.html` write target. A
378
- // single (non-catch-all) `:slug` is ONE segment — a value
379
- // containing `/` or being `.`/`..` (e.g. an unsanitized CMS slug
380
- // `../../secret`) would escape the intended structure and write
381
- // outside it. Catch-all `:slug*` legitimately spans segments
382
- // (`a/b/c`), so it's exempt from the `/` check but still rejects
383
- // `.`/`..` traversal segments.
384
- const segs = isCatchAll ? value.split('/') : [value]
385
- if (
386
- (!isCatchAll && value.includes('/')) ||
387
- segs.some((s) => s === '.' || s === '..')
388
- ) {
389
- throw new Error(
390
- `[zero:ssg] getStaticPaths for "${pattern}" produced an unsafe "${name}" value ` +
391
- `(${JSON.stringify(value)}): a ${isCatchAll ? 'catch-all' : 'dynamic'} segment ` +
392
- `must not contain path-traversal ("." / "..")${isCatchAll ? '' : ' or "/"'}.`,
393
- )
394
- }
395
- return value
396
- })
397
- .join('/')
398
- }
399
-
400
- /**
401
- * Auto-detect static paths from the route tree AND expand dynamic routes
402
- * via each route's `getStaticPaths` export (when present). A "static" path
403
- * is one with NO dynamic segments (`[id]`, `[...rest]`); a "dynamic" path
404
- * with `getStaticPaths` is expanded via the registry; remaining dynamic
405
- * routes are silently skipped (the user must hand-list them in
406
- * `ssg.paths`).
407
- */
408
- async function autoDetectStaticPaths(
409
- routesDir: string,
410
- registry?: GetStaticPathsRegistry,
411
- errors: { path: string; error: unknown }[] = [],
412
- i18n?: I18nRoutingConfig,
413
- ): Promise<string[]> {
414
- // Routes dir missing → fall back to "/" anyway. A project that doesn't
415
- // expose routes via fs-routing (custom routes module, single-page app
416
- // shell, etc.) still needs at least an index.html so static hosts have
417
- // a default response. The user can always set explicit `ssg.paths` to
418
- // override this floor.
419
- if (!existsSync(routesDir)) return ['/']
420
- const files = await scanRouteFiles(routesDir)
421
- // PR H — fan routes into per-locale variants when `i18n` is configured.
422
- // Each duplicated FileRoute carries the same `getStaticPaths` enumerator
423
- // (via `exports`) so dynamic + i18n cardinality compounds naturally:
424
- // `/blog/[slug]` × `[en, de]` × 3 slugs → 6 paths under
425
- // `prefix-except-default`.
426
- const baseRoutes = parseFileRoutes(files)
427
- const fileRoutes = i18n ? expandRoutesForLocales(baseRoutes, i18n) : baseRoutes
428
-
429
- const out: string[] = []
430
- for (const r of fileRoutes) {
431
- if (r.isLayout || r.isError || r.isLoading || r.isNotFound) continue
432
- const path = r.urlPath
433
- if (!path) continue
434
-
435
- // Static path — emit as-is.
436
- if (!/[:*]/.test(path)) {
437
- out.push(path)
438
- continue
439
- }
440
-
441
- // Dynamic path — expand via getStaticPaths if available.
442
- const enumerator = registry?.get(path)
443
- if (!enumerator) continue // no getStaticPaths → skip silently
444
-
445
- try {
446
- const result = await enumerator()
447
- if (!Array.isArray(result)) {
448
- throw new Error(
449
- `getStaticPaths for "${path}" must return an array, got ${typeof result}`,
450
- )
451
- }
452
- for (const entry of result) {
453
- if (!entry || typeof entry !== 'object' || !entry.params) {
454
- throw new Error(
455
- `getStaticPaths for "${path}" returned an entry without "params"`,
456
- )
457
- }
458
- out.push(expandUrlPattern(path, entry.params))
459
- }
460
- } catch (error) {
461
- errors.push({ path, error })
462
- }
463
- }
464
-
465
- // Dedup (order-preserving). The same concrete path can be produced
466
- // more than once — a `getStaticPaths` returning a duplicate slug, or
467
- // i18n route fan-out colliding — which otherwise renders the same
468
- // `dist/<path>/index.html` twice (wasted work + last-write race) and
469
- // feeds a duplicate `<url>` into the SSG→sitemap merge.
470
- const deduped = [...new Set(out)]
471
-
472
- // Always include "/" as a fallback if no static routes were found —
473
- // a project with only dynamic routes still needs an index.html for the
474
- // host to know where to send unmatched URLs.
475
- return deduped.length > 0 ? deduped : ['/']
476
- }
477
-
478
- async function resolvePaths(
479
- config: ZeroConfig,
480
- routesDir: string,
481
- registry?: GetStaticPathsRegistry,
482
- errors: { path: string; error: unknown }[] = [],
483
- ): Promise<string[]> {
484
- const explicit = config.ssg?.paths
485
- if (typeof explicit === 'function') {
486
- const result = await explicit()
487
- return Array.isArray(result) ? result : []
488
- }
489
- if (Array.isArray(explicit)) return explicit
490
- return autoDetectStaticPaths(routesDir, registry, errors, config.i18n)
491
- }
492
-
493
- /**
494
- * Write `content` to `target` atomically: write to a sibling temp file first,
495
- * then `rename` into place. Rename is an atomic syscall on POSIX (and Windows
496
- * for same-volume renames) — readers either see the OLD content or the FULL
497
- * new content, never a half-written file.
498
- *
499
- * M2.1 — Use this for manifests that adapters consume (`_redirects`,
500
- * `_pyreon-ssg-paths.json`, `_pyreon-revalidate.json`, etc.). A SIGINT during
501
- * a sequential plain-`writeFile` chain in `closeBundle` would leave partial
502
- * state: half the manifests pointing at the new render, half the old. Atomic
503
- * writes mean each manifest is independently consistent — readers see either
504
- * the old build's manifest or the new build's, never a mix.
505
- *
506
- * Per-page HTML writes (`dist/<path>/index.html`) intentionally do NOT use
507
- * this — they're individually-readable files (no cross-file invariants), and
508
- * the rename-per-page cost on 10k-path sites would be significant.
509
- *
510
- * Temp filename embeds `pid + perf-counter` so concurrent runs (e.g. CI
511
- * pipelines that fight over the same dist) don't collide. The tmp file is
512
- * cleaned up in a finally block — even if the rename fails, no orphaned
513
- * `.tmp.*` files leak.
514
- *
515
- * @internal Exposed via `_internal.writeFileAtomic` for unit tests.
516
- */
517
- let _atomicSeq = 0
518
- async function writeFileAtomic(target: string, content: string | Uint8Array): Promise<void> {
519
- const tmp = `${target}.tmp.${process.pid}.${++_atomicSeq}`
520
- try {
521
- await writeFile(tmp, content)
522
- await rename(tmp, target)
523
- } catch (err) {
524
- // Best-effort cleanup — if rename succeeded the tmp file is gone; if it
525
- // failed (or writeFile failed), unlink it. unlink-on-missing is fine.
526
- try {
527
- await unlink(tmp)
528
- } catch {
529
- // Already gone (rename succeeded, or writeFile never produced it).
530
- }
531
- throw err
532
- }
533
- }
534
-
535
- /**
536
- * Build the per-locale breakdown summary string for the closeBundle log.
537
- *
538
- * M2.5 — Computes per-locale path counts from `writtenPaths` by checking
539
- * each path's leading segment against the configured locale list. Paths
540
- * with no locale prefix go to the default locale (under
541
- * `prefix-except-default`) or are skipped (under `prefix`, where every
542
- * locale carries an explicit prefix — unprefixed paths are unexpected).
543
- *
544
- * Returns `` ` [en: 100, de: 100, cs: 100]` `` (with leading space) for
545
- * pretty concatenation into the summary line, or empty string when i18n
546
- * is unconfigured / writtenPaths is empty.
547
- *
548
- * @internal Exposed via `_internal.buildLocaleSummary` for unit tests.
549
- */
550
- function buildLocaleSummary(
551
- writtenPaths: readonly string[],
552
- i18n: I18nRoutingConfig,
553
- ): string {
554
- if (writtenPaths.length === 0 || i18n.locales.length === 0) return ''
555
- const counts = new Map<string, number>()
556
- for (const locale of i18n.locales) counts.set(locale, 0)
557
- const defaultLocale = i18n.defaultLocale ?? i18n.locales[0] ?? ''
558
- const strategy = i18n.strategy ?? 'prefix-except-default'
559
- for (const p of writtenPaths) {
560
- // Split on '/' — `/de/about` → ['', 'de', 'about']; `/about` → ['', 'about']
561
- const firstSeg = p.split('/')[1]
562
- if (firstSeg && counts.has(firstSeg)) {
563
- counts.set(firstSeg, (counts.get(firstSeg) ?? 0) + 1)
564
- } else if (strategy === 'prefix-except-default' && defaultLocale) {
565
- // Unprefixed path under prefix-except-default belongs to the default locale.
566
- counts.set(defaultLocale, (counts.get(defaultLocale) ?? 0) + 1)
567
- }
568
- // Under `prefix` strategy: unprefixed paths are unexpected (every
569
- // locale should carry an explicit prefix). Silently skip — the
570
- // path-collision detector (M1.4) would have caught structurally
571
- // invalid duplicates anyway.
572
- }
573
- const parts: string[] = []
574
- for (const locale of i18n.locales) parts.push(`${locale}: ${counts.get(locale) ?? 0}`)
575
- return ` [${parts.join(', ')}]`
576
- }
577
-
578
- /**
579
- * Detect duplicate URLs in the resolved-paths list. Returns the duplicates
580
- * (sorted, unique). Empty array = no collisions.
581
- *
582
- * The render loop's `writtenPaths.push(p)` would silently last-wins on
583
- * duplicates — two routes producing the same URL would have one's HTML
584
- * overwrite the other's with no error. Catching the collision before
585
- * render makes the conflict visible at the source-of-truth (the routes
586
- * tree), not at the symptom (mysterious HTML drift between rebuilds).
587
- *
588
- * @internal Exposed via `_internal.detectPathCollisions` for unit tests.
589
- */
590
- function detectPathCollisions(paths: readonly string[]): string[] {
591
- const seen = new Set<string>()
592
- const duplicates = new Set<string>()
593
- for (const p of paths) {
594
- if (seen.has(p)) duplicates.add(p)
595
- seen.add(p)
596
- }
597
- return [...duplicates].sort()
598
- }
599
-
600
- /** Format a path-collision error message with actionable guidance. */
601
- /**
602
- * Wiring helper: run the collision detector + throw with the formatted error
603
- * when any collisions are found. The closeBundle handler calls this between
604
- * `resolvePaths` and the render loop. Factored out so unit tests can exercise
605
- * the full "detect → throw" path without spinning up a Vite SSR sub-build.
606
- *
607
- * @internal Exposed via `_internal.assertNoPathCollisions` for unit tests.
608
- */
609
- function assertNoPathCollisions(paths: readonly string[]): void {
610
- const collisions = detectPathCollisions(paths)
611
- if (collisions.length > 0) {
612
- throw new Error(formatPathCollisionError(collisions))
613
- }
614
- }
615
-
616
- function formatPathCollisionError(duplicates: readonly string[]): string {
617
- const list = duplicates.map((p) => ` - ${p}`).join('\n')
618
- 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.`
619
- }
620
-
621
- function resolveOutputPath(distDir: string, path: string): string {
622
- if (path === '/') return join(distDir, 'index.html')
623
- if (path.endsWith('.html')) return join(distDir, path)
624
- return join(distDir, path, 'index.html')
625
- }
626
-
627
- /**
628
- * Path-containment check that is SEPARATOR-TERMINATED. A bare
629
- * `resolve(filePath).startsWith(resolve(distDir))` is a string-prefix
630
- * test, not a path test: with distDir `/app/dist`, a traversed filePath
631
- * resolving to the SIBLING `/app/dist-evil/x` passes
632
- * `'/app/dist-evil/x'.startsWith('/app/dist')` → true and the build
633
- * writes outside the intended output root. `path` derives from caller
634
- * route params (CMS slugs via `getStaticPaths`), so this is reachable.
635
- */
636
- function isInsideDist(distDir: string, filePath: string): boolean {
637
- const root = resolve(distDir)
638
- const target = resolve(filePath)
639
- return target === root || target.startsWith(root + sep)
640
- }
641
-
642
- // ─── Redirect emission (PR B) ──────────────────────────────────────────────
643
- //
644
- // The shape returned by the SSG entry's renderPath when a loader throws
645
- // `redirect()`. The `kind` discriminator lets the closeBundle loop branch
646
- // to the right writer (HTML file vs. redirect-manifest entry).
647
- export interface RedirectEntry {
648
- from: string
649
- to: string
650
- status: number
651
- }
652
-
653
- /**
654
- * Render Netlify / Cloudflare Pages `_redirects` file content. One line
655
- * per redirect, format: `<from> <to> <status>`. Both platforms parse this
656
- * format identically; Vercel ignores it (use the JSON below). Lines with
657
- * leading `#` are comments — included so the file is self-documenting in
658
- * a deploy log.
659
- */
660
- function renderNetlifyRedirects(entries: RedirectEntry[]): string {
661
- if (entries.length === 0) return ''
662
- const lines = ['# Auto-generated by @pyreon/zero SSG. Do not edit.']
663
- for (const e of entries) {
664
- lines.push(`${e.from} ${e.to} ${e.status}`)
665
- }
666
- return `${lines.join('\n')}\n`
667
- }
668
-
669
- /**
670
- * Render Vercel `_redirects.json` content. Vercel reads this from the
671
- * `vercel.json` `redirects` array shape — but the bare `_redirects.json`
672
- * file ships alongside as documentation / fallback for adapters that
673
- * read either format. The 308/301/302/307 status maps to Vercel's
674
- * `permanent: true|false` boolean (308/301 → permanent; 302/307 →
675
- * temporary).
676
- */
677
- function renderVercelRedirectsJson(entries: RedirectEntry[]): string {
678
- return `${JSON.stringify(
679
- {
680
- redirects: entries.map((e) => ({
681
- source: e.from,
682
- destination: e.to,
683
- permanent: e.status === 301 || e.status === 308,
684
- statusCode: e.status,
685
- })),
686
- },
687
- null,
688
- 2,
689
- )}\n`
690
- }
691
-
692
- /**
693
- * Render a meta-refresh HTML stub for static hosts that don't read
694
- * `_redirects` (plain S3, GitHub Pages, simple file servers). The
695
- * `<meta http-equiv="refresh" content="0; url=…">` triggers a
696
- * client-side refresh; the canonical link is for SEO so search
697
- * engines de-dupe the source path against the target. Status code
698
- * has no HTML equivalent — a meta-refresh is always "client-side."
699
- */
700
- function renderMetaRefreshHtml(target: string): string {
701
- // Escape the target for HTML attribute context. Targets are typically
702
- // absolute paths (`/login`) or absolute URLs — HTML special chars are
703
- // rare but possible (`?q=a&b=c`). Always escape `&`, `<`, `>`, `"`,
704
- // `'` to be safe.
705
- const escaped = target
706
- .replace(/&/g, '&amp;')
707
- .replace(/</g, '&lt;')
708
- .replace(/>/g, '&gt;')
709
- .replace(/"/g, '&quot;')
710
- .replace(/'/g, '&#39;')
711
- return `<!DOCTYPE html>
712
- <html>
713
- <head>
714
- <meta charset="utf-8">
715
- <meta http-equiv="refresh" content="0; url=${escaped}">
716
- <link rel="canonical" href="${escaped}">
717
- <title>Redirecting to ${escaped}</title>
718
- </head>
719
- <body>
720
- <p>Redirecting to <a href="${escaped}">${escaped}</a>...</p>
721
- </body>
722
- </html>
723
- `
724
- }
725
-
726
- /**
727
- * Serialize the captured render-loop errors as a stable JSON artifact.
728
- * Each entry has `{ path, message, name, stack }`. Errors that aren't
729
- * `Error` instances (e.g. a loader that threw a string) are coerced via
730
- * `String()` for `message`; `name` falls back to `'Error'`; `stack` is
731
- * `undefined` (omitted from JSON output).
732
- *
733
- * Wrapped in `{ errors: [...] }` rather than emitted as a bare array so
734
- * future fields (timing, build metadata) can be added without breaking
735
- * existing CI consumers. Pretty-printed with 2-space indent — the file
736
- * is meant to be read both by tooling AND humans diagnosing a failed
737
- * build, so byte-density is not the priority.
738
- */
739
- /**
740
- * Drain `items` through `concurrency` parallel workers, calling
741
- * `processItem(item)` on each and `onSettled(item, idx)` once each
742
- * settles (regardless of resolve/reject). The work-stealing pattern
743
- * (each worker pulls from a shared `nextIdx++` cursor) keeps load
744
- * balanced even when individual `processItem` calls vary widely in
745
- * duration — a fast item doesn't make its worker idle until the slowest
746
- * peer finishes.
747
- *
748
- * Settle ordering: `onSettled` fires in the order items finish, NOT in
749
- * input order. `idx` is the index into `items` (same identity across
750
- * `processItem` and `onSettled`), useful for "completed N of M"
751
- * progress reporting.
752
- *
753
- * Concurrency clamping: ≤ 0 inputs ARE clamped to 1 — the worker pool
754
- * is meaningless without at least one worker, but a value of `0` from
755
- * a misconfiguration shouldn't silently hang. The actual worker count
756
- * is `min(concurrency, items.length)` so a 2-item list with concurrency
757
- * 10 only spawns 2 workers (no idle workers spawned).
758
- *
759
- * Errors from `processItem` are NOT caught here — callers must handle
760
- * exceptions inside `processItem` (the SSG path does so via try/catch
761
- * in `renderOne`). Errors from `onSettled` likewise propagate; in the
762
- * SSG path the caller wraps it to record into `errors[]`. We don't
763
- * silently swallow because that would hide real bugs.
764
- *
765
- * Atomic operations under Node's single-threaded JS: `nextIdx++` is
766
- * atomic — workers never observe a partial increment, so two workers
767
- * never claim the same index. The pool relies on this invariant; do
768
- * NOT port to a multi-threaded runtime without revisiting.
769
- */
770
- async function runWithConcurrency<T>(
771
- items: readonly T[],
772
- concurrency: number,
773
- processItem: (item: T, idx: number) => Promise<void>,
774
- onSettled?: (item: T, idx: number) => Promise<void> | void,
775
- ): Promise<void> {
776
- const cap = Math.max(1, concurrency)
777
- const workerCount = Math.min(cap, items.length)
778
- if (workerCount === 0) return
779
-
780
- let nextIdx = 0
781
-
782
- const worker = async (): Promise<void> => {
783
- while (true) {
784
- const idx = nextIdx++
785
- if (idx >= items.length) return
786
- const item = items[idx]!
787
- await processItem(item, idx)
788
- if (onSettled) await onSettled(item, idx)
789
- }
790
- }
791
-
792
- await Promise.all(Array.from({ length: workerCount }, () => worker()))
793
- }
794
-
795
- /**
796
- * PR I — build the revalidate manifest from scanned FileRoutes + the
797
- * list of paths that successfully rendered.
798
- *
799
- * For each FileRoute with a `revalidateLiteral` (captured at scan time
800
- * via `detectRouteExports`), parse the literal as JSON (numbers and
801
- * `false` are valid JSON tokens), build a regex from the route's
802
- * urlPath pattern, and match against `writtenPaths`. Each matching
803
- * concrete path goes into the manifest under the route's revalidate
804
- * value.
805
- *
806
- * Returns `{}` when no routes have a revalidate literal — the caller
807
- * checks `Object.keys(...).length > 0` before writing the manifest.
808
- *
809
- * Static routes match exactly (urlPath === concretePath). Dynamic
810
- * routes (`/posts/:id`) compile to `^\/posts\/[^/]+$`. Catch-alls
811
- * (`/blog/:slug*`) compile to `^\/blog\/.*$`. Layout / error / loading
812
- * / not-found routes are skipped — they don't appear in writtenPaths
813
- * anyway, but the explicit guard keeps the helper stand-alone-testable.
814
- *
815
- * Exposed via `_internal.buildRevalidateManifest` so it can be unit-
816
- * tested without a full SSG round-trip.
817
- */
818
- export function buildRevalidateManifest(
819
- fileRoutes: ReadonlyArray<{
820
- urlPath: string
821
- isLayout: boolean
822
- isError: boolean
823
- isLoading: boolean
824
- isNotFound: boolean
825
- exports?: { revalidateLiteral?: string }
826
- }>,
827
- writtenPaths: readonly string[],
828
- ): Record<string, number | false> {
829
- const manifest: Record<string, number | false> = {}
830
- for (const route of fileRoutes) {
831
- if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue
832
- const literal = route.exports?.revalidateLiteral
833
- if (literal === undefined) continue
834
- let parsed: unknown
835
- try {
836
- // The literal text is a number (`60`, `3600`) or boolean
837
- // (`false`). JSON.parse handles both. Other values (`true`,
838
- // strings, objects) aren't valid `revalidate` shapes — skip
839
- // silently rather than throw.
840
- parsed = JSON.parse(literal)
841
- } catch {
842
- continue
843
- }
844
- if (typeof parsed !== 'number' && parsed !== false) continue
845
- const value = parsed as number | false
846
- const matcher = compileUrlPatternMatcher(route.urlPath)
847
- for (const concretePath of writtenPaths) {
848
- if (matcher(concretePath)) {
849
- manifest[concretePath] = value
850
- }
851
- }
852
- }
853
- return manifest
854
- }
855
-
856
- /**
857
- * Compile a route's urlPath pattern (`/posts/:id`, `/blog/:slug*`,
858
- * `/about`) into a predicate that returns `true` for any concrete
859
- * path that matches. Static patterns return a `===` comparator.
860
- * Dynamic / catch-all patterns return a regex predicate.
861
- *
862
- * Internal helper to `buildRevalidateManifest`. Mirrors the routing
863
- * matcher's behaviour for `:param` (single segment) and `:param*`
864
- * (catch-all, zero-or-more segments). Doesn't need to handle every
865
- * router edge case — the writtenPaths it matches against are already
866
- * concrete (no params, no wildcards).
867
- */
868
- function compileUrlPatternMatcher(urlPath: string): (concrete: string) => boolean {
869
- if (!urlPath.includes(':') && !urlPath.includes('*')) {
870
- return (concrete) => concrete === urlPath
871
- }
872
- // Escape regex metachars (except `:` and `*` which we handle
873
- // explicitly), then substitute `:name*` → `.*` and `:name` → `[^/]+`.
874
- // Order matters — `:name*` MUST be replaced before `:name`.
875
- const regex = urlPath
876
- .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
877
- .replace(/:([A-Za-z_$][\w$]*)\*/g, '.*')
878
- .replace(/:([A-Za-z_$][\w$]*)/g, '[^/]+')
879
- const re = new RegExp(`^${regex}$`)
880
- return (concrete) => re.test(concrete)
881
- }
882
-
883
- function renderErrorArtifact(entries: { path: string; error: unknown }[]): string {
884
- const errors = entries.map(({ path, error }) => ({
885
- path,
886
- message: error instanceof Error ? error.message : String(error),
887
- name: error instanceof Error ? error.name : 'Error',
888
- stack: error instanceof Error ? error.stack : undefined,
889
- }))
890
- return `${JSON.stringify({ errors }, null, 2)}\n`
891
- }
892
-
893
- /**
894
- * Inject a rendered SSR result into the index.html template. Prefers
895
- * Pyreon's `<!--pyreon-head-->` / `<!--pyreon-app-->` /
896
- * `<!--pyreon-scripts-->` placeholders; falls back to inserting before
897
- * `</head>` / inside `<div id="app">` / before `</body>` so a bare
898
- * Vite-style `index.html` (no Pyreon comments) still receives content.
899
- *
900
- * Factored out of the per-path render loop so the 404 emission path can
901
- * reuse the exact same injection rules — keeps the rendered _404.tsx
902
- * subject to the same head/body/scripts pipeline as regular pages
903
- * (styler tag, @pyreon/head meta, hashed asset preload links).
904
- */
905
- function injectIntoTemplate(
906
- template: string,
907
- result: { appHtml: string; head: string; loaderScript: string },
908
- ): string {
909
- let html = template
910
- if (html.includes('<!--pyreon-head-->')) {
911
- html = html.replace('<!--pyreon-head-->', result.head)
912
- } else if (result.head) {
913
- html = html.replace('</head>', `${result.head}</head>`)
914
- }
915
- if (html.includes('<!--pyreon-app-->')) {
916
- html = html.replace('<!--pyreon-app-->', result.appHtml)
917
- } else if (result.appHtml) {
918
- const appDivMatch = html.match(/<div\s+id=["']app["']\s*>([\s\S]*?)<\/div>/)
919
- if (appDivMatch) {
920
- html = html.replace(appDivMatch[0], `<div id="app">${result.appHtml}</div>`)
921
- } else {
922
- html = html.replace('</body>', `<div id="app">${result.appHtml}</div></body>`)
923
- }
924
- }
925
- if (html.includes('<!--pyreon-scripts-->')) {
926
- html = html.replace('<!--pyreon-scripts-->', result.loaderScript)
927
- } else if (result.loaderScript) {
928
- html = html.replace('</body>', `${result.loaderScript}</body>`)
929
- }
930
- return html
931
- }
932
-
933
- /**
934
- * Plugin that performs SSG when `mode: "ssg"` is configured. Wires into
935
- * Vite's `closeBundle` hook so it runs once after the main client build
936
- * completes. The recursive SSR sub-build is gated by an env flag.
937
- */
938
- export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
939
- const config = resolveConfig(userConfig)
940
- let root = ''
941
- let distDir = ''
942
- // Track whether this plugin instance is running inside the inner SSR
943
- // sub-build (where it must be a no-op) vs. the outer client build.
944
- const isInnerBuild = process.env[SSG_BUILD_FLAG] === '1'
945
-
946
- return {
947
- name: 'pyreon-zero-ssg',
948
- apply: 'build',
949
- enforce: 'post',
950
-
951
- configResolved(resolved) {
952
- root = resolved.root
953
- distDir = resolve(root, resolved.build.outDir)
954
- },
955
-
956
- async closeBundle() {
957
- if (config.mode !== 'ssg') return
958
- if (isInnerBuild) return
959
-
960
- const ssrOutDir = join(distDir, '.zero-ssg-server')
961
- const indexHtmlPath = join(distDir, 'index.html')
962
-
963
- if (!existsSync(indexHtmlPath)) {
964
- // Client build hasn't produced index.html — nothing we can wrap.
965
- // Most likely: user is running `vite build --ssr` directly, in
966
- // which case this plugin shouldn't be active anyway.
967
- // oxlint-disable-next-line no-console
968
- console.warn(
969
- `[zero:ssg] Skipping SSG — ${indexHtmlPath} not found. Did the client build complete?`,
970
- )
971
- return
972
- }
973
-
974
- // Materialize the SSR entry to disk inside the routes directory so
975
- // its imports resolve relative to the user's source tree. Doing this
976
- // INSIDE node_modules-equivalent paths breaks Vite's plugin-resolution
977
- // semantics; placing it next to the user's routes lets zero's main
978
- // plugin pick it up identically to user code. Cleaned up after the
979
- // build.
980
- const entryPath = join(root, SSR_ENTRY_FILENAME)
981
- // PR K: bake the configured locales into the SSG entry source so the
982
- // per-locale 404 walker can detect which RouteRecord serves which
983
- // locale at module-eval time inside the SSR sub-build.
984
- const i18nLocales = config.i18n?.locales ?? []
985
- await writeFile(entryPath, renderSsrEntrySource(i18nLocales), 'utf-8')
986
-
987
- // Vite's programmatic build API. Loaded lazily so the plugin doesn't
988
- // pull `vite` into the runtime dep graph at module-evaluation time.
989
- const { build } = await import('vite')
990
-
991
- // Inner SSR sub-build. Re-assembles zero's plugin chain plus
992
- // `@pyreon/vite-plugin` (JSX compiler) — every Pyreon app already
993
- // has both because zero is built on top of pyreon. Loading both
994
- // lazily keeps the SSG plugin off the module-eval critical path.
995
- // Env-flag gate prevents the inner ssgPlugin instance from
996
- // re-triggering itself.
997
- process.env[SSG_BUILD_FLAG] = '1'
998
- try {
999
- const [{ zeroPlugin }, pyreonModule] = await Promise.all([
1000
- import('./vite-plugin'),
1001
- import('@pyreon/vite-plugin'),
1002
- ])
1003
- const pyreon = (pyreonModule as { default: () => unknown }).default
1004
-
1005
- await build({
1006
- root,
1007
- mode: 'production',
1008
- logLevel: 'error',
1009
- configFile: false,
1010
- publicDir: false,
1011
- plugins: [pyreon(), zeroPlugin(userConfig)] as Plugin[],
1012
- resolve: { conditions: ['bun'] },
1013
- build: {
1014
- ssr: entryPath,
1015
- outDir: ssrOutDir,
1016
- emptyOutDir: true,
1017
- target: 'esnext',
1018
- rollupOptions: {
1019
- input: entryPath,
1020
- output: {
1021
- format: 'es',
1022
- entryFileNames: 'entry-server.mjs',
1023
- },
1024
- external: [/^node:/],
1025
- },
1026
- },
1027
- })
1028
- } finally {
1029
- delete process.env[SSG_BUILD_FLAG]
1030
- // Remove the synthetic entry file so it never lands in user's
1031
- // working tree.
1032
- try {
1033
- await rm(entryPath, { force: true })
1034
- } catch {
1035
- // best-effort cleanup
1036
- }
1037
- }
1038
-
1039
- // Load the built renderer. Use a file:// URL to avoid Node import
1040
- // cache collisions across multiple builds within the same process.
1041
- const handlerPath = join(ssrOutDir, 'entry-server.mjs')
1042
- if (!existsSync(handlerPath)) {
1043
- // oxlint-disable-next-line no-console
1044
- console.warn(`[zero:ssg] SSR build did not produce ${handlerPath} — skipping prerender`)
1045
- return
1046
- }
1047
- // The path is computed at runtime from a freshly-built SSR artifact
1048
- // — Vite's `dynamic-import-vars` plugin can't statically analyze the
1049
- // import. Without the `@vite-ignore` hint, Vite emits a console
1050
- // warning on every consumer's dev server boot ("The above dynamic
1051
- // import cannot be analyzed by Vite"), which looks alarming but is
1052
- // expected here. Suppress per Vite's own recommendation.
1053
- const handlerMod = (await import(/* @vite-ignore */ pathToFileURL(handlerPath).href)) as {
1054
- // PR B — return shape is a discriminated union: regular paths
1055
- // produce HTML, redirect-throwing loaders produce a redirect
1056
- // descriptor for the manifest writer.
1057
- default: (
1058
- path: string,
1059
- ) => Promise<
1060
- | { kind: 'html'; appHtml: string; head: string; loaderScript: string }
1061
- | { kind: 'redirect'; from: string; to: string; status: number }
1062
- >
1063
- // PR A — getStaticPaths registry collected from the routes tree.
1064
- __getStaticPathsRegistry?: GetStaticPathsRegistry
1065
- // PR C — 404 emission. The synthetic SSG entry walks the routes
1066
- // tree at module-eval time and exports the first
1067
- // `notFoundComponent` it finds (root-level `_404.tsx` in the
1068
- // common case), plus an async `__renderNotFound()` that pushes
1069
- // it through the same renderWithHead pipeline as regular paths.
1070
- // The outer plugin reads both: presence gates the emission,
1071
- // the renderer produces the same `{ appHtml, head, loaderScript }`
1072
- // shape so `injectIntoTemplate` can reuse the same injection
1073
- // rules. The `?` keeps zero forward-compatible — an entry built
1074
- // before PR C just doesn't expose these and emit404 silently
1075
- // no-ops.
1076
- __notFoundComponent?: unknown
1077
- __notFoundComponentsByLocale?: Map<string | null, unknown>
1078
- __renderNotFound?: (
1079
- locale?: string | null,
1080
- ) => Promise<{ appHtml: string; head: string; loaderScript: string } | null>
1081
- }
1082
- const renderPath = handlerMod.default
1083
- const registry = handlerMod.__getStaticPathsRegistry
1084
-
1085
- // Read the user's built index.html template. Vite has just produced it
1086
- // with hashed asset URLs (`/assets/index-XYZ.js`), preload links, etc.
1087
- // We inject the rendered head/body/loader-data into placeholder
1088
- // comments — same convention as zero's dev SSR. If the template lacks
1089
- // the placeholders, we fall back to inserting before `</head>` and
1090
- // `</body>` respectively so a bare `index.html` still works.
1091
- const template = await readFile(indexHtmlPath, 'utf-8')
1092
-
1093
- // Errors from getStaticPaths run AND per-page render are collected
1094
- // into the same array so the post-render summary catches both. The
1095
- // SSG plugin completes either way — a single bad route shouldn't
1096
- // abort the whole site build.
1097
- const errors: { path: string; error: unknown }[] = []
1098
-
1099
- // Resolve paths and render.
1100
- const routesDir = join(root, 'src', 'routes')
1101
- const paths = await resolvePaths(config, routesDir, registry, errors)
1102
-
1103
- if (paths.length === 0) {
1104
- // oxlint-disable-next-line no-console
1105
- console.warn('[zero:ssg] No static paths to prerender — set ssg.paths in zero config')
1106
- await rm(ssrOutDir, { recursive: true, force: true })
1107
- return
1108
- }
1109
-
1110
- // M1.4 — Detect duplicate paths BEFORE the render loop. Two routes
1111
- // producing the same URL (a static `/posts/foo.tsx` + a dynamic
1112
- // `[id].tsx` with `getStaticPaths: [{id:'foo'}]`) would silently
1113
- // last-wins under `writtenPaths.push(p)`. Surface as an actionable
1114
- // error with the duplicate URL listed so users can fix the source
1115
- // route conflict instead of wondering why HTML mysteriously changes
1116
- // between rebuilds. Bisect-verified via `assertNoPathCollisions` unit
1117
- // tests: removing the call → fixture with dupes proceeds to render
1118
- // and silently overwrites; restoring → throws with the formatted
1119
- // error listing every duplicate URL.
1120
- assertNoPathCollisions(paths)
1121
-
1122
- let pages = 0
1123
- // PR B — collect redirects from loader-throws so the post-render
1124
- // step can write `_redirects` / `_redirects.json` / meta-refresh
1125
- // HTML files. Loader redirects DON'T produce a per-path index.html
1126
- // — the redirect IS the response.
1127
- const redirects: RedirectEntry[] = []
1128
- // PR F — track every path that produced a `dist/<path>/index.html` so
1129
- // the post-loop step can emit `dist/_pyreon-ssg-paths.json` for
1130
- // `seoPlugin({ sitemap: { useSsgPaths: true } })` to read at its
1131
- // own `closeBundle`. We track the resolved URL paths (post-
1132
- // getStaticPaths expansion + per-locale duplication when PR H
1133
- // ships) — exactly what the sitemap.xml needs. Paths that errored
1134
- // OR redirected are intentionally absent: errored pages have no
1135
- // HTML to link to, and redirect sources go to `_redirects`, not
1136
- // sitemap.xml (linking to a redirect source confuses crawlers).
1137
- const writtenPaths: string[] = []
1138
- const start = Date.now()
1139
-
1140
- // PR D — render a single path. Extracted from the inline loop body
1141
- // so the worker-pool below can call it concurrently. All side
1142
- // effects (redirects.push, errors.push, writtenPaths.push, pages++)
1143
- // are append-only mutations on shared arrays/counter — safe under
1144
- // the concurrent worker pattern because Node is single-threaded
1145
- // (each worker yields at every `await`, never in mid-statement).
1146
- //
1147
- // Returns the path on settle so the worker can fire onProgress with
1148
- // it. Throws are NOT propagated — they're caught here and recorded
1149
- // in `errors[]`, so a single failed path can't take down the worker.
1150
- const renderOne = async (p: string): Promise<void> => {
1151
- // M2.3 — emit `ssg.pathRender` per attempted render. Pair with
1152
- // `ssg.pathWrite` / `ssg.pathRedirect` / `ssg.pathError` to see
1153
- // per-path settle distribution.
1154
- if (__DEV__) _countSink.__pyreon_count__?.('ssg.pathRender')
1155
- // Hold the timer id outside try/Promise.race so the finally
1156
- // block can `clearTimeout` it on the success path. Pre-fix the
1157
- // rejection setTimeout was left pending until 30s every time
1158
- // `renderPath(p)` won the race (i.e. every successful render).
1159
- // Concurrent worker pool × N paths under default `concurrency: 4`
1160
- // → up to N pending timer closures across the whole build,
1161
- // each pinning a rejection callback.
1162
- let timeoutId: ReturnType<typeof setTimeout> | undefined
1163
- try {
1164
- const result = await Promise.race([
1165
- renderPath(p),
1166
- new Promise<never>((_, reject) => {
1167
- timeoutId = setTimeout(
1168
- () => reject(new Error(`Prerender timeout for "${p}" (30s)`)),
1169
- 30_000,
1170
- )
1171
- }),
1172
- ])
1173
-
1174
- if (result.kind === 'redirect') {
1175
- // M2.3 — track redirect outcomes separately from successful renders.
1176
- if (__DEV__) _countSink.__pyreon_count__?.('ssg.pathRedirect')
1177
- // PR B — loader threw `redirect()`. Record for the manifest;
1178
- // optionally emit a meta-refresh HTML stub at the source path.
1179
- redirects.push({ from: result.from, to: result.to, status: result.status })
1180
-
1181
- if (config.ssg?.redirectsAsHtml === 'meta-refresh') {
1182
- const filePath = resolveOutputPath(distDir, p)
1183
- if (!isInsideDist(distDir, filePath)) {
1184
- errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1185
- return
1186
- }
1187
- await mkdir(dirname(filePath), { recursive: true })
1188
- await writeFile(filePath, renderMetaRefreshHtml(result.to), 'utf-8')
1189
- }
1190
- return
1191
- }
1192
-
1193
- const html = injectIntoTemplate(template, result)
1194
-
1195
- const filePath = resolveOutputPath(distDir, p)
1196
-
1197
- // Path-traversal guard — same as @pyreon/server's prerender.
1198
- if (!isInsideDist(distDir, filePath)) {
1199
- errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1200
- return
1201
- }
1202
-
1203
- await mkdir(dirname(filePath), { recursive: true })
1204
- await writeFile(filePath, html, 'utf-8')
1205
- pages++
1206
- writtenPaths.push(p)
1207
- // M2.3 — track successful HTML emits. `ssg.pathRender - ssg.pathWrite
1208
- // - ssg.pathRedirect - ssg.pathError` should sum to roughly zero;
1209
- // non-zero residual = paths swallowed silently somewhere.
1210
- if (__DEV__) _countSink.__pyreon_count__?.('ssg.pathWrite')
1211
- } catch (error) {
1212
- // M2.3 — track render-error outcomes.
1213
- if (__DEV__) _countSink.__pyreon_count__?.('ssg.pathError')
1214
- errors.push({ path: p, error })
1215
- // PR G — onPathError fallback hook. The user-supplied callback
1216
- // can return HTML to write at the path's URL, OR null to skip.
1217
- // The error is ALREADY recorded in `errors[]` before this call
1218
- // so the post-build summary catches it regardless of what the
1219
- // callback does. The callback's own throws are caught + recorded
1220
- // as a separate entry — a buggy callback shouldn't take down
1221
- // the whole build, AND we surface the bug instead of swallowing.
1222
- if (config.ssg?.onPathError) {
1223
- try {
1224
- const fallbackHtml = await config.ssg.onPathError(p, error)
1225
- if (typeof fallbackHtml === 'string') {
1226
- const filePath = resolveOutputPath(distDir, p)
1227
- if (!isInsideDist(distDir, filePath)) {
1228
- errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1229
- return
1230
- }
1231
- await mkdir(dirname(filePath), { recursive: true })
1232
- await writeFile(filePath, fallbackHtml, 'utf-8')
1233
- pages++
1234
- }
1235
- } catch (callbackError) {
1236
- errors.push({ path: `${p} (onPathError)`, error: callbackError })
1237
- }
1238
- }
1239
- } finally {
1240
- if (timeoutId !== undefined) clearTimeout(timeoutId)
1241
- }
1242
- }
1243
-
1244
- // PR D — worker-pool concurrency. Default 4 workers in flight; the
1245
- // user can opt into more via `ssg.concurrency` (CI multi-core) or
1246
- // back to 1 for fully sequential (the pre-PR-D shape). Per-path
1247
- // logic lives in `renderOne()`; the pool primitive lives in
1248
- // `runWithConcurrency()` (testable in isolation).
1249
- //
1250
- // Progress ordering: `onProgress` fires in path-SETTLE order, NOT
1251
- // input order. Across workers, callbacks may run in parallel —
1252
- // the runtime doesn't serialize them. If you need strict serial
1253
- // output (e.g. a progress bar painting one line at a time), wrap
1254
- // the callback to push to a single-consumer queue.
1255
- const concurrency = Math.max(1, config.ssg?.concurrency ?? 4)
1256
- let completed = 0
1257
-
1258
- await runWithConcurrency(paths, concurrency, renderOne, async (p) => {
1259
- completed++
1260
- if (config.ssg?.onProgress) {
1261
- try {
1262
- await config.ssg.onProgress({
1263
- completed,
1264
- total: paths.length,
1265
- currentPath: p,
1266
- elapsed: Date.now() - start,
1267
- })
1268
- } catch (callbackError) {
1269
- errors.push({ path: `${p} (onProgress)`, error: callbackError })
1270
- }
1271
- }
1272
- })
1273
-
1274
- // PR F — Sitemap path manifest.
1275
- //
1276
- // Write the resolved URL paths to `dist/_pyreon-ssg-paths.json` so
1277
- // `seoPlugin({ sitemap: { useSsgPaths: true } })` can read them at
1278
- // its own `closeBundle` and emit a sitemap that includes dynamic-
1279
- // route enumerations (PR A's `getStaticPaths`) AND per-locale
1280
- // variants (PR H, when shipped). Without this manifest, `seoPlugin`
1281
- // walks the file-system route tree directly and silently skips
1282
- // dynamic routes (`[id]`) because their concrete values aren't
1283
- // knowable at file-scan time.
1284
- //
1285
- // The 404 path is omitted intentionally — error pages don't belong
1286
- // in sitemap.xml. Redirected sources are ALSO omitted (they're
1287
- // already absent from `writtenPaths` because the loop hits the
1288
- // redirect branch + `continue` before the push).
1289
- //
1290
- // Always emit when SSG ran. Filename starts with `_` so static
1291
- // hosts that publish the dist root don't ALSO publish this internal
1292
- // manifest as a public asset — convention matches `_redirects` /
1293
- // `_redirects.json`. The seoPlugin reads + cleans it up after use.
1294
- if (writtenPaths.length > 0) {
1295
- // PR K (i18n follow-up): also embed the i18n config in the manifest
1296
- // when present so `seoPlugin({ sitemap: { useSsgPaths: true } })`
1297
- // can emit hreflang cross-references without duplicating the i18n
1298
- // declaration. Zero-config win — users opt into i18n routing via
1299
- // `zero({ i18n: { ... } })` once and both the route duplication
1300
- // (PR H) AND the sitemap hreflang automatically pick it up.
1301
- // M2.1 — atomic write so the seoPlugin (or any consumer) never reads
1302
- // a half-written manifest if the build is interrupted mid-flush.
1303
- await writeFileAtomic(
1304
- join(distDir, '_pyreon-ssg-paths.json'),
1305
- `${JSON.stringify(
1306
- {
1307
- paths: writtenPaths,
1308
- ...(config.i18n ? { i18n: config.i18n } : {}),
1309
- },
1310
- null,
1311
- 2,
1312
- )}\n`,
1313
- )
1314
- }
1315
-
1316
- // PR I — Build-time ISR revalidate manifest.
1317
- //
1318
- // Walk the route files for `export const revalidate = <n|false>`
1319
- // declarations (captured as a literal at scan time), match each
1320
- // declaration's url-pattern against the rendered written paths,
1321
- // and emit `dist/_pyreon-revalidate.json` mapping concrete path
1322
- // → revalidate value. Adapters (vercel/cloudflare/netlify) read
1323
- // this manifest at deploy time to wire platform-specific ISR
1324
- // (Vercel `output/config.json`, Cloudflare cache rules, Netlify
1325
- // redirect headers).
1326
- //
1327
- // Path matching: static routes (`/about`) match writtenPaths[i]
1328
- // exactly. Dynamic routes (`/posts/:id`) and catch-all
1329
- // (`/blog/:slug*`) compile to a regex and match every concrete
1330
- // child. Same revalidate value applies to all enumerated children
1331
- // — `posts/[id].tsx` with `export const revalidate = 60` gives
1332
- // `/posts/1`, `/posts/2`, `/posts/3` ALL `60` in the manifest.
1333
- //
1334
- // Filename starts with `_` so the static-host publish step
1335
- // doesn't expose it as a public asset (matches `_redirects` /
1336
- // `_redirects.json` / `_pyreon-ssg-paths.json` convention).
1337
- // ONLY emit when at least one route has a revalidate literal —
1338
- // empty manifests aren't useful, and absence is a meaningful
1339
- // signal to adapters ("no per-route ISR config, fall through to
1340
- // platform defaults").
1341
- // M2.5 — track the count for the build summary breakdown.
1342
- let revalidateCount = 0
1343
- if (existsSync(routesDir)) {
1344
- try {
1345
- const fileRoutesWithExports = await scanRouteFilesWithExports(routesDir, config.mode)
1346
- const revalidateManifest = buildRevalidateManifest(
1347
- fileRoutesWithExports,
1348
- writtenPaths,
1349
- )
1350
- revalidateCount = Object.keys(revalidateManifest).length
1351
- if (revalidateCount > 0) {
1352
- // M2.1 — atomic write. Adapters polling for the manifest at
1353
- // deploy time should see the full document or nothing.
1354
- await writeFileAtomic(
1355
- join(distDir, '_pyreon-revalidate.json'),
1356
- `${JSON.stringify({ revalidate: revalidateManifest }, null, 2)}\n`,
1357
- )
1358
- }
1359
- } catch (err) {
1360
- // Manifest emission is opt-in via the `revalidate` export.
1361
- // A scan failure shouldn't abort the build — just record it
1362
- // alongside the other SSG errors so CI surfaces it.
1363
- errors.push({ path: '(revalidate-manifest)', error: err })
1364
- }
1365
- }
1366
-
1367
- // PR C — Auto-emit dist/404.html from _404.tsx convention.
1368
- // PR K (i18n follow-up) — also emit dist/{locale}/404.html for every
1369
- // locale subtree that has its own _404 (i.e. every non-default locale
1370
- // under `prefix-except-default`, every locale under `prefix`).
1371
- //
1372
- // fs-router scans `_404.tsx` / `_not-found.tsx` and attaches it as
1373
- // `notFoundComponent` on its parent layout RouteRecord (or directly
1374
- // on the page record when no wrapping layout exists — which is the
1375
- // per-locale subtree shape after PR H's root-layout-skip). The
1376
- // synthetic SSG entry walks the routes tree at module-eval time,
1377
- // collects per-locale 404 components into a Map keyed by locale
1378
- // (or `null` for the default / no-i18n case), and exposes an
1379
- // async `__renderNotFound(locale)` that renders the matching one.
1380
- //
1381
- // We iterate that map here and write each to the right path:
1382
- // - `null` locale → `dist/404.html` (static-host convention —
1383
- // Netlify / Cloudflare Pages / GitHub Pages / S3+CloudFront
1384
- // serve this for unmatched URLs by default)
1385
- // - non-null locale `de` → `dist/de/404.html` (mirrors how those
1386
- // same hosts serve per-prefix 404s when configured with the
1387
- // `errors_404` field per directory in `netlify.toml` / etc.)
1388
- //
1389
- // Per-locale 404 lets search engines + users see a 404 page in the
1390
- // right language with the right navigation chrome, not the
1391
- // default-locale page bolted onto a German URL.
1392
- //
1393
- // Gated by `config.ssg.emit404 !== false` (default true). Skipped
1394
- // silently when no `_404.tsx` exists anywhere — the Map is empty
1395
- // and the loop body never runs.
1396
- let emitted404Count = 0
1397
- if (config.ssg?.emit404 !== false && handlerMod.__renderNotFound) {
1398
- // Back-compat: old SSG entries (pre-PR-K) expose only the singular
1399
- // `__notFoundComponent`; new entries expose the Map. Build a synthetic
1400
- // single-entry iterator from the singular when the Map isn't there
1401
- // so users with a stale SSR_ENTRY cache or downstream consumers that
1402
- // never rebuilt their lib/ keep emitting one 404.html.
1403
- const localeEntries: (string | null)[]
1404
- = handlerMod.__notFoundComponentsByLocale instanceof Map
1405
- ? [...handlerMod.__notFoundComponentsByLocale.keys()]
1406
- : handlerMod.__notFoundComponent
1407
- ? [null]
1408
- : []
1409
-
1410
- for (const locale of localeEntries) {
1411
- // Hold the timer id outside try/Promise.race so the finally
1412
- // block can `clearTimeout` it on the success path. Same shape
1413
- // as the per-path render timeout above — every successful
1414
- // 404 render leaked a 30s pending timer pre-fix.
1415
- let timeoutId: ReturnType<typeof setTimeout> | undefined
1416
- try {
1417
- const result = await Promise.race([
1418
- handlerMod.__renderNotFound(locale),
1419
- new Promise<never>((_, reject) => {
1420
- timeoutId = setTimeout(
1421
- () =>
1422
- reject(
1423
- new Error(
1424
- `Prerender timeout for ${locale == null ? '404' : `${locale}/404`} (30s)`,
1425
- ),
1426
- ),
1427
- 30_000,
1428
- )
1429
- }),
1430
- ])
1431
- if (result) {
1432
- const html = injectIntoTemplate(template, result)
1433
- const filePath
1434
- = locale == null
1435
- ? join(distDir, '404.html')
1436
- : join(distDir, locale, '404.html')
1437
- // Ensure the locale subdirectory exists before writeFile —
1438
- // dist/de/ may not have been created yet if no static pages
1439
- // landed under that locale, but most apps will have at
1440
- // least the locale-root index.html so this is usually a no-op.
1441
- if (locale != null) {
1442
- await mkdir(join(distDir, locale), { recursive: true })
1443
- }
1444
- await writeFile(filePath, html, 'utf-8')
1445
- emitted404Count++
1446
- // M2.3 — track per-locale 404 emits.
1447
- if (__DEV__) _countSink.__pyreon_count__?.('ssg.404Emit')
1448
- }
1449
- } catch (error) {
1450
- errors.push({
1451
- path: locale == null ? '404.html' : `${locale}/404.html`,
1452
- error,
1453
- })
1454
- } finally {
1455
- if (timeoutId !== undefined) clearTimeout(timeoutId)
1456
- }
1457
- }
1458
- }
1459
-
1460
- // PR B — emit redirect manifests when loaders threw `redirect()`.
1461
- // Both Netlify (`_redirects`) and Vercel (`_redirects.json`)
1462
- // formats ship together so the user doesn't have to pick at SSG
1463
- // time. The file is empty / absent when no redirects fired.
1464
- if (redirects.length > 0 && config.ssg?.emitRedirects !== false) {
1465
- // M2.1 — atomic so an interrupted build doesn't leave a half-
1466
- // written `_redirects` file that adapters / static hosts misparse.
1467
- await writeFileAtomic(
1468
- join(distDir, '_redirects'),
1469
- renderNetlifyRedirects(redirects),
1470
- )
1471
- await writeFileAtomic(
1472
- join(distDir, '_redirects.json'),
1473
- renderVercelRedirectsJson(redirects),
1474
- )
1475
- }
1476
-
1477
- // PR G — emit `dist/_pyreon-ssg-errors.json` summarising every error
1478
- // captured during the render loop (path traversal, render exception,
1479
- // getStaticPaths throw, onPathError throw, 404 render fail). The file
1480
- // is ONLY written when `errors.length > 0` — successful builds don't
1481
- // leak an empty manifest. Reading it lets CI gate on render failures
1482
- // without parsing console output.
1483
- //
1484
- // Default: 'json' (write the artifact). Set `errorArtifact: 'none'`
1485
- // to opt out — errors stay console-only, matching pre-PR-G behaviour.
1486
- if (errors.length > 0 && config.ssg?.errorArtifact !== 'none') {
1487
- // M2.1 — atomic so CI that reads this manifest (`jq '.errors | length'`)
1488
- // never sees a partial-JSON parse error from an interrupted build.
1489
- await writeFileAtomic(
1490
- join(distDir, '_pyreon-ssg-errors.json'),
1491
- renderErrorArtifact(errors),
1492
- )
1493
- }
1494
-
1495
- // PR J — adapter.build() invocation for SSG mode. Each platform
1496
- // adapter (vercel/cloudflare/netlify) writes its routing config
1497
- // for the prerendered dist; static / node / bun adapters no-op
1498
- // for SSG (they're SSR runners or trivial).
1499
- //
1500
- // Pre-PR-J: Adapter.build() was implemented per-platform but
1501
- // never invoked from any build pipeline — the methods existed
1502
- // but no production code path called them. SSG is the natural
1503
- // first hook because the dist/ shape is final at this point
1504
- // (all paths rendered, redirects + sitemap manifests written).
1505
- // Adapter throws are caught here so a buggy adapter can't take
1506
- // down the rest of the build (sitemap, error artifact, summary
1507
- // log all already ran). The error lands in the error log + the
1508
- // _pyreon-ssg-errors.json if errorArtifact was set, but we
1509
- // emit it AFTER the artifact write so the path-render errors
1510
- // already in the file aren't displaced.
1511
- const adapter = resolveAdapter(config)
1512
- try {
1513
- await adapter.build({ kind: 'ssg', outDir: distDir, config })
1514
- } catch (adapterError) {
1515
- errors.push({ path: `(adapter:${adapter.name})`, error: adapterError })
1516
- }
1517
-
1518
- // Cleanup the SSR build artifacts — they're an implementation detail
1519
- // and shouldn't ship to the static host.
1520
- await rm(ssrOutDir, { recursive: true, force: true })
1521
-
1522
- const elapsed = Date.now() - start
1523
- const redirectsSummary = redirects.length > 0 ? ` + ${redirects.length} redirect(s)` : ''
1524
- const concurrencySummary = concurrency > 1 ? ` (concurrency: ${concurrency})` : ''
1525
- const adapterSummary = adapter.name !== 'node' ? ` [adapter: ${adapter.name}]` : ''
1526
- // M2.5 — revalidate-manifest entry count surfaces in the summary so
1527
- // users see at a glance whether per-route ISR config landed in
1528
- // `_pyreon-revalidate.json` (vs silently empty because no route
1529
- // exported a `revalidate` literal).
1530
- const revalidateSummary =
1531
- revalidateCount > 0 ? ` + ${revalidateCount} revalidate path(s)` : ''
1532
- // M2.5 — per-locale breakdown when i18n config is active. The
1533
- // breakdown is computed from `writtenPaths` — each path either has a
1534
- // locale prefix (`/de/...`) or belongs to the default locale (under
1535
- // `prefix-except-default`) / is locale-less (no i18n). Surfaces
1536
- // shape mismatches: `[en: 100, de: 90, cs: 100]` flags that de had
1537
- // 10 paths skipped relative to the others.
1538
- const localeSummary = config.i18n ? buildLocaleSummary(writtenPaths, config.i18n) : ''
1539
- // oxlint-disable-next-line no-console
1540
- console.log(
1541
- `[zero:ssg] Prerendered ${pages} page(s)${
1542
- emitted404Count > 0
1543
- ? emitted404Count === 1
1544
- ? ' + 404.html'
1545
- : ` + ${emitted404Count} 404 pages`
1546
- : ''
1547
- }${redirectsSummary}${revalidateSummary} in ${elapsed}ms${concurrencySummary}${adapterSummary}${localeSummary}` +
1548
- (errors.length > 0 ? ` (${errors.length} error(s))` : ''),
1549
- )
1550
- for (const { path: errPath, error } of errors) {
1551
- // oxlint-disable-next-line no-console
1552
- console.error(`[zero:ssg] Failed to prerender "${errPath}":`, error)
1553
- }
1554
- },
1555
- } satisfies Plugin
1556
- }
1557
-
1558
- // ─── Test exports ─────────────────────────────────────────────────────────────
1559
- //
1560
- // Internal helpers exposed for unit tests. Not part of the public API.
1561
-
1562
- export const _internal = {
1563
- resolvePaths,
1564
- autoDetectStaticPaths,
1565
- resolveOutputPath,
1566
- isInsideDist,
1567
- expandUrlPattern,
1568
- injectIntoTemplate,
1569
- renderNetlifyRedirects,
1570
- renderVercelRedirectsJson,
1571
- renderMetaRefreshHtml,
1572
- renderErrorArtifact,
1573
- runWithConcurrency,
1574
- buildRevalidateManifest,
1575
- renderSsrEntrySource,
1576
- SSR_ENTRY_FILENAME,
1577
- detectPathCollisions,
1578
- formatPathCollisionError,
1579
- assertNoPathCollisions,
1580
- writeFileAtomic,
1581
- buildLocaleSummary,
1582
- }