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.
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/bin/cli.mjs +212 -0
- package/engine/scripts/inject-brand.js +430 -0
- package/engine/scripts/prerender.js +726 -0
- package/engine/scripts/validate-config.js +117 -0
- package/engine/src/hooks/usePageMeta.js +78 -0
- package/engine/src/islands.js +129 -0
- package/engine/vite.config.js +18 -0
- package/init/VERSION +1 -0
- package/init/ecosystem.cjs +45 -0
- package/init/eslint.config.js +30 -0
- package/init/index.html +42 -0
- package/init/package-lock.json +3183 -0
- package/init/package.json +29 -0
- package/init/proxy.package.json +21 -0
- package/init/public/_headers +31 -0
- package/init/public/_redirects +16 -0
- package/init/scripts/inject-brand.js +430 -0
- package/init/scripts/prerender.js +726 -0
- package/init/scripts/prestruct-proxy.service +47 -0
- package/init/scripts/proxy.js +373 -0
- package/init/scripts/proxy.worker.js +217 -0
- package/init/scripts/validate-config.js +117 -0
- package/init/src/App.jsx +21 -0
- package/init/src/AppIslands.jsx +39 -0
- package/init/src/AppLayout.jsx +66 -0
- package/init/src/hooks/usePageMeta.js +78 -0
- package/init/src/islands.js +129 -0
- package/init/src/main.jsx +35 -0
- package/init/ssr.config.example.js +151 -0
- package/init/vite.config.js +18 -0
- package/init/vitest.config.js +14 -0
- package/init/wrangler.toml +32 -0
- package/package.json +70 -4
|
@@ -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, '&')
|
|
98
|
+
.replace(/</g, '<')
|
|
99
|
+
.replace(/>/g, '>')
|
|
100
|
+
.replace(/"/g, '"')
|
|
101
|
+
.replace(/'/g, ''')
|
|
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, '&')
|
|
321
|
+
.replace(/</g, '<')
|
|
322
|
+
.replace(/>/g, '>')
|
|
323
|
+
.replace(/"/g, '"')
|
|
324
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|