prestruct 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,726 @@
1
+ /**
2
+ * scripts/prerender.js
3
+ * ====================
4
+ * Canonical engine source (engine/scripts/prerender.js). Deployed copies at
5
+ * scripts/prerender.js in init/ and example/ are generated byte-identically by
6
+ * scripts/sync-engine.mjs -- edit the engine, never the copies.
7
+ *
8
+ * Runs after `vite build && node scripts/inject-brand.js` via `npm run build`.
9
+ * Uses Vite's ssrLoadModule to render each route to static HTML.
10
+ *
11
+ * Why ssrLoadModule instead of `vite build --ssr`:
12
+ * ssrLoadModule resolves all imports through Vite's unified module registry.
13
+ * This guarantees a single instance of react-router-dom -- StaticRouter and
14
+ * Routes share the same context, so location propagates correctly.
15
+ * A compiled SSR bundle would silently render every route as '/' with no error.
16
+ * See AGENTS.md for the full root cause analysis.
17
+ *
18
+ * Module surface (importable for tests; CLI logic only runs when executed):
19
+ * replaceRootDiv(shell, appHtml) -- root-div injector, attribute-tolerant
20
+ * injectMeta(html, meta, routePath, siteUrl, ogImage)
21
+ * generateSitemap(routes, siteUrl)
22
+ * generate404(shell, config, siteUrl)
23
+ * readCache() / writeCache(cache) / cleanCache()
24
+ * prerender() -- full pipeline
25
+ *
26
+ * Output (for a 3-route app with /, /about, /contact):
27
+ * dist/index.html -> /
28
+ * dist/about/index.html -> /about
29
+ * dist/contact/index.html -> /contact
30
+ * dist/404.html -> served by CF Pages for unmatched routes (HTTP 404)
31
+ * dist/sitemap.xml -> replaces the static one from public/
32
+ *
33
+ * Failure policy: exits 0 on fatal error so CF Pages deploy continues as SPA.
34
+ * Set PRESTRUCT_STRICT=1 to make config-validation failures exit 1 (local CI use).
35
+ */
36
+
37
+ import fs from 'fs'
38
+ import path from 'path'
39
+ import crypto from 'crypto'
40
+ import { fileURLToPath, pathToFileURL } from 'url'
41
+
42
+ import { validateConfig, formatDiagnostics } from './validate-config.js'
43
+
44
+ // Root-package re-export: `import { defineConfig } from 'prestruct'`
45
+ // (binding stays out of this file's scope to avoid an unused-import)
46
+ export { defineConfig } from './validate-config.js'
47
+
48
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
49
+ const ROOT = path.resolve(__dirname, '..')
50
+
51
+ // Rooted-path derivation: the npm CLI passes the USER PROJECT's root;
52
+ // deployed template copies call without opts and get their own project root.
53
+ /**
54
+ * Resolve the engine's working paths from an explicit root.
55
+ *
56
+ * @param {string} [root=ROOT] - host project root (defaults to the engine package)
57
+ * @returns {{root: string, dist: string, cacheDir: string, cacheFile: string}} paths
58
+ */
59
+ export function enginePaths(root = ROOT) {
60
+ return {
61
+ root,
62
+ dist: path.join(root, 'dist'),
63
+ cacheDir: path.join(root, '.prestruct', 'cache'),
64
+ cacheFile: path.join(root, '.prestruct', 'cache', 'routes.json'),
65
+ }
66
+ }
67
+
68
+ // -- Replacement safety --------------------------------------------------------
69
+ // Prevent regex backreference expansion ($1, $2, ...) when dynamic values are
70
+ // used in replacement strings.
71
+
72
+ /**
73
+ * Neutralize replacement-pattern characters ($&, $1, ...) so a string can
74
+ * safely be used as the replacement side of String.prototype.replace().
75
+ *
76
+ * @param {string} s - arbitrary generated content
77
+ * @returns {string} replacement-safe text
78
+ */
79
+ export function escapeReplacement(s) {
80
+ return String(s || '').replace(/\$/g, '$$$$')
81
+ }
82
+
83
+ // HTML attribute/text escaping for values landed in the baked HTML by
84
+ // injectMeta and generate404. Route meta titles/descriptions/OG images are
85
+ // operator config today, but CSM-fed configs interpolate third-party data --
86
+ // a `"` in a description silently breaks the attribute, and a `</title>` in a
87
+ // title injects markup into every baked page. Escape BEFORE $-escaping: the
88
+ // latter only handles replace() semantics.
89
+ /**
90
+ * Escape HTML-significant characters (& < > " ') for text and attribute contexts.
91
+ *
92
+ * @param {string} s - raw meta/OG value
93
+ * @returns {string} escaped value
94
+ */
95
+ export function escapeHtml(s) {
96
+ return String(s == null ? '' : s)
97
+ .replace(/&/g, '&amp;')
98
+ .replace(/</g, '&lt;')
99
+ .replace(/>/g, '&gt;')
100
+ .replace(/"/g, '&quot;')
101
+ .replace(/'/g, '&#39;')
102
+ }
103
+
104
+ // -- Cache-entry validity (stale-cache fix) ---------------------------------
105
+ // The old skip was path-keyed only: `if (!force && cache[route.path])` -- a
106
+ // changed shell (new vite asset hashes, meta edits, deploy-id) or changed
107
+ // route meta did NOT invalidate, and the loop happily rewrote yesterday's
108
+ // HTML over today's build. Real fix: every cache entry carries a
109
+ // signature over every rendered input -- the shell, the siteUrl, and the
110
+ // route's own meta/ogImage -- and a hit only counts when the signature
111
+ // matches. Entries written before signatures exist (no `sig` field) count
112
+ // as stale and get rerendered once, then upgraded in place.
113
+ //
114
+ // The helper is exported (not inlined in the loop) so the battery can pin
115
+ // the stale-path without running the whole vite pipeline.
116
+ // The helper is exported (not inlined in the loop) so the battery can pin
117
+ // the stale path without running the whole vite pipeline.
118
+ /**
119
+ * Compute the cache signature for a route render. Covers every input whose
120
+ * change invalidates the cached HTML: route meta, siteUrl, and the shell.
121
+ *
122
+ * @param {{path: string, meta?: object}} route - route definition
123
+ * @param {string} siteUrl - configured site origin
124
+ * @param {string} shell - dist/index.html contents
125
+ * @returns {string} stable hash signature
126
+ */
127
+ export function cacheSignature(route, siteUrl, shell) {
128
+ return crypto.createHash('sha256').update(JSON.stringify({
129
+ path: route.path,
130
+ siteUrl,
131
+ meta: route.meta ?? null,
132
+ ogImage: route.ogImage ?? null,
133
+ shell,
134
+ })).digest('hex')
135
+ }
136
+
137
+ /**
138
+ * Whether a cached entry may be reused for a route. Legacy entries (no
139
+ * signature) always miss to render once and upgrade.
140
+ *
141
+ * @param {{sig?: string}} entry - cached entry or undefined
142
+ * @param {{path: string, meta?: object}} route - current route definition
143
+ * @param {string} siteUrl - configured site origin
144
+ * @param {string} shell - current dist/index.html contents
145
+ * @returns {boolean} true when the cache may be reused
146
+ */
147
+ export function cacheEntryValid(entry, route, siteUrl, shell) {
148
+ if (!entry || typeof entry !== 'object' || typeof entry.html !== 'string') return false
149
+ // Legacy entries predate signatures: treat as stale once, rerender, and
150
+ // the write below upgrades them in place.
151
+ if (!entry.sig) return false
152
+ return entry.sig === cacheSignature(route, siteUrl, shell)
153
+ }
154
+
155
+ // -- Root-div injection (W8 fix) -----------------------------------------------
156
+ // Attribute-tolerant: inject-brand.js may add its own attrs to the root div
157
+ // (e.g. data-deploy-id-footer when config.deployIdFooter is true). This
158
+ // matcher therefore matches <div id="root">, <div id="root" ...attrs>, and
159
+ // carries existing attrs into the SSR output so they survive prerendering.
160
+
161
+ /**
162
+ * Swap the shell's root div for one stamped with data-server-rendered and
163
+ * carrying the prerendered markup. Uses a function replacer so $-shaped
164
+ * content in appHtml stays literal.
165
+ *
166
+ * @param {string} shell - dist/index.html contents
167
+ * @param {string} appHtml - renderToString output for the route
168
+ * @returns {string} full HTML document for the route
169
+ * @throws when the shell has no root div
170
+ */
171
+ export function replaceRootDiv(shell, appHtml) {
172
+ const match = shell.match(/<div id="root"([^>]*)><\/div>/)
173
+ if (!match) {
174
+ throw new Error('<div id="root"></div> not found in shell -- check index.html')
175
+ }
176
+ const existing = match[1].replace(/\s+$/, '')
177
+ const attrs = existing ? `${existing} data-server-rendered="true"` : ' data-server-rendered="true"'
178
+ // appHtml MUST be $-escaped: it is okay for route content to contain
179
+ // "$1"/"$&"-shaped text (prices, code samples, quotes) and the second
180
+ // argument to replace() would expand those into capture references,
181
+ // mangling the SSR output. Same lesson as the $120 meta-description bug.
182
+ return shell.replace(
183
+ /<div id="root"[^>]*><\/div>/,
184
+ () => `<div id="root"${attrs}>${appHtml}</div>`
185
+ )
186
+ }
187
+
188
+ // -- Meta injection ------------------------------------------------------------
189
+
190
+ /**
191
+ * Stamp per-route title/description/canonical/OG onto prerendered HTML.
192
+ * Derives the canonical URL from siteUrl + routePath (no trailing slash for
193
+ * the home route) and falls back to the global ogImage.
194
+ *
195
+ * @param {string} html - prerendered document
196
+ * @param {{title?: string, description?: string, ogImage?: string}} meta - route meta
197
+ * @param {string} routePath - '/'-prefixed route path
198
+ * @param {string} siteUrl - configured site origin (no trailing slash)
199
+ * @param {?string} globalOgImage - config-level ogImage fallback
200
+ * @returns {string} updated html
201
+ */
202
+ export function injectMeta(html, meta, routePath, siteUrl, globalOgImage) {
203
+ const title = escapeReplacement(escapeHtml(meta.title))
204
+ const desc = escapeReplacement(escapeHtml(meta.description))
205
+ const ogImage = escapeReplacement(escapeHtml(meta.ogImage || globalOgImage || ''))
206
+ const url = escapeReplacement(escapeHtml(`${siteUrl}${routePath === '/' ? '' : routePath}`))
207
+
208
+ if (title) {
209
+ html = html.replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`)
210
+ html = html.replace(/(<meta\s+property="og:title"\s+content=")[^"]*(")/s, `$1${title}$2`)
211
+ html = html.replace(/(<meta\s+name="twitter:title"\s+content=")[^"]*(")/s, `$1${title}$2`)
212
+ }
213
+ if (desc) {
214
+ html = html.replace(/(<meta\s+name="description"\s+content=")[^"]*(")/s, `$1${desc}$2`)
215
+ html = html.replace(/(<meta\s+property="og:description"\s+content=")[^"]*(")/s, `$1${desc}$2`)
216
+ html = html.replace(/(<meta\s+name="twitter:description"\s+content=")[^"]*(")/s, `$1${desc}$2`)
217
+ }
218
+ html = html.replace(/(<meta\s+property="og:url"\s+content=")[^"]*(")/s, `$1${url}$2`)
219
+ html = html.replace(/(<link\s+rel="canonical"\s+href=")[^"]*(")/s, `$1${url}$2`)
220
+ if (ogImage) {
221
+ html = html.replace(/(<meta\s+property="og:image"\s+content=")[^"]*(")/s, `$1${ogImage}$2`)
222
+ html = html.replace(/(<meta\s+name="twitter:image"\s+content=")[^"]*(")/s, `$1${ogImage}$2`)
223
+ }
224
+
225
+ return html
226
+ }
227
+
228
+ // -- Incremental cache ---------------------------------------------------------
229
+
230
+ /**
231
+ * Read the incremental cache map from disk (empty object when absent/corrupt).
232
+ *
233
+ * @param {{cacheFile?: string}} [opts] - explicit cache file path override
234
+ * @returns {Record<string, {html: string, time: number, sig?: string}>} cache map
235
+ */
236
+ export function readCache({ cacheFile = enginePaths().cacheFile } = {}) {
237
+ try {
238
+ if (fs.existsSync(cacheFile)) {
239
+ return JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
240
+ }
241
+ } catch {}
242
+ return {}
243
+ }
244
+
245
+ /**
246
+ * Persist the incremental cache map (creating the cache dir as needed).
247
+ *
248
+ * @param {Record<string, object>} cache - route-keyed cached entries
249
+ * @param {{cacheDir?: string, cacheFile?: string}} [opts] - path overrides
250
+ * @returns {void}
251
+ */
252
+ export function writeCache(cache, { cacheDir = enginePaths().cacheDir, cacheFile = enginePaths().cacheFile } = {}) {
253
+ fs.mkdirSync(cacheDir, { recursive: true })
254
+ fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2))
255
+ }
256
+
257
+ /**
258
+ * Reset the incremental cache. Contract: reset-to-empty (writes `{}`), never
259
+ * deletion -- a custom cacheFile's directory is created if missing so the
260
+ * file never dangles, and the next build treats the empty map as fresh.
261
+ *
262
+ * @param {{cacheFile?: string}} [opts] - explicit cache file path override
263
+ * @returns {void}
264
+ */
265
+ export function cleanCache({ cacheFile = enginePaths().cacheFile } = {}) {
266
+ // Directory is derived from the file path so a custom cacheFile can never
267
+ // point at a directory that was not created.
268
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true })
269
+ fs.writeFileSync(cacheFile, '{}')
270
+ console.log('[prerender] Cache cleared')
271
+ }
272
+
273
+ // -- Sitemap -------------------------------------------------------------------
274
+
275
+ /**
276
+ * Generate robots.txt when config.robots is opted in; otherwise null so the
277
+ * author's own public/robots.txt is left untouched. The Sitemap: line points
278
+ * at the freshly written sitemap.
279
+ *
280
+ * @param {object} config - full ssr.config.js export
281
+ * @returns {?string} robots.txt contents, or null when not opted in
282
+ */
283
+ export function generateRobots(config) {
284
+ const cfg = config?.robots || null
285
+ // No config.robots = existing public/robots.txt stays authorable; we never
286
+ // touch dist/robots.txt in that case.
287
+ if (!cfg) return null
288
+
289
+ const cfgBool = cfg === true
290
+ const cfgBody = typeof cfg === 'string' ? cfg : (cfg.body || '')
291
+ const cfgSitemap = cfgBool || (cfg && typeof cfg === 'object' ? cfg.sitemap !== false : true)
292
+ const siteUrl = (config.siteUrl || '').replace(/\/$/, '')
293
+
294
+ if (cfgBool) {
295
+ const lines = [
296
+ 'User-agent: *',
297
+ 'Allow: /',
298
+ ]
299
+ if (cfgSitemap && siteUrl) {
300
+ lines.push('', `Sitemap: ${siteUrl}/sitemap.xml`)
301
+ }
302
+ return lines.join('\n') + '\n'
303
+ }
304
+
305
+ // Custom body; sitemap pointer appended unless { sitemap: false }
306
+ return cfgSitemap && siteUrl
307
+ ? `${cfgBody.trimEnd()}\n\nSitemap: ${siteUrl}/sitemap.xml\n`
308
+ : `${cfgBody.trimEnd()}\n`
309
+ }
310
+
311
+ /**
312
+ * Escape XML-significant characters for sitemap/RSS payloads.
313
+ *
314
+ * @param {string} unsafe - raw value
315
+ * @returns {string} XML-safe value
316
+ */
317
+ export function escapeXml(unsafe) {
318
+ if (!unsafe) return ''
319
+ return String(unsafe)
320
+ .replace(/&/g, '&amp;')
321
+ .replace(/</g, '&lt;')
322
+ .replace(/>/g, '&gt;')
323
+ .replace(/"/g, '&quot;')
324
+ .replace(/'/g, '&apos;')
325
+ }
326
+
327
+ /**
328
+ * Build sitemap.xml from the merged route list, XML-escaped, no trailing
329
+ * slash on the home URL.
330
+ *
331
+ * @param {Array<{path: string}>} routes - merged route definitions
332
+ * @param {string} siteUrl - configured site origin
333
+ * @returns {string} sitemap.xml contents
334
+ */
335
+ export function generateSitemap(routes, siteUrl) {
336
+ const now = new Date().toISOString().split('T')[0]
337
+ const urls = routes.map(r => `
338
+ <url>
339
+ <loc>${escapeXml(siteUrl)}${r.path === '/' ? '' : escapeXml(r.path)}</loc>
340
+ <lastmod>${now}</lastmod>
341
+ <changefreq>${escapeXml(r.changefreq || 'monthly')}</changefreq>
342
+ <priority>${escapeXml(r.priority || '0.5')}</priority>
343
+ </url>`).join('')
344
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}\n</urlset>`
345
+ }
346
+
347
+ // -- 404 page ------------------------------------------------------------------
348
+ // Uses id="root-404" (not "root") so main.jsx does NOT trigger hydrateRoot.
349
+ // The React bundle script tag is stripped -- no JS loads at all on 404 pages.
350
+ // See AGENTS.md for the full explanation.
351
+
352
+ /**
353
+ * Render the static 404 page: id="root-404" (never hydrated), noindex,
354
+ * stripped React bundle tag, build tag stamp.
355
+ *
356
+ * @param {string} shell - dist/index.html contents
357
+ * @param {object} config - full ssr.config.js export
358
+ * @param {string} siteUrl - configured site origin
359
+ * @returns {string} 404.html contents
360
+ */
361
+ export function generate404(shell, config, siteUrl) {
362
+ const notFoundConfig = config.notFound || {}
363
+ // User-supplied notFound values: html-escaped before they land in markup
364
+ // (a <script> in a heading would ship working JS otherwise) AND
365
+ // $$-escaped where they appear in replacement strings ("$&" in a heading
366
+ // would otherwise pull the matched tag text into the output).
367
+ const heading = escapeXml(notFoundConfig.heading || 'Page not found.')
368
+ const body = escapeXml(notFoundConfig.body || "That page doesn't exist -- or it moved.")
369
+ const primaryLabel = escapeXml(notFoundConfig.primaryCta?.label || 'Go home')
370
+ const primaryHref = escapeXml(notFoundConfig.primaryCta?.href || '/')
371
+ const siteName = escapeXml(config.siteName || 'Site')
372
+ const v = escapeReplacement
373
+
374
+ const bodyLines = [
375
+ '<div id="root-404">',
376
+ ' <div style="min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:4rem 2rem">',
377
+ ` <h1 style="font-size:2.5rem;font-weight:800;margin-bottom:1rem">${heading}</h1>`,
378
+ ` <p style="max-width:480px;font-size:1.05rem;line-height:1.7;margin-bottom:2rem">${body}</p>`,
379
+ // Theme-neutral CTA (#19): the filled `background:#000;color:#fff` button
380
+ // went invisible on dark themes (black fill on near-black pages). Text and
381
+ // outline now follow currentColor, so the CTA reads on any host theme.
382
+ ` <a href="${primaryHref}" style="display:inline-flex;align-items:center;padding:0.75rem 1.75rem;color:inherit;font-weight:700;font-size:0.9rem;letter-spacing:0.05em;text-transform:uppercase;border:2px solid currentColor;border-radius:6px;text-decoration:none">${primaryLabel}</a>`,
383
+ ' </div>',
384
+ '</div>',
385
+ ]
386
+
387
+ // Function replacer: bodyLines carry user text; string-form replacement
388
+ // would expand $-sequences (same trap as replaceRootDiv).
389
+ let html = shell.replace(/<div id="root"[^>]*><\/div>/, () => bodyLines.join('\n'))
390
+ html = html.replace(/<title>[^<]*<\/title>/, () => `<title>Page Not Found | ${siteName}</title>`)
391
+ html = html.replace(
392
+ /(<meta\s+name="description"\s+content=")[^"]*(")/s,
393
+ () => `$1The page you were looking for does not exist.$2`
394
+ )
395
+ html = html.replace(
396
+ /(<meta\s+property="og:title"\s+content=")[^"]*(")/s,
397
+ () => `$1Page Not Found | ${siteName}$2`
398
+ )
399
+ html = html.replace(
400
+ /(<meta\s+name="twitter:title"\s+content=")[^"]*(")/s,
401
+ () => `$1Page Not Found | ${siteName}$2`
402
+ )
403
+ html = html.replace(
404
+ /(<link\s+rel="canonical"\s+href=")[^"]*(")/s,
405
+ () => `$1${v(siteUrl)}/$2`
406
+ )
407
+ // noindex -- 404 pages should not appear in search results
408
+ html = html.replace(
409
+ /<meta\s+name="robots"\s+content="[^"]*"\s*\/?>/s,
410
+ ''
411
+ )
412
+ html = html.replace(
413
+ '<meta name="author"',
414
+ '<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex" />\n <meta name="author"'
415
+ )
416
+ // Strip the React bundle -- 404 is pure static HTML, no React needed.
417
+ // modulepreload must go too: vite emits one `<link rel=modulepreload>` per
418
+ // imported chunk next to the entry script, and a modulepreload link makes
419
+ // the browser PREFETCH the 160KB vendor bundle on a page whose whole
420
+ // contract is zero JS loads (caught by the 2026-10 perf/page-weight audit).
421
+ html = html.replace(/<script type="module"[^>]*><\/script>/, '')
422
+ html = html.replace(/<link[^>]*rel="modulepreload"[^>]*>/g, '')
423
+
424
+ return html
425
+ }
426
+
427
+ // -- Main pipeline -------------------------------------------------------------
428
+
429
+ async function loadConfig() {
430
+ try {
431
+ config = (await import('../ssr.config.js')).default
432
+ } catch (err) {
433
+ console.error('[prerender] Could not load ssr.config.js:', err.message)
434
+ return null
435
+ }
436
+ const { ok, errors } = validateConfig(config)
437
+ if (!ok) {
438
+ formatDiagnostics(errors).forEach(line => console.error(line))
439
+ if (process.env.PRESTRUCT_STRICT === '1') {
440
+ console.error('[prerender] PRESTRUCT_STRICT=1 -- failing build on config diagnostics')
441
+ process.exit(1)
442
+ }
443
+ }
444
+ const { siteUrl } = config
445
+ if (!siteUrl) return null
446
+ return config
447
+ }
448
+
449
+ let config = null
450
+
451
+ // CSS-route merge: the fetchRoutes() hook merges its CMS-driven routes with
452
+ // the static ones. Dedupe by path -- the STATIC entry takes precedence, per
453
+ // the documented merge contract. Pure function so tests can assert the
454
+ // dedupe rules without spinning the whole pipeline.
455
+ /**
456
+ * Merge static + fetchRoutes() CMS routes: dedupe by path with the static
457
+ * entry winning collisions; fetched routes are appended in order. A nullish
458
+ * fetched list degrades to the static list unchanged.
459
+ *
460
+ * @param {Array<{path: string}>} [staticRoutes=[]] - config routes
461
+ * @param {?Array<{path: string}>} [fetchedRoutes] - hook result
462
+ * @returns {Array<{path: string}>} merged route list
463
+ */
464
+ export function mergeRoutes(staticRoutes = [], fetchedRoutes) {
465
+ // Static routes keep both precedence (path collisions) and position
466
+ // (static first, CMS routes appended). A hook that resolves to nothing
467
+ // (null/undefined) degrades to the static list unchanged.
468
+ const out = new Map((staticRoutes ?? []).map(r => [r.path, r]))
469
+ for (const r of fetchedRoutes ?? []) {
470
+ if (!out.has(r.path)) out.set(r.path, r)
471
+ }
472
+ return [...out.values()]
473
+ }
474
+
475
+ // StaticRouter loader: react-router-dom's exports map differs by version.
476
+ // v6 exposes the SSR entry as a './server.js' subpath; v7 re-exports
477
+ // StaticRouter from the package root. `importer` is injectable so the v7
478
+ // fallback (and the both-fail case) is testable offline.
479
+ /**
480
+ * Load StaticRouter across the react-router-dom peer range: v6 exposes the
481
+ * SSR entry as './server.js'; v7 re-exports it from the package root.
482
+ *
483
+ * @param {(m: string) => Promise<any>} [importer] - injectable importer (tests)
484
+ * @returns {Promise<Function>} the StaticRouter component
485
+ * @throws when neither entry provides StaticRouter
486
+ */
487
+ export async function loadStaticRouter(importer = (m) => import(m)) {
488
+ let StaticRouter
489
+ try {
490
+ ;({ StaticRouter } = await importer('react-router-dom/server.js'))
491
+ } catch {
492
+ ;({ StaticRouter } = await importer('react-router-dom'))
493
+ }
494
+ if (!StaticRouter) {
495
+ throw new Error('[prerender] Could not load StaticRouter from react-router-dom -- is it installed?')
496
+ }
497
+ return StaticRouter
498
+ }
499
+
500
+ /**
501
+ * Run the full build-time prerender: ssrLoadModule the host's AppLayout,
502
+ * render each route through StaticRouter, stamp route meta, and write
503
+ * dist/<route>/index.html, 404.html, sitemap.xml, robots.txt, and the
504
+ * cache-stats snapshot. Library-safe: returns a summary, never process.exit.
505
+ *
506
+ * @param {object} [cfg=config] - full ssr.config.js export
507
+ * @param {{force?: boolean, root?: string}} [opts] - force rerender; host root
508
+ * @returns {?{total: number, succeeded: number, failed: number, cached: number, failures: Array}} render summary
509
+ * (null when skipped: no config, no dist shell, or zero routes)
510
+ */
511
+ export async function prerender(cfg = config, { force = false, root } = {}) {
512
+ const p = enginePaths(root || ROOT)
513
+ console.log('\n[prerender] Starting static HTML generation...')
514
+
515
+ const { siteUrl,
516
+ routes: staticRoutes = [],
517
+ appLayoutPath = '/src/AppLayout.jsx',
518
+ } = cfg
519
+
520
+ // fetchRoutes() hook: documented in docs/guides/api.md and the config
521
+ // example. Runs at build time only (never in the browser). Merged with
522
+ // the static routes; static wins on path collisions.
523
+ let fetched = []
524
+ if (typeof cfg.fetchRoutes === 'function') {
525
+ fetched = (await cfg.fetchRoutes()) || []
526
+ }
527
+ const ROUTES = mergeRoutes(staticRoutes, fetched)
528
+
529
+ if (!ROUTES.length) {
530
+ console.warn('[prerender] No routes defined in ssr.config.js -- skipping')
531
+ // Library-safety: never process.exit from a callable function -- callers
532
+ // (tests, host CLI) would lose control of their own process. The CLI's
533
+ // wrapper reports exit 0 naturally when this returns.
534
+ return
535
+ }
536
+
537
+ const { createServer } = await import('vite')
538
+ const { renderToString } = await import('react-dom/server')
539
+ const React = (await import('react')).default
540
+ const StaticRouter = await loadStaticRouter()
541
+
542
+ // Vite dev server in SSR mode.
543
+ // ssrLoadModule resolves all imports through Vite's unified module registry,
544
+ // guaranteeing a single react-router-dom instance. StaticRouter's location
545
+ // context reaches useLocation() inside Routes correctly.
546
+ const vite = await createServer({
547
+ root: p.root,
548
+ server: { middlewareMode: true },
549
+ appType: 'custom',
550
+ customLogger: {
551
+ info: () => {},
552
+ warn: (msg) => { if (!msg.includes('ExperimentalWarning')) process.stderr.write('[prerender:warn] ' + msg + '\n') },
553
+ error: (msg) => process.stderr.write('[prerender:err] ' + msg + '\n'),
554
+ clearScreen: () => {},
555
+ hasErrorLogged: () => false,
556
+ hasWarned: false,
557
+ warnOnce: () => {},
558
+ },
559
+ })
560
+
561
+ try {
562
+ const { default: AppLayout } = await vite.ssrLoadModule(appLayoutPath)
563
+
564
+ // Validate dist/index.html exists (vite build may have failed)
565
+ const indexPath = path.join(p.dist, 'index.html')
566
+ if (!fs.existsSync(indexPath)) {
567
+ console.error('[prerender] dist/index.html not found -- run vite build first')
568
+ return
569
+ }
570
+
571
+ const shell = fs.readFileSync(indexPath, 'utf-8')
572
+ let succeeded = 0
573
+ const failures = []
574
+ const cache = readCache({ cacheFile: p.cacheFile })
575
+
576
+ console.log(`[prerender] Cache: ${Object.keys(cache).length} routes cached${force ? ' (forcing rebuild)' : ''}`)
577
+
578
+ for (const route of ROUTES) {
579
+ // Defense in depth for the validator's path rules: at this point write
580
+ // happens for real -- anything that could escape dist/ is refused, not
581
+ // path-joined. Validation diagnostics already flagged it; the loop
582
+ // still skips silently-uncaught otherwise.
583
+ if (route.path.includes('..') || route.path.includes('\\')) {
584
+ console.error(`[prerender] FAIL ${route.path}: refusing path outside dist/ (contains ".." or backslash)`)
585
+ continue
586
+ }
587
+
588
+ const distPath = route.path === '/'
589
+ ? path.join(p.dist, 'index.html')
590
+ : path.join(p.dist, route.path.slice(1), 'index.html')
591
+
592
+ // Incremental: skip ONLY on a valid cache entry -- the old bare
593
+ // path-keyed check rewrote stale HTML over fresh builds whenever the
594
+ // shell or route meta changed. cacheEntryValid sees signatures over
595
+ // every rendered input; legacy entries (no sig) render once.
596
+ if (!force && cacheEntryValid(cache[route.path], route, siteUrl, shell)) {
597
+ fs.mkdirSync(path.dirname(distPath), { recursive: true })
598
+ fs.writeFileSync(distPath, cache[route.path].html, 'utf-8')
599
+ console.log(`[prerender] cached ${route.path}`)
600
+ succeeded++
601
+ continue
602
+ }
603
+
604
+ // Render fresh
605
+ try {
606
+ const appHtml = renderToString(
607
+ React.createElement(
608
+ StaticRouter,
609
+ { location: route.path },
610
+ React.createElement(AppLayout)
611
+ )
612
+ )
613
+
614
+ // Attribute-tolerant root-div injection (carries inject-brand attrs)
615
+ let html = replaceRootDiv(shell, appHtml)
616
+ html = injectMeta(html, route.meta || {}, route.path, siteUrl, cfg.ogImage)
617
+
618
+ // Save to cache with a signature over exactly these rendered inputs
619
+ cache[route.path] = { html, time: Date.now(), sig: cacheSignature(route, siteUrl, shell) }
620
+
621
+ fs.mkdirSync(path.dirname(distPath), { recursive: true })
622
+ fs.writeFileSync(distPath, html, 'utf-8')
623
+
624
+ console.log(`[prerender] ok ${route.path}`)
625
+ succeeded++
626
+ } catch (err) {
627
+ console.error(`[prerender] FAIL ${route.path}: ${err.message}`)
628
+ failures.push({ path: route.path, message: err.message })
629
+ }
630
+ }
631
+
632
+ // Save cache
633
+ writeCache(cache, { cacheDir: p.cacheDir, cacheFile: p.cacheFile })
634
+
635
+ // 404.html -- CF Pages serves this for all unmatched routes with HTTP 404
636
+ fs.writeFileSync(path.join(p.dist, '404.html'), generate404(shell, cfg || config, siteUrl), 'utf-8')
637
+ console.log('[prerender] ok /404.html')
638
+
639
+ // sitemap.xml -- overwrites the static one in public/ with today's date
640
+ fs.writeFileSync(path.join(p.dist, 'sitemap.xml'), generateSitemap(ROUTES, siteUrl), 'utf-8')
641
+ console.log('[prerender] ok /sitemap.xml')
642
+
643
+ // robots.txt -- opt-in only (config.robots): without it the author-owned
644
+ // public/robots.txt (if any) is untouched. Generated after sitemap so the
645
+ // Sitemap: line points at a file that was just (re-)written.
646
+ const robots = generateRobots(cfg || config)
647
+ if (robots !== null) {
648
+ fs.writeFileSync(path.join(p.dist, 'robots.txt'), robots, 'utf-8')
649
+ console.log('[prerender] ok /robots.txt')
650
+ }
651
+
652
+ // Cache stats -- static-sites-safe monitor: a JSON snapshot beside dist/
653
+ // (Intentionally not a runtime endpoint; static hosts serve files, not
654
+ // processes. Follows the PRD "static-sites-safe option preferred" note.)
655
+ const stats = {
656
+ generatedAt: new Date().toISOString(),
657
+ routeCount: ROUTES.length,
658
+ succeeded,
659
+ cached: Object.keys(cache).length,
660
+ force,
661
+ routes: ROUTES.map(r => ({ path: r.path, cached: Boolean(cache[r.path]) })),
662
+ }
663
+ fs.mkdirSync(path.join(p.dist, '.prestruct'), { recursive: true })
664
+ fs.writeFileSync(path.join(p.dist, '.prestruct', 'status.json'), JSON.stringify(stats, null, 2), 'utf-8')
665
+
666
+ console.log(`[prerender] Done. ${succeeded}/${ROUTES.length} pages rendered.\n`)
667
+
668
+ // Structured result -- callers (CLI, tests) need the failure count to
669
+ // decide the exit code. Live-fire (2026-10): every prerendering route
670
+ // is a silent-empty-page site; prerender used to end on a green-ok line while
671
+ // the routes errorred out. Return, never process.exit: library-safety.
672
+ return { total: ROUTES.length, succeeded, cached: ROUTES.length - succeeded, failed: failures.length, failures }
673
+
674
+ } finally {
675
+ await vite.close()
676
+ }
677
+ }
678
+
679
+ // -- CLI entry -----------------------------------------------------------------
680
+ // Only runs when executed directly (node scripts/prerender.js) -- never on
681
+ // import. prerender() itself does not load ssr.config.js; the CLI wrapper does.
682
+
683
+ /**
684
+ * True when this module was executed directly (node scripts/prerender.js);
685
+ * library importers never trigger the CLI wrapper.
686
+ *
687
+ * @returns {boolean}
688
+ */
689
+ export function isMainExecute() {
690
+ try {
691
+ return import.meta.url === pathToFileURL(process.argv[1] || '').href
692
+ } catch {
693
+ return false
694
+ }
695
+ }
696
+
697
+ async function main() {
698
+ config = await loadConfig()
699
+ if (!config) process.exit(0)
700
+ const args = process.argv.slice(2)
701
+ if (args.includes('--clean')) {
702
+ cleanCache()
703
+ process.exit(0)
704
+ }
705
+ const result = await prerender(config, { force: args.includes('--force') })
706
+ // A partially-rendered build ships empty HTML for the failed routes and
707
+ // (pre-SPA-fallback-removal) nothing at all on deep paths. That deploy is
708
+ // broken, not degraded -- make the failure real so CI and CF Pages builds
709
+ // surface it instead of "(0/2 pages rendered)" riding through green.
710
+ if (result && result.failed > 0) {
711
+ console.error(`[prerender] FATAL: ${result.failed}/${result.total} route(s) failed to prerender:`)
712
+ for (const f of result.failures) console.error(`[prerender] ${f.path}: ${f.message}`)
713
+ process.exit(1)
714
+ }
715
+ }
716
+
717
+ if (isMainExecute()) {
718
+ main().catch(err => {
719
+ // Fail loudly. Historically this exited 0 "so the SPA keeps deploying" --
720
+ // but the SPA fallback redirect rule is removed once prerendering is in
721
+ // place (AGENTS.md: _redirects), so a silent pass-through ships a site
722
+ // where deep routes 404 in production. A failed build must fail the build.
723
+ console.error('[prerender] Fatal:', err)
724
+ process.exit(1)
725
+ })
726
+ }