@pyreon/zero 0.24.5 → 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.
- package/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- 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, '&')
|
|
707
|
-
.replace(/</g, '<')
|
|
708
|
-
.replace(/>/g, '>')
|
|
709
|
-
.replace(/"/g, '"')
|
|
710
|
-
.replace(/'/g, ''')
|
|
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
|
-
}
|