prestruct 1.1.0 → 1.2.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/README.md +28 -64
- package/bin/cli.mjs +217 -9
- package/engine/scripts/audit.js +530 -0
- package/engine/scripts/hooks.js +93 -0
- package/engine/scripts/indexnow.js +139 -0
- package/engine/scripts/inject-brand.js +112 -10
- package/engine/scripts/ogimage.js +165 -0
- package/engine/scripts/prerender.js +585 -92
- package/engine/scripts/validate-config.js +103 -1
- package/engine/src/hooks/usePageMeta.js +3 -1
- package/engine/src/islands.js +23 -4
- package/engine/ssr.config.example.js +196 -0
- package/init/VERSION +1 -1
- package/init/ecosystem.cjs +1 -1
- package/init/package-lock.json +6 -6
- package/init/package.json +6 -6
- package/init/scripts/audit-if-configured.cjs +26 -0
- package/init/scripts/audit.js +530 -0
- package/init/scripts/hooks.js +93 -0
- package/init/scripts/indexnow.js +139 -0
- package/init/scripts/inject-brand.js +112 -10
- package/init/scripts/ogimage.js +165 -0
- package/init/scripts/prerender.js +585 -92
- package/init/scripts/proxy.js +100 -17
- package/init/scripts/proxy.worker.js +150 -33
- package/init/scripts/validate-config.js +103 -1
- package/init/src/hooks/usePageMeta.js +3 -1
- package/init/src/islands.js +23 -4
- package/init/ssr.config.example.js +37 -0
- package/package.json +15 -6
|
@@ -18,9 +18,13 @@
|
|
|
18
18
|
* Module surface (importable for tests; CLI logic only runs when executed):
|
|
19
19
|
* replaceRootDiv(shell, appHtml) -- root-div injector, attribute-tolerant
|
|
20
20
|
* injectMeta(html, meta, routePath, siteUrl, ogImage)
|
|
21
|
-
*
|
|
21
|
+
* assertXmlSafe(value, field) -- XML escape + control-char loud-fail
|
|
22
|
+
* generateSitemap(routes, siteUrl, routeMtimes?)
|
|
22
23
|
* generate404(shell, config, siteUrl)
|
|
23
24
|
* readCache() / writeCache(cache) / cleanCache()
|
|
25
|
+
* rewriteRrdNamedImports(source) -- react-router named-import interop rewrite
|
|
26
|
+
* esmTwinSource(id, opts) -- react-router CJS -> ESM twin redirect (#68)
|
|
27
|
+
* loadStaticRouter(importer?) -- StaticRouter across the v6/v7 peer range
|
|
24
28
|
* prerender() -- full pipeline
|
|
25
29
|
*
|
|
26
30
|
* Output (for a 3-route app with /, /about, /contact):
|
|
@@ -40,6 +44,7 @@ import crypto from 'crypto'
|
|
|
40
44
|
import { fileURLToPath, pathToFileURL } from 'url'
|
|
41
45
|
|
|
42
46
|
import { validateConfig, formatDiagnostics } from './validate-config.js'
|
|
47
|
+
import { resolveOgImageGeneration, renderRouteOgImage, imageSlug } from './ogimage.js'
|
|
43
48
|
|
|
44
49
|
// Root-package re-export: `import { defineConfig } from 'prestruct'`
|
|
45
50
|
// (binding stays out of this file's scope to avoid an unused-import)
|
|
@@ -112,8 +117,6 @@ export function escapeHtml(s) {
|
|
|
112
117
|
// as stale and get rerendered once, then upgraded in place.
|
|
113
118
|
//
|
|
114
119
|
// 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
120
|
// the stale path without running the whole vite pipeline.
|
|
118
121
|
/**
|
|
119
122
|
* Compute the cache signature for a route render. Covers every input whose
|
|
@@ -124,13 +127,19 @@ export function escapeHtml(s) {
|
|
|
124
127
|
* @param {string} shell - dist/index.html contents
|
|
125
128
|
* @returns {string} stable hash signature
|
|
126
129
|
*/
|
|
127
|
-
export function cacheSignature(route, siteUrl, shell) {
|
|
130
|
+
export function cacheSignature(route, siteUrl, shell, extras = null) {
|
|
128
131
|
return crypto.createHash('sha256').update(JSON.stringify({
|
|
129
132
|
path: route.path,
|
|
130
133
|
siteUrl,
|
|
131
134
|
meta: route.meta ?? null,
|
|
132
135
|
ogImage: route.ogImage ?? null,
|
|
133
136
|
shell,
|
|
137
|
+
// Config-gated head additions (the 2026-10 ride-along set): a host that
|
|
138
|
+
// flips feed / hreflang / verification / searchUrl on between builds
|
|
139
|
+
// must re-render the routes that carry them. The QC sweep caught the
|
|
140
|
+
// signature covering only the shell-side inputs, so newly enabled
|
|
141
|
+
// extras rode a valid-cache hit and never reached the shipped head.
|
|
142
|
+
extras: extras ?? null,
|
|
134
143
|
})).digest('hex')
|
|
135
144
|
}
|
|
136
145
|
|
|
@@ -144,24 +153,92 @@ export function cacheSignature(route, siteUrl, shell) {
|
|
|
144
153
|
* @param {string} shell - current dist/index.html contents
|
|
145
154
|
* @returns {boolean} true when the cache may be reused
|
|
146
155
|
*/
|
|
147
|
-
export function cacheEntryValid(entry, route, siteUrl, shell) {
|
|
156
|
+
export function cacheEntryValid(entry, route, siteUrl, shell, extras = null) {
|
|
148
157
|
if (!entry || typeof entry !== 'object' || typeof entry.html !== 'string') return false
|
|
149
158
|
// Legacy entries predate signatures: treat as stale once, rerender, and
|
|
150
159
|
// the write below upgrades them in place.
|
|
151
160
|
if (!entry.sig) return false
|
|
152
|
-
return entry.sig === cacheSignature(route, siteUrl, shell)
|
|
161
|
+
return entry.sig === cacheSignature(route, siteUrl, shell, extras)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build the config-gated render inputs that BOTH the cache signature and
|
|
166
|
+
* injectMeta consume. Extracted so the cache-signature contract is pinnable
|
|
167
|
+
* offline (the QC-sweep lesson: a config-gated head addition that changes the
|
|
168
|
+
* baked HTML must participate in the signature, or flipping it on rides a
|
|
169
|
+
* valid-cache hit and never reaches the shipped head).
|
|
170
|
+
*
|
|
171
|
+
* `ogImageGeneration` is reduced to a stable identity descriptor
|
|
172
|
+
* (templatePath + dimensions + font presence), never the resolved block --
|
|
173
|
+
* that carries the template TEXT and font BYTES, and a Buffer does not
|
|
174
|
+
* JSON-round-trip. The descriptor changes exactly when the generated image
|
|
175
|
+
* (and therefore the per-route og:image the page ships) would change.
|
|
176
|
+
*
|
|
177
|
+
* @param {object} cfg - ssr.config.js export
|
|
178
|
+
* @param {?object} ogImageGeneration - resolved block from resolveOgImageGeneration
|
|
179
|
+
* @returns {object} extras fed to cacheSignature + injectMeta
|
|
180
|
+
*/
|
|
181
|
+
export function buildHeadExtras(cfg, ogImageGeneration) {
|
|
182
|
+
return {
|
|
183
|
+
feed: cfg.feed ?? null,
|
|
184
|
+
alternates: cfg.alternates ?? null,
|
|
185
|
+
verification: cfg.verification ?? null,
|
|
186
|
+
searchUrl: cfg.searchUrl ?? null,
|
|
187
|
+
// Enabling/retargeting per-route OG generation rewrites every route's
|
|
188
|
+
// og:image/twitter:image to the generated PNG -- a rendered input.
|
|
189
|
+
ogImageGeneration: ogImageGeneration
|
|
190
|
+
? {
|
|
191
|
+
templatePath: ogImageGeneration.templatePath,
|
|
192
|
+
width: ogImageGeneration.width,
|
|
193
|
+
height: ogImageGeneration.height,
|
|
194
|
+
font: Boolean(ogImageGeneration.fontBytes),
|
|
195
|
+
}
|
|
196
|
+
: null,
|
|
197
|
+
}
|
|
153
198
|
}
|
|
154
199
|
|
|
155
|
-
// -- Root-div injection (W8 fix)
|
|
200
|
+
// -- Root-div injection (W8 fix + re-entrancy fix, 2026-10) ---------------------
|
|
156
201
|
// Attribute-tolerant: inject-brand.js may add its own attrs to the root div
|
|
157
202
|
// (e.g. data-deploy-id-footer when config.deployIdFooter is true). This
|
|
158
203
|
// matcher therefore matches <div id="root">, <div id="root" ...attrs>, and
|
|
159
204
|
// carries existing attrs into the SSR output so they survive prerendering.
|
|
205
|
+
// Content-tolerant too, and NOT optional: prerender's own output lands in
|
|
206
|
+
// dist/index.html -- the root div ships RENDERED. A bare re-run (CI debug
|
|
207
|
+
// rerun, cache-money builds without a fresh vite step) reads that rendered
|
|
208
|
+
// shell, and the old empty-only matcher threw "<div id="root"></div> not
|
|
209
|
+
// found" FATAL on every route. Re-rendering an already-rendered shell is now
|
|
210
|
+
// the same operation as rendering a pristine one, and idempotent.
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Locate the shell's root <div id="root"> element and return [start, end)
|
|
214
|
+
* offsets spanning the WHOLE element -- open tag through its matching close.
|
|
215
|
+
* Nested divs inside a previously rendered shell are depth-counted; a lazy
|
|
216
|
+
* matching scan would stop at the first nested </div> and swap the wrong
|
|
217
|
+
* span. A <div ... /> self-closing token neither opens nor closes a level.
|
|
218
|
+
*/
|
|
219
|
+
function findRootDivSpan(shell) {
|
|
220
|
+
// Attribute-tolerant: quote style, whitespace around `=`, and a stray
|
|
221
|
+
// attribute before id (inject-brand adds data-deploy-id-footer) all match.
|
|
222
|
+
// The old byte-exact `/<div id="root"[^>]*>/` failed hard on any reflow a
|
|
223
|
+
// minifier or prettier pass introduced.
|
|
224
|
+
const open = /<div\b[^>]*\sid\s*=\s*("root"|'root'|root)[^>]*>/.exec(shell)
|
|
225
|
+
if (!open) return null
|
|
226
|
+
let depth = 1
|
|
227
|
+
const TOKEN = /<div\b[^>]*>|<\/div>/g
|
|
228
|
+
TOKEN.lastIndex = open.index + open[0].length
|
|
229
|
+
let t
|
|
230
|
+
while ((t = TOKEN.exec(shell)) !== null) {
|
|
231
|
+
if (t[0] === '</div>') depth -= 1
|
|
232
|
+
else if (t[0].endsWith('/>')) continue
|
|
233
|
+
else depth += 1
|
|
234
|
+
if (depth === 0) return [open.index, TOKEN.lastIndex]
|
|
235
|
+
}
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
160
238
|
|
|
161
239
|
/**
|
|
162
240
|
* Swap the shell's root div for one stamped with data-server-rendered and
|
|
163
|
-
* carrying the prerendered markup.
|
|
164
|
-
* content in appHtml stays literal.
|
|
241
|
+
* carrying the prerendered markup.
|
|
165
242
|
*
|
|
166
243
|
* @param {string} shell - dist/index.html contents
|
|
167
244
|
* @param {string} appHtml - renderToString output for the route
|
|
@@ -169,20 +246,30 @@ export function cacheEntryValid(entry, route, siteUrl, shell) {
|
|
|
169
246
|
* @throws when the shell has no root div
|
|
170
247
|
*/
|
|
171
248
|
export function replaceRootDiv(shell, appHtml) {
|
|
172
|
-
const
|
|
173
|
-
if (!
|
|
249
|
+
const span = findRootDivSpan(shell)
|
|
250
|
+
if (!span) {
|
|
174
251
|
throw new Error('<div id="root"></div> not found in shell -- check index.html')
|
|
175
252
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
253
|
+
const closeIdx = shell.indexOf('>', span[0]) + 1
|
|
254
|
+
// Carry the open tag through UNCHANGED except for the render stamp: drop any
|
|
255
|
+
// stamp a previous pass wrote (so re-entry cannot stack duplicate stamps),
|
|
256
|
+
// then append one. Preserving the original open tag verbatim is what makes
|
|
257
|
+
// this attribute-tolerant -- the earlier form sliced attrs at the fixed
|
|
258
|
+
// `<div id="root"`.length offset, so a reflowed shell (`class="app"
|
|
259
|
+
// id="root"`, `id = "root"`, single quotes) shipped CORRUPT markup: a
|
|
260
|
+
// duplicated id, half an attribute name as garbage, or dropped attrs.
|
|
261
|
+
const base = shell
|
|
262
|
+
.slice(span[0], closeIdx)
|
|
263
|
+
.replace(/\s+data-server-rendered\s*=\s*("true"|'true'|true)/, '')
|
|
264
|
+
.replace(/>$/, '')
|
|
265
|
+
.replace(/\s+$/, '')
|
|
266
|
+
const open = base + ' data-server-rendered="true">'
|
|
267
|
+
// appHtml MUST NOT be $-expanded: it is okay for route content to contain
|
|
268
|
+
// "$1"/"$&"-shaped text (prices, code samples, quotes) and a replacement
|
|
269
|
+
// string would expand those into capture references, mangling the SSR
|
|
270
|
+
// output. Same lesson as the $120 meta-description bug.
|
|
271
|
+
// (Slice-assembly instead: no replace() in the swap at all.)
|
|
272
|
+
return shell.slice(0, span[0]) + open + appHtml + '</div>' + shell.slice(span[1])
|
|
186
273
|
}
|
|
187
274
|
|
|
188
275
|
// -- Meta injection ------------------------------------------------------------
|
|
@@ -199,27 +286,195 @@ export function replaceRootDiv(shell, appHtml) {
|
|
|
199
286
|
* @param {?string} globalOgImage - config-level ogImage fallback
|
|
200
287
|
* @returns {string} updated html
|
|
201
288
|
*/
|
|
202
|
-
|
|
289
|
+
// llms.txt (#49 family): one H1, a blockquote tagline, then a Docs section
|
|
290
|
+
// listing every indexable route's title, absolute URL and description - the
|
|
291
|
+
// AI-consumable site summary in the llmstxt.org shape. The prerender pass
|
|
292
|
+
// already carries every route's meta, so this costs no new scanning.
|
|
293
|
+
// Generated-only: an existing llms.txt (hand-rolled or SSG-written) is
|
|
294
|
+
// never touched, same contract as sitemap.xml. Opt-out: `llms: false`.
|
|
295
|
+
/**
|
|
296
|
+
* One line of plain text, whatever it was given: newlines split list
|
|
297
|
+
* entries silently, control characters ride in as garbage. Bad bytes
|
|
298
|
+
* drop, whitespace collapses.
|
|
299
|
+
*/
|
|
300
|
+
function oneline(value) {
|
|
301
|
+
return String(value)
|
|
302
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
303
|
+
.replace(/[\r\n]+/g, ' ')
|
|
304
|
+
.trim()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Write llms.txt into dist. Routes only: the prerendered route set (the
|
|
309
|
+
* same gate sitemap honors). @returns {string|null} the written path.
|
|
310
|
+
*/
|
|
311
|
+
export function generateLlms(routes, config, distDir) {
|
|
312
|
+
if (config && config.llms === false) return null
|
|
313
|
+
const target = path.join(distDir, 'llms.txt')
|
|
314
|
+
if (fs.existsSync(target)) {
|
|
315
|
+
console.log('[prerender] llms.txt exists - leaving it untouched')
|
|
316
|
+
return null
|
|
317
|
+
}
|
|
318
|
+
const base = ((config && config.siteUrl) || '').replace(/\/$/, '')
|
|
319
|
+
const lines = []
|
|
320
|
+
lines.push('# ' + oneline((config && config.siteName) || 'Site'))
|
|
321
|
+
const tagline = config && config.tagline
|
|
322
|
+
if (tagline) lines.push('', '> ' + oneline(tagline))
|
|
323
|
+
lines.push('', '## Docs', '')
|
|
324
|
+
let count = 0
|
|
325
|
+
for (const r of (routes || [])) {
|
|
326
|
+
if (!r || typeof r.path !== 'string') continue
|
|
327
|
+
const title = oneline((r.meta && r.meta.title) || r.path)
|
|
328
|
+
const desc = (r.meta && r.meta.description) ? ': ' + oneline(r.meta.description) : ''
|
|
329
|
+
lines.push('- [' + title + '](' + base + (r.path === '/' ? '/' : r.path) + ')' + desc)
|
|
330
|
+
count++
|
|
331
|
+
}
|
|
332
|
+
fs.writeFileSync(target, lines.join('\n') + '\n', 'utf-8')
|
|
333
|
+
console.log('[prerender] llms.txt: emitted for ' + count + ' route(s)')
|
|
334
|
+
return target
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function injectMeta(html, meta, routePath, siteUrl, globalOgImage, extras = null) {
|
|
203
338
|
const title = escapeReplacement(escapeHtml(meta.title))
|
|
204
339
|
const desc = escapeReplacement(escapeHtml(meta.description))
|
|
205
|
-
|
|
206
|
-
|
|
340
|
+
// og:image per-route override, string shorthand OR object form (object
|
|
341
|
+
// contract mirrors inject-brand's): object resolution takes `.url` so a
|
|
342
|
+
// structured global or route value lands as a URL, never as the string
|
|
343
|
+
// "[object Object]" corrupting every head silently.
|
|
344
|
+
const globalImgUrl = (typeof globalOgImage === 'object' && globalOgImage !== null) ? globalOgImage.url : globalOgImage
|
|
345
|
+
const routeImgUrl = (typeof meta.ogImage === 'object' && meta.ogImage !== null) ? meta.ogImage.url : meta.ogImage
|
|
346
|
+
const ogImage = escapeReplacement(escapeHtml(String(routeImgUrl || globalImgUrl || '')))
|
|
347
|
+
// Per-route og:image OBJECT companions (#audit B4): a route that overrides
|
|
348
|
+
// og:image with a structured object owns its width/height/alt/type too. The
|
|
349
|
+
// companions are reconciled below (the route's replace the site-wide ones
|
|
350
|
+
// inject-brand baked; absent ones are stripped so a stale global never
|
|
351
|
+
// describes a different raster). String-form overrides keep the global
|
|
352
|
+
// companions, which still describe the same site-wide image.
|
|
353
|
+
const routeOgObject = (meta.ogImage && typeof meta.ogImage === 'object' && !Array.isArray(meta.ogImage)) ? meta.ogImage : null
|
|
354
|
+
// Trailing-slash normalization (#53): a `siteUrl` written with its trailing
|
|
355
|
+
// slash would otherwise join into `https://site//about` on every page's
|
|
356
|
+
// canonical + og:url.robots.txt already strips; this is the same contract
|
|
357
|
+
// at the injection seam.
|
|
358
|
+
const url = escapeReplacement(escapeHtml(`${(siteUrl || '').replace(/\/$/, '')}${routePath === '/' ? '' : routePath}`))
|
|
207
359
|
|
|
208
360
|
if (title) {
|
|
209
361
|
html = html.replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`)
|
|
210
|
-
html = html.replace(/(<meta\s+property="og:title"\s+content=")[^"]*(")
|
|
211
|
-
html = html.replace(/(<meta\s+name="twitter:title"\s+content=")[^"]*(")
|
|
362
|
+
html = html.replace(/(<meta\s+property="og:title"\s+content=")[^"\r\n]*(")/, `$1${title}$2`)
|
|
363
|
+
html = html.replace(/(<meta\s+name="twitter:title"\s+content=")[^"\r\n]*(")/, `$1${title}$2`)
|
|
212
364
|
}
|
|
213
365
|
if (desc) {
|
|
214
|
-
html = html.replace(/(<meta\s+name="description"\s+content=")[^"]*(")
|
|
215
|
-
html = html.replace(/(<meta\s+property="og:description"\s+content=")[^"]*(")
|
|
216
|
-
html = html.replace(/(<meta\s+name="twitter:description"\s+content=")[^"]*(")
|
|
366
|
+
html = html.replace(/(<meta\s+name="description"\s+content=")[^"\r\n]*(")/, `$1${desc}$2`)
|
|
367
|
+
html = html.replace(/(<meta\s+property="og:description"\s+content=")[^"\r\n]*(")/, `$1${desc}$2`)
|
|
368
|
+
html = html.replace(/(<meta\s+name="twitter:description"\s+content=")[^"\r\n]*(")/, `$1${desc}$2`)
|
|
217
369
|
}
|
|
218
|
-
html = html.replace(/(<meta\s+property="og:url"\s+content=")[^"]*(")
|
|
219
|
-
html = html.replace(/(<link\s+rel="canonical"\s+href=")[^"]*(")
|
|
370
|
+
html = html.replace(/(<meta\s+property="og:url"\s+content=")[^"\r\n]*(")/, `$1${url}$2`)
|
|
371
|
+
html = html.replace(/(<link\s+rel="canonical"\s+href=")[^"\r\n]*(")/, `$1${url}$2`)
|
|
220
372
|
if (ogImage) {
|
|
221
|
-
html = html.replace(/(<meta\s+property="og:image"\s+content=")[^"]*(")
|
|
222
|
-
html = html.replace(/(<meta\s+name="twitter:image"\s+content=")[^"]*(")
|
|
373
|
+
html = html.replace(/(<meta\s+property="og:image"\s+content=")[^"\r\n]*(")/, `$1${ogImage}$2`)
|
|
374
|
+
html = html.replace(/(<meta\s+name="twitter:image"\s+content=")[^"\r\n]*(")/, `$1${ogImage}$2`)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Reconcile og:image companion tags against the route's object override.
|
|
378
|
+
// Only runs for the object form; a string override leaves the global
|
|
379
|
+
// companions in place (they describe the same site-wide image). All stale
|
|
380
|
+
// companions are stripped first (so the global width/height/alt/type/
|
|
381
|
+
// secure_url can never describe a different raster), then the route's own
|
|
382
|
+
// companions are re-emitted.
|
|
383
|
+
if (routeOgObject) {
|
|
384
|
+
html = html.replace(/\n?\s*<meta\s+property="og:image:(?:secure_url|type|width|height|alt)"\s+content="[^"\r\n]*"\s*\/?>/g, '')
|
|
385
|
+
const companions = []
|
|
386
|
+
if (typeof routeImgUrl === 'string' && routeImgUrl.startsWith('http://')) {
|
|
387
|
+
companions.push(['og:image:secure_url', 'https://' + routeImgUrl.slice('http://'.length)])
|
|
388
|
+
}
|
|
389
|
+
if (typeof routeOgObject.type === 'string' && routeOgObject.type) companions.push(['og:image:type', routeOgObject.type])
|
|
390
|
+
if (/^[0-9]+$/.test(String(routeOgObject.width ?? ''))) companions.push(['og:image:width', String(routeOgObject.width)])
|
|
391
|
+
if (/^[0-9]+$/.test(String(routeOgObject.height ?? ''))) companions.push(['og:image:height', String(routeOgObject.height)])
|
|
392
|
+
if (typeof routeOgObject.alt === 'string' && routeOgObject.alt) companions.push(['og:image:alt', routeOgObject.alt])
|
|
393
|
+
for (const [prop, value] of companions) {
|
|
394
|
+
const tag = ` <meta property="${prop}" content="${escapeReplacement(escapeHtml(value))}" />`
|
|
395
|
+
html = html.replace(/(<meta\s+property="og:image"\s+content="[^"\r\n]*"\s*\/>)/, `$1\n${tag}`)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// -- Grouped head additions (missing-only, config-gated: a host that opts
|
|
400
|
+
// out renders identical HTML to before). These ride a FUNCTION replacer
|
|
401
|
+
// (`</head>` swapped for tail + `</head>`), and a function replacer does
|
|
402
|
+
// NOT process `$`-syntax in its return value. So the values here need only
|
|
403
|
+
// escapeHtml (markup class); applying escapeReplacement too would ship the
|
|
404
|
+
// doubled `$$` literally (a feed URL `.../feed$1.xml` -> `.../feed$$1.xml`).
|
|
405
|
+
let tail = ''
|
|
406
|
+
const headAdd = (line) => { tail += '\n ' + line }
|
|
407
|
+
|
|
408
|
+
// Feed autodiscovery: feed readers only learn a feed exists from <head>.
|
|
409
|
+
const feed = extras && extras.feed
|
|
410
|
+
if (feed && typeof feed.url === 'string' && feed.url) {
|
|
411
|
+
const fType = feed.type || 'application/rss+xml'
|
|
412
|
+
const fTitle = feed.title ? ` title="${escapeHtml(feed.title)}"` : ''
|
|
413
|
+
headAdd(`<link rel="alternate" type="${escapeHtml(fType)}"${fTitle} href="${escapeHtml(feed.url)}" />`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// hreflang alternates: site-wide set, per-route override (swap semantics).
|
|
417
|
+
const alternates = (meta && meta.alternates) || (extras && extras.alternates)
|
|
418
|
+
if (Array.isArray(alternates)) {
|
|
419
|
+
for (const a of alternates) {
|
|
420
|
+
if (!a || typeof a.href !== 'string' || !a.href || typeof a.hreflang !== 'string' || !a.hreflang) continue
|
|
421
|
+
headAdd(`<link rel="alternate" hreflang="${escapeHtml(a.hreflang)}" href="${escapeHtml(a.href)}" />`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Verification metas (site verifiers for search consoles, tooling): keys
|
|
426
|
+
// must be plain meta-name shapes, values escaped. A $ or quote inside a
|
|
427
|
+
// key would otherwise become markup or replacement syntax.
|
|
428
|
+
const verification = extras && extras.verification
|
|
429
|
+
if (verification && typeof verification === 'object' && !Array.isArray(verification)) {
|
|
430
|
+
for (const [vName, vValue] of Object.entries(verification)) {
|
|
431
|
+
if (typeof vValue !== 'string' || vValue === '') continue
|
|
432
|
+
if (!/^[A-Za-z0-9._-]+$/.test(vName)) continue
|
|
433
|
+
headAdd(`<meta name="${escapeHtml(vName)}" content="${escapeHtml(vValue)}" />`)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Per-route JSON-LD (#35): route-scoped schema nodes ship on the pages
|
|
438
|
+
// that carry them (FAQPage belongs on the FAQ page, not site-wide). The
|
|
439
|
+
// home route may also wrap the site's query interface (SearchAction) when
|
|
440
|
+
// the host configures a searchUrl with a {query} slot.
|
|
441
|
+
const routeNodes = []
|
|
442
|
+
const searchUrl = extras && routePath === '/' && typeof extras.searchUrl === 'string' ? extras.searchUrl : null
|
|
443
|
+
if (searchUrl && searchUrl.includes('{query}')) {
|
|
444
|
+
routeNodes.push({
|
|
445
|
+
'@context': 'https://schema.org',
|
|
446
|
+
'@type': 'WebSite',
|
|
447
|
+
url: `${(siteUrl || '').replace(/\/$/, '')}/`,
|
|
448
|
+
potentialAction: {
|
|
449
|
+
'@type': 'SearchAction',
|
|
450
|
+
target: { '@type': 'EntryPoint', urlTemplate: searchUrl },
|
|
451
|
+
'query-input': 'required name=query',
|
|
452
|
+
},
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
const routeJsonLd = meta && meta.jsonLd
|
|
456
|
+
if (Array.isArray(routeJsonLd)) {
|
|
457
|
+
for (const n of routeJsonLd) {
|
|
458
|
+
if (n && typeof n === 'object' && !Array.isArray(n)) routeNodes.push(n)
|
|
459
|
+
}
|
|
460
|
+
} else if (routeJsonLd && typeof routeJsonLd === 'object') {
|
|
461
|
+
routeNodes.push(routeJsonLd)
|
|
462
|
+
}
|
|
463
|
+
if (routeNodes.length > 0) {
|
|
464
|
+
const payload = routeNodes.length === 1
|
|
465
|
+
? routeNodes[0]
|
|
466
|
+
: { '@context': 'https://schema.org', '@graph': routeNodes }
|
|
467
|
+
// Rebuild idempotence: strip any previously injected route block first.
|
|
468
|
+
html = html.replace(/\n\s*<!-- JSON-LD \(route\) -->[\s\S]*?<\/script>(?=[\s\S]*?<\/head>)/, '')
|
|
469
|
+
const script = '<script type="application/ld+json">'
|
|
470
|
+
+ JSON.stringify(payload, null, 2).replace(/</g, '\\u003c')
|
|
471
|
+
+ '</script>'
|
|
472
|
+
headAdd('<!-- JSON-LD (route) -->')
|
|
473
|
+
headAdd(script)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (tail) {
|
|
477
|
+
html = html.replace('</head>', function () { return tail + '\n </head>' })
|
|
223
478
|
}
|
|
224
479
|
|
|
225
480
|
return html
|
|
@@ -317,9 +572,9 @@ export function generateRobots(config) {
|
|
|
317
572
|
const sitemapLine = cfgSitemap && siteUrl ? ['', `Sitemap: ${siteUrl}/sitemap.xml`] : []
|
|
318
573
|
|
|
319
574
|
// { aeo: true | {} } -> search bots allowed, training crawls blocked by
|
|
320
|
-
// default. { aeo: { aiBots: 'allow' } } flips training to Allow. aeo
|
|
321
|
-
//
|
|
322
|
-
//
|
|
575
|
+
// default. { aeo: { aiBots: 'allow' } } flips training to Allow. Under aeo
|
|
576
|
+
// the preset owns the whole file: an accompanying `body` is NOT emitted
|
|
577
|
+
// (the preset branch returns before the custom-body path below).
|
|
323
578
|
const aeo = !cfgBool && cfg && cfg.aeo
|
|
324
579
|
const aeoCli = aeo ? (aeo.aiBots === 'allow' ? 'allow' : 'block') : null
|
|
325
580
|
|
|
@@ -357,26 +612,78 @@ export function escapeXml(unsafe) {
|
|
|
357
612
|
.replace(/'/g, ''')
|
|
358
613
|
}
|
|
359
614
|
|
|
615
|
+
/**
|
|
616
|
+
* XML-safety loud-fail (freestruct xmllike.js adoption): sitemap fields are
|
|
617
|
+
* user-config data (paths, supplied meta), so escape what escaping can fix
|
|
618
|
+
* and REFUSE what it cannot. Control characters cannot appear in XML at all
|
|
619
|
+
* -- no replacement exists, and writing them anyway ships a corrupt sitemap
|
|
620
|
+
* that reads green. A value that still produces invalid XML is a build
|
|
621
|
+
* error, not a silently poisoned artifact.
|
|
622
|
+
*
|
|
623
|
+
* @param {unknown} value - raw sitemap field value
|
|
624
|
+
* @param {string} field - field name for the diagnostic
|
|
625
|
+
* @returns {string} XML-safe value (escapeXml over a verified-clean text)
|
|
626
|
+
* @throws {Error} when the value carries a control character
|
|
627
|
+
*/
|
|
628
|
+
export function assertXmlSafe(value, field) {
|
|
629
|
+
const text = String(value ?? '')
|
|
630
|
+
const bad = text.match(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/)
|
|
631
|
+
if (bad) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`XML safety: value for ${field} contains control character U+${bad[0].charCodeAt(0).toString(16).padStart(4, '0')}`
|
|
634
|
+
+ ' which cannot be escaped - refusing to write invalid XML'
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
return escapeXml(text)
|
|
638
|
+
}
|
|
639
|
+
|
|
360
640
|
/**
|
|
361
641
|
* Build sitemap.xml from the merged route list, XML-escaped, no trailing
|
|
362
642
|
* slash on the home URL.
|
|
363
643
|
*
|
|
364
644
|
* @param {Array<{path: string}>} routes - merged route definitions
|
|
365
645
|
* @param {string} siteUrl - configured site origin
|
|
646
|
+
* @param {Map<string, Date>} [routeMtimes] - per-route dist file mtimes, the
|
|
647
|
+
* honest <lastmod> source (content-change dates); entries missing from
|
|
648
|
+
* the map fall back to now
|
|
366
649
|
* @returns {string} sitemap.xml contents
|
|
367
650
|
*/
|
|
368
|
-
export function generateSitemap(routes, siteUrl) {
|
|
369
|
-
const
|
|
370
|
-
const urls = routes.map(r =>
|
|
371
|
-
|
|
372
|
-
<
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
651
|
+
export function generateSitemap(routes, siteUrl, routeMtimes = new Map()) {
|
|
652
|
+
const base = (siteUrl || '').replace(/\/$/, '') // #53: never join onto a doubled slash
|
|
653
|
+
const urls = routes.map(r => {
|
|
654
|
+
// Honest lastmod (freestruct xmllike adoption): a universal build stamp
|
|
655
|
+
// moves every <lastmod> on every build, so freshness signals churn even
|
|
656
|
+
// when one page of a 300-page site changed. Each route carries its dist
|
|
657
|
+
// file's mtime -- written once per content change (writeRouteFile skips
|
|
658
|
+
// byte-identical rewrites) -- and unknown routes fall back to now.
|
|
659
|
+
const mtime = routeMtimes.get(r.path) || new Date()
|
|
660
|
+
const loc = assertXmlSafe(`${base}${r.path === '/' ? '' : r.path}`, 'sitemap loc')
|
|
661
|
+
return `\n <url>\n <loc>${loc}</loc>\n <lastmod>${assertXmlSafe(mtime.toISOString().split('T')[0], 'sitemap lastmod')}</lastmod>\n <changefreq>${assertXmlSafe(r.changefreq || 'monthly', 'sitemap changefreq')}</changefreq>\n <priority>${assertXmlSafe(r.priority || '0.5', 'sitemap priority')}</priority>\n </url>`
|
|
662
|
+
}).join('')
|
|
377
663
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}\n</urlset>`
|
|
378
664
|
}
|
|
379
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Write one route file, honestly. Byte-identical content skips the write so
|
|
668
|
+
* the file KEEPS its mtime -- the date the content last changed, which is
|
|
669
|
+
* the sitemap's per-route <lastmod> source. Fresh content gets a fresh
|
|
670
|
+
* stat. Cache hits previously rewrote identical HTML every build, which
|
|
671
|
+
* silently turned every lastmod into a build stamp.
|
|
672
|
+
*
|
|
673
|
+
* @param {string} distPath - final file path
|
|
674
|
+
* @param {string} html - route HTML to write
|
|
675
|
+
* @param {Map<string, Date>} routeMtimes - lastmod map, mutated with the
|
|
676
|
+
* route's content-change date
|
|
677
|
+
* @param {string} routePath - route registry key in the map
|
|
678
|
+
*/
|
|
679
|
+
function writeRouteFile(distPath, html, routeMtimes, routePath) {
|
|
680
|
+
fs.mkdirSync(path.dirname(distPath), { recursive: true })
|
|
681
|
+
let prev = null
|
|
682
|
+
try { prev = fs.readFileSync(distPath, 'utf8') } catch (e) { prev = null }
|
|
683
|
+
if (prev !== html) fs.writeFileSync(distPath, html, 'utf-8')
|
|
684
|
+
routeMtimes.set(routePath, fs.existsSync(distPath) ? fs.statSync(distPath).mtime : new Date())
|
|
685
|
+
}
|
|
686
|
+
|
|
380
687
|
// -- 404 page ------------------------------------------------------------------
|
|
381
688
|
// Uses id="root-404" (not "root") so main.jsx does NOT trigger hydrateRoot.
|
|
382
689
|
// The React bundle script tag is stripped -- no JS loads at all on 404 pages.
|
|
@@ -400,9 +707,22 @@ export function generate404(shell, config, siteUrl) {
|
|
|
400
707
|
const heading = escapeXml(notFoundConfig.heading || 'Page not found.')
|
|
401
708
|
const body = escapeXml(notFoundConfig.body || "That page doesn't exist -- or it moved.")
|
|
402
709
|
const primaryLabel = escapeXml(notFoundConfig.primaryCta?.label || 'Go home')
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
710
|
+
// CTA href scheme guard (#walk 2026-10): escapeXml fixes the markup
|
|
711
|
+
// breaking class (" in the attribute) but nothing can make a
|
|
712
|
+
// javascript:/data: URL into a safe destination -- it would ship as a
|
|
713
|
+
// WORKING script href on the one page whose whole contract is zero JS.
|
|
714
|
+
// Allow only real anchors (relative, absolute http(s), mailto:, tel:
|
|
715
|
+
// and a plain hash); anything else falls back to '/'. Config is
|
|
716
|
+
// author-owned, so this is hardening, not a trust boundary.
|
|
717
|
+
const rawHref = notFoundConfig.primaryCta?.href || '/'
|
|
718
|
+
const primaryHref = /^(\/|https?:\/\/|mailto:|tel:|#)/i.test(String(rawHref))
|
|
719
|
+
? escapeXml(rawHref)
|
|
720
|
+
: escapeXml('/')
|
|
721
|
+
// siteName is NOT escaped here: it flows into injectMeta's title, which
|
|
722
|
+
// escapes it (escapeHtml + escapeReplacement) exactly once. Pre-escaping
|
|
723
|
+
// it here double-escaped the 404 title ("Tom & Jerry's" shipped as
|
|
724
|
+
// "Tom &amp; Jerry&apos;s").
|
|
725
|
+
const siteName = config.siteName || 'Site'
|
|
406
726
|
|
|
407
727
|
const bodyLines = [
|
|
408
728
|
'<div id="root-404">',
|
|
@@ -417,42 +737,47 @@ export function generate404(shell, config, siteUrl) {
|
|
|
417
737
|
'</div>',
|
|
418
738
|
]
|
|
419
739
|
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
)
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
html = html
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
)
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
)
|
|
440
|
-
//
|
|
740
|
+
// Root-div swap rides findRootDivSpan (re-entrancy fix): slice assembly
|
|
741
|
+
// keeps $-shaped config text literal with no replacement string in sight.
|
|
742
|
+
const span = findRootDivSpan(shell)
|
|
743
|
+
if (!span) {
|
|
744
|
+
throw new Error('<div id="root"></div> not found in shell -- check index.html')
|
|
745
|
+
}
|
|
746
|
+
let html = shell.slice(0, span[0]) + bodyLines.join('\n') + shell.slice(span[1])
|
|
747
|
+
// #42 (the root fix behind the replacer tragedy #33/#34): the four text
|
|
748
|
+
// tags ride the SAME injection pipeline as every page -- injectMeta owns
|
|
749
|
+
// title/description/og:title/twitter:title with its single-line,
|
|
750
|
+
// class-excluding regexes and the shared escape pipeline. No 404-specific
|
|
751
|
+
// replacer remains for any future head tag to trip over.
|
|
752
|
+
html = injectMeta(html, {
|
|
753
|
+
title: `Page Not Found | ${siteName}`,
|
|
754
|
+
description: 'The page you were looking for does not exist.',
|
|
755
|
+
}, '/', siteUrl, config && config.ogImage)
|
|
756
|
+
// The 404 canonical + og:url are the HOME origin, and injectMeta above
|
|
757
|
+
// already wrote them: passing '/' as the route path makes it emit exactly
|
|
758
|
+
// the home URL (the bare origin, no trailing slash -- the same form
|
|
759
|
+
// index.html, the sitemap home loc and usePageMeta use). The old explicit
|
|
760
|
+
// override here appended a trailing slash (#76), so the 404 was the one
|
|
761
|
+
// surface that disagreed with every other home-canonical writer; removing
|
|
762
|
+
// it leaves a single derivation.
|
|
763
|
+
// noindex -- 404 pages should not appear in search results. Strip any
|
|
764
|
+
// author's robots meta first (the shell may carry one), then anchor the
|
|
765
|
+
// noindex directive to `</head>` so it never depends on an optional
|
|
766
|
+
// neighbour tag: the old form anchored on `<meta name="author">`, which a
|
|
767
|
+
// shell lacking that tag (author not configured) silently skipped -- a
|
|
768
|
+
// 404 with NO noindex, invisible to a reader but still indexable.
|
|
441
769
|
html = html.replace(
|
|
442
|
-
/<meta\s+name="robots"\s+content="[^"]*"\s
|
|
770
|
+
/<meta\s+name="robots"\s+content="[^"\r\n]*"\s*\/?>/,
|
|
443
771
|
''
|
|
444
772
|
)
|
|
445
|
-
html = html.replace(
|
|
446
|
-
'<meta name="author"',
|
|
447
|
-
'<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex" />\n <meta name="author"'
|
|
448
|
-
)
|
|
773
|
+
html = html.replace('</head>', '<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex" />\n </head>')
|
|
449
774
|
// Strip the React bundle -- 404 is pure static HTML, no React needed.
|
|
450
775
|
// modulepreload must go too: vite emits one `<link rel=modulepreload>` per
|
|
451
776
|
// imported chunk next to the entry script, and a modulepreload link makes
|
|
452
777
|
// the browser PREFETCH the 160KB vendor bundle on a page whose whole
|
|
453
778
|
// contract is zero JS loads (caught by the 2026-10 perf/page-weight audit).
|
|
454
|
-
html = html.replace(/<script type="module"[
|
|
455
|
-
html = html.replace(/<link[
|
|
779
|
+
html = html.replace(/<script type="module"[^>\r\n]*><\/script>/, '')
|
|
780
|
+
html = html.replace(/<link[^>\r\n]*rel="modulepreload"[^>\r\n]*>/g, '')
|
|
456
781
|
|
|
457
782
|
return html
|
|
458
783
|
}
|
|
@@ -498,13 +823,116 @@ export function mergeRoutes(staticRoutes = [], fetchedRoutes) {
|
|
|
498
823
|
// Static routes keep both precedence (path collisions) and position
|
|
499
824
|
// (static first, CMS routes appended). A hook that resolves to nothing
|
|
500
825
|
// (null/undefined) degrades to the static list unchanged.
|
|
501
|
-
|
|
502
|
-
|
|
826
|
+
//
|
|
827
|
+
// Junk tolerance (#walk 2026-10): a non-object entry (null route, a bare
|
|
828
|
+
// string from a CMS mapping bug) used to crash the Map build with
|
|
829
|
+
// `Cannot read properties of undefined (reading 'path')` -- an obscure
|
|
830
|
+
// mid-build TypeError far from its cause. validate-config already flags
|
|
831
|
+
// these as diagnostics, but a non-STRICT build still reaches here, so
|
|
832
|
+
// the downstream posture is skip-LOUD per entry (never die silently,
|
|
833
|
+
// never take down the other valid routes with one bad row).
|
|
834
|
+
const out = new Map()
|
|
835
|
+
let junk = 0
|
|
836
|
+
const addRoute = (r, origin) => {
|
|
837
|
+
if (!r || typeof r !== 'object' || typeof r.path !== 'string') {
|
|
838
|
+
console.error(`[prerender] ${origin}: skipped non-route entry (expected { path, ... }, got ${r === null ? 'null' : typeof r}${typeof r === 'string' ? ": ''" + r.slice(0, 40) + "''" : ''})`)
|
|
839
|
+
junk++
|
|
840
|
+
return
|
|
841
|
+
}
|
|
503
842
|
if (!out.has(r.path)) out.set(r.path, r)
|
|
504
843
|
}
|
|
844
|
+
for (const r of (staticRoutes ?? [])) addRoute(r, 'routes')
|
|
845
|
+
for (const r of (fetchedRoutes ?? [])) addRoute(r, 'fetchRoutes()')
|
|
846
|
+
if (junk > 0) console.error(`[prerender] ${junk} junk route entr(y|ies) skipped -- fix ssr.config.js (or the fetchRoutes() hook)`)
|
|
505
847
|
return [...out.values()]
|
|
506
848
|
}
|
|
507
849
|
|
|
850
|
+
// react-router-dom v7 named-import interop (#29): v7 ships CJS, and vite's
|
|
851
|
+
// SSR graph externalizes node_modules entirely, so a HOST layout's
|
|
852
|
+
// `import { useLocation } from 'react-router-dom'` dies at link time
|
|
853
|
+
// ("Named export 'useLocation' not found") before any engine hook can
|
|
854
|
+
// intercede. Fix at import-analysis: a pre-transform rewrites the layout's
|
|
855
|
+
// named-import statement into the namespace-interop shape the scaffold
|
|
856
|
+
// itself uses (v6 ESM namespace or v7 CJS default), matching both majors.
|
|
857
|
+
/**
|
|
858
|
+
* Rewrite `import { A, B } from 'react-router-dom'` statements into the
|
|
859
|
+
* dual-major namespace interop form. Exported so the battery can pin the
|
|
860
|
+
* rewrite offline against both import spoken forms.
|
|
861
|
+
*
|
|
862
|
+
* @param {string} source
|
|
863
|
+
* @returns {string}
|
|
864
|
+
*/
|
|
865
|
+
export function rewriteRrdNamedImports(source) {
|
|
866
|
+
let n = 0
|
|
867
|
+
return source.replace(
|
|
868
|
+
/import\s+(?:([A-Za-z_$][\w$]*)\s*,\s*)?\{([^}]+)\}\s+from\s+(['"])react-router-dom\3/g,
|
|
869
|
+
(_m, defaultName, names, q) => {
|
|
870
|
+
const bindings = names.split(',').map(function (s) { return s.trim() }).filter(Boolean).join(', ')
|
|
871
|
+
const ns = `rrdInteropNs_${++n}`
|
|
872
|
+
const resolved = `${ns}.default ?? ${ns}`
|
|
873
|
+
// Default+named form (`import rrd, { useLocation }`) is rewritten too:
|
|
874
|
+
// the old regex required `{` immediately after `import `, so that shape
|
|
875
|
+
// passed through untouched and still died at link time on v7 CJS.
|
|
876
|
+
const defaultBinding = defaultName ? ` const ${defaultName} = ${resolved};` : ''
|
|
877
|
+
return `import * as ${ns} from ${q}react-router-dom${q};${defaultBinding} const { ${bindings} } = ${resolved}`
|
|
878
|
+
},
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Applied as a vite pre-plugin so the rewrite lands before import analysis.
|
|
883
|
+
const rrdInteropPlugin = {
|
|
884
|
+
name: 'prestruct-rrd-cjs-interop',
|
|
885
|
+
enforce: 'pre',
|
|
886
|
+
transform(code, id) {
|
|
887
|
+
if (id.includes('node_modules')) return null // never touch the externals
|
|
888
|
+
if (!code.includes('react-router-dom')) return null
|
|
889
|
+
const out = rewriteRrdNamedImports(code)
|
|
890
|
+
return out === code ? null : { code: out, map: null }
|
|
891
|
+
},
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// react-router ESM-twin redirect (prestruct#68): when a host sets
|
|
895
|
+
// `ssr.noExternal` (the documented way to keep the router in-process for the
|
|
896
|
+
// old consumer shim), Vite INLINES the package's CJS node entry
|
|
897
|
+
// (`dist/index.js` on v7, `dist/server.js`/`dist/main.js` on v6) into the SSR
|
|
898
|
+
// registry. The registry evaluates that CJS source as ESM -- `module`/
|
|
899
|
+
// `exports` are undefined -- so the load dies with `module is not defined`
|
|
900
|
+
// (v7) or `exports is not defined` (v6), 0/n pages rendered. Every router
|
|
901
|
+
// entry ships a sibling `.mjs` ESM twin; serving the twin's content for the
|
|
902
|
+
// CJS file URL keeps the registry a real ESM module. Externalized trees never
|
|
903
|
+
// hit this hook (Node loads the file itself), so the redirect is a no-op
|
|
904
|
+
// there -- it only rescues the inlined case, leaving the default shape alone.
|
|
905
|
+
/**
|
|
906
|
+
* Redirect react-router(-dom) CJS dist loads to their sibling `.mjs` ESM twin
|
|
907
|
+
* during the SSR pass. Returns null (honest pass-through) when the id is not a
|
|
908
|
+
* router CJS file or no twin exists.
|
|
909
|
+
*
|
|
910
|
+
* @param {string} id - module id Vite is loading
|
|
911
|
+
* @param {?{ssr?: boolean}} opts - Vite load options
|
|
912
|
+
* @returns {?string} ESM source, or null to pass through
|
|
913
|
+
*/
|
|
914
|
+
export function esmTwinSource(id, opts) {
|
|
915
|
+
if (!opts || opts.ssr !== true) return null
|
|
916
|
+
const clean = id.split('?')[0]
|
|
917
|
+
if (!/[\\/]node_modules[\\/]react-router(-dom)?[\\/]/.test(clean)) return null
|
|
918
|
+
if (!clean.endsWith('.js')) return null
|
|
919
|
+
const esm = clean.replace(/\.js$/, '.mjs')
|
|
920
|
+
try {
|
|
921
|
+
if (!fs.existsSync(esm)) return null
|
|
922
|
+
return fs.readFileSync(esm, 'utf8')
|
|
923
|
+
} catch {
|
|
924
|
+
return null
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const rrdEsmTwinPlugin = {
|
|
929
|
+
name: 'prestruct-rrd-esm-twin',
|
|
930
|
+
enforce: 'pre',
|
|
931
|
+
load(id, opts) {
|
|
932
|
+
return esmTwinSource(id, opts)
|
|
933
|
+
},
|
|
934
|
+
}
|
|
935
|
+
|
|
508
936
|
// StaticRouter loader: react-router-dom's exports map differs by version.
|
|
509
937
|
// v6 exposes the SSR entry as a './server.js' subpath; v7 re-exports
|
|
510
938
|
// StaticRouter from the package root. `importer` is injectable so the v7
|
|
@@ -518,12 +946,15 @@ export function mergeRoutes(staticRoutes = [], fetchedRoutes) {
|
|
|
518
946
|
* @throws when neither entry provides StaticRouter
|
|
519
947
|
*/
|
|
520
948
|
export async function loadStaticRouter(importer = (m) => import(m)) {
|
|
521
|
-
let
|
|
949
|
+
let mod
|
|
522
950
|
try {
|
|
523
|
-
|
|
951
|
+
mod = await importer('react-router-dom/server.js')
|
|
524
952
|
} catch {
|
|
525
|
-
|
|
953
|
+
mod = await importer('react-router-dom')
|
|
526
954
|
}
|
|
955
|
+
// Named export first; the namespace-interop `default` carries it when a CJS
|
|
956
|
+
// entry resolved without named bindings.
|
|
957
|
+
const StaticRouter = mod && (mod.StaticRouter || (mod.default && mod.default.StaticRouter))
|
|
527
958
|
if (!StaticRouter) {
|
|
528
959
|
throw new Error('[prerender] Could not load StaticRouter from react-router-dom -- is it installed?')
|
|
529
960
|
}
|
|
@@ -570,16 +1001,22 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
|
|
|
570
1001
|
const { createServer } = await import('vite')
|
|
571
1002
|
const { renderToString } = await import('react-dom/server')
|
|
572
1003
|
const React = (await import('react')).default
|
|
573
|
-
|
|
1004
|
+
|
|
1005
|
+
// Pre-server load: the fail-before-server path (vite missing, no config) and
|
|
1006
|
+
// the externalized default both land here. On react-router 6 this IS the
|
|
1007
|
+
// registry's instance (require()-shaped CJS), so it is also the correct one
|
|
1008
|
+
// to render with.
|
|
1009
|
+
let StaticRouter = await loadStaticRouter()
|
|
574
1010
|
|
|
575
1011
|
// Vite dev server in SSR mode.
|
|
576
1012
|
// ssrLoadModule resolves all imports through Vite's unified module registry,
|
|
577
1013
|
// guaranteeing a single react-router-dom instance. StaticRouter's location
|
|
578
1014
|
// context reaches useLocation() inside Routes correctly.
|
|
579
|
-
|
|
1015
|
+
const vite = await createServer({
|
|
580
1016
|
root: p.root,
|
|
581
1017
|
server: { middlewareMode: true },
|
|
582
1018
|
appType: 'custom',
|
|
1019
|
+
plugins: [rrdInteropPlugin, rrdEsmTwinPlugin],
|
|
583
1020
|
customLogger: {
|
|
584
1021
|
info: () => {},
|
|
585
1022
|
warn: (msg) => { if (!msg.includes('ExperimentalWarning')) process.stderr.write('[prerender:warn] ' + msg + '\n') },
|
|
@@ -594,6 +1031,22 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
|
|
|
594
1031
|
try {
|
|
595
1032
|
const { default: AppLayout } = await vite.ssrLoadModule(appLayoutPath)
|
|
596
1033
|
|
|
1034
|
+
// The seam (prestruct#68): a host that sets `ssr.noExternal` (the old
|
|
1035
|
+
// consumer-shim requirement) makes Vite INLINE react-router instead of
|
|
1036
|
+
// externalizing it, so the registry holds its OWN instance while the
|
|
1037
|
+
// pre-server `loadStaticRouter()` above holds Node's. On react-router 7
|
|
1038
|
+
// (real ESM) those are two module instances with two sets of React
|
|
1039
|
+
// contexts, and every route dies with "useLocation() may be used only in
|
|
1040
|
+
// the context of a <Router> component". Re-load StaticRouter through the
|
|
1041
|
+
// registry so the wrapper and the tree share one instance. The plain
|
|
1042
|
+
// import stays as the fallback when the registry cannot serve it.
|
|
1043
|
+
try {
|
|
1044
|
+
const registryStaticRouter = await loadStaticRouter((m) => vite.ssrLoadModule(m))
|
|
1045
|
+
if (registryStaticRouter) StaticRouter = registryStaticRouter
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
console.warn(`[prerender] registry StaticRouter unavailable (${err.message}) -- using the module-scope import`)
|
|
1048
|
+
}
|
|
1049
|
+
|
|
597
1050
|
// Validate dist/index.html exists (vite build may have failed)
|
|
598
1051
|
const indexPath = path.join(p.dist, 'index.html')
|
|
599
1052
|
if (!fs.existsSync(indexPath)) {
|
|
@@ -603,8 +1056,42 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
|
|
|
603
1056
|
|
|
604
1057
|
const shell = fs.readFileSync(indexPath, 'utf-8')
|
|
605
1058
|
let succeeded = 0
|
|
1059
|
+
let cacheHits = 0
|
|
606
1060
|
const failures = []
|
|
607
1061
|
const cache = readCache({ cacheFile: p.cacheFile })
|
|
1062
|
+
// Honest per-route sitemap dates: consumed by generateSitemap's <lastmod>.
|
|
1063
|
+
const routeMtimes = new Map()
|
|
1064
|
+
// Per-route OG image generation (#43): resolved once per run, rendered
|
|
1065
|
+
// for EVERY route in the set before the render loop -- a cache-hit route
|
|
1066
|
+
// must still ship its (new-template) PNG, or a vite-cleared dist would
|
|
1067
|
+
// carry head tags pointing at a missing file. Routes with their own
|
|
1068
|
+
// og:image override keep theirs (missing-only).
|
|
1069
|
+
const ogImageGeneration = resolveOgImageGeneration(cfg, p.root)
|
|
1070
|
+
// The config-gated head additions that rendered HTML depends on: carried
|
|
1071
|
+
// into the incremental cache signature (QC sweep: flipping these on
|
|
1072
|
+
// between builds used to ride a valid-cache hit and never reach the
|
|
1073
|
+
// shipped head). Route-level alternates/jsonLd are already covered
|
|
1074
|
+
// via route.meta in the base signature.
|
|
1075
|
+
const headExtras = buildHeadExtras(cfg, ogImageGeneration)
|
|
1076
|
+
const routeOgOverrides = new Map()
|
|
1077
|
+
if (ogImageGeneration) {
|
|
1078
|
+
// Slug collision guard: imageSlug flattens '/' to '-', so '/blog/post-1'
|
|
1079
|
+
// and '/blog-post-1' both map to 'blog-post-1.png'. Without this, the
|
|
1080
|
+
// second render silently OVERWRITES the first's PNG while both pages
|
|
1081
|
+
// point at the same file -- two routes, one image, no error. Surface it
|
|
1082
|
+
// loudly (author picks distinct paths, or an og:image override).
|
|
1083
|
+
const slugSeen = new Map()
|
|
1084
|
+
for (const route of ROUTES) {
|
|
1085
|
+
if (route && route.meta && route.meta.ogImage) continue
|
|
1086
|
+
const slug = imageSlug(route.path)
|
|
1087
|
+
if (slugSeen.has(slug) && slugSeen.get(slug) !== route.path) {
|
|
1088
|
+
console.warn(`[prerender] og image slug collision: "${route.path}" and "${slugSeen.get(slug)}" both map to og/${slug}.png -- the generated image is shared; set a distinct og:image on one route`)
|
|
1089
|
+
}
|
|
1090
|
+
slugSeen.set(slug, route.path)
|
|
1091
|
+
const generated = renderRouteOgImage(ogImageGeneration, route, siteUrl, p.dist)
|
|
1092
|
+
if (generated) routeOgOverrides.set(route.path, `${siteUrl.replace(/\/$/, '')}${generated.src}`)
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
608
1095
|
|
|
609
1096
|
console.log(`[prerender] Cache: ${Object.keys(cache).length} routes cached${force ? ' (forcing rebuild)' : ''}`)
|
|
610
1097
|
|
|
@@ -626,10 +1113,11 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
|
|
|
626
1113
|
// path-keyed check rewrote stale HTML over fresh builds whenever the
|
|
627
1114
|
// shell or route meta changed. cacheEntryValid sees signatures over
|
|
628
1115
|
// every rendered input; legacy entries (no sig) render once.
|
|
629
|
-
if (!force && cacheEntryValid(cache[route.path], route, siteUrl, shell)) {
|
|
1116
|
+
if (!force && cacheEntryValid(cache[route.path], route, siteUrl, shell, headExtras)) {
|
|
630
1117
|
fs.mkdirSync(path.dirname(distPath), { recursive: true })
|
|
631
|
-
|
|
1118
|
+
writeRouteFile(distPath, cache[route.path].html, routeMtimes, route.path)
|
|
632
1119
|
console.log(`[prerender] cached ${route.path}`)
|
|
1120
|
+
cacheHits++
|
|
633
1121
|
succeeded++
|
|
634
1122
|
continue
|
|
635
1123
|
}
|
|
@@ -646,13 +1134,15 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
|
|
|
646
1134
|
|
|
647
1135
|
// Attribute-tolerant root-div injection (carries inject-brand attrs)
|
|
648
1136
|
let html = replaceRootDiv(shell, appHtml)
|
|
649
|
-
|
|
1137
|
+
// Per-route branded og:image when generated (#43); global otherwise.
|
|
1138
|
+
const routeOg = routeOgOverrides.get(route.path) || cfg.ogImage
|
|
1139
|
+
html = injectMeta(html, route.meta || {}, route.path, siteUrl, routeOg, headExtras)
|
|
650
1140
|
|
|
651
1141
|
// Save to cache with a signature over exactly these rendered inputs
|
|
652
|
-
cache[route.path] = { html, time: Date.now(), sig: cacheSignature(route, siteUrl, shell) }
|
|
1142
|
+
cache[route.path] = { html, time: Date.now(), sig: cacheSignature(route, siteUrl, shell, headExtras) }
|
|
653
1143
|
|
|
654
1144
|
fs.mkdirSync(path.dirname(distPath), { recursive: true })
|
|
655
|
-
|
|
1145
|
+
writeRouteFile(distPath, html, routeMtimes, route.path)
|
|
656
1146
|
|
|
657
1147
|
console.log(`[prerender] ok ${route.path}`)
|
|
658
1148
|
succeeded++
|
|
@@ -669,8 +1159,11 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
|
|
|
669
1159
|
fs.writeFileSync(path.join(p.dist, '404.html'), generate404(shell, cfg || config, siteUrl), 'utf-8')
|
|
670
1160
|
console.log('[prerender] ok /404.html')
|
|
671
1161
|
|
|
672
|
-
// sitemap.xml -- overwrites the static one in public
|
|
673
|
-
fs.writeFileSync(path.join(p.dist, 'sitemap.xml'), generateSitemap(ROUTES, siteUrl), 'utf-8')
|
|
1162
|
+
// sitemap.xml -- overwrites the static one in public/, honest lastmods
|
|
1163
|
+
fs.writeFileSync(path.join(p.dist, 'sitemap.xml'), generateSitemap(ROUTES, siteUrl, routeMtimes), 'utf-8')
|
|
1164
|
+
|
|
1165
|
+
// llms.txt -- generated-only after the sitemap (same baked route set)
|
|
1166
|
+
generateLlms(ROUTES, cfg, p.dist)
|
|
674
1167
|
console.log('[prerender] ok /sitemap.xml')
|
|
675
1168
|
|
|
676
1169
|
// robots.txt -- opt-in only (config.robots): without it the author-owned
|
|
@@ -691,7 +1184,7 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
|
|
|
691
1184
|
const stats = {
|
|
692
1185
|
routeCount: ROUTES.length,
|
|
693
1186
|
succeeded,
|
|
694
|
-
cached:
|
|
1187
|
+
cached: cacheHits,
|
|
695
1188
|
force,
|
|
696
1189
|
routes: ROUTES.map(r => ({ path: r.path, cached: Boolean(cache[r.path]) })),
|
|
697
1190
|
}
|
|
@@ -704,7 +1197,7 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
|
|
|
704
1197
|
// decide the exit code. Live-fire (2026-10): every prerendering route
|
|
705
1198
|
// is a silent-empty-page site; prerender used to end on a green-ok line while
|
|
706
1199
|
// the routes errorred out. Return, never process.exit: library-safety.
|
|
707
|
-
return { total: ROUTES.length, succeeded, cached:
|
|
1200
|
+
return { total: ROUTES.length, succeeded, cached: cacheHits, failed: failures.length, failures }
|
|
708
1201
|
|
|
709
1202
|
} finally {
|
|
710
1203
|
await vite.close()
|