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.
@@ -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
- * generateSitemap(routes, siteUrl)
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. Uses a function replacer so $-shaped
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 match = shell.match(/<div id="root"([^>]*)><\/div>/)
173
- if (!match) {
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 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
- )
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
- export function injectMeta(html, meta, routePath, siteUrl, globalOgImage) {
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
- const ogImage = escapeReplacement(escapeHtml(meta.ogImage || globalOgImage || ''))
206
- const url = escapeReplacement(escapeHtml(`${siteUrl}${routePath === '/' ? '' : routePath}`))
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=")[^"]*(")/s, `$1${title}$2`)
211
- html = html.replace(/(<meta\s+name="twitter:title"\s+content=")[^"]*(")/s, `$1${title}$2`)
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=")[^"]*(")/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`)
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=")[^"]*(")/s, `$1${url}$2`)
219
- html = html.replace(/(<link\s+rel="canonical"\s+href=")[^"]*(")/s, `$1${url}$2`)
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=")[^"]*(")/s, `$1${ogImage}$2`)
222
- html = html.replace(/(<meta\s+name="twitter:image"\s+content=")[^"]*(")/s, `$1${ogImage}$2`)
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 with
321
- // an explicit body: the body is appended after the preset groups (letting
322
- // owners add extra paths) only via body; the preset itself ignores body.
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, '&apos;')
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 now = new Date().toISOString().split('T')[0]
370
- const urls = routes.map(r => `
371
- <url>
372
- <loc>${escapeXml(siteUrl)}${r.path === '/' ? '' : escapeXml(r.path)}</loc>
373
- <lastmod>${now}</lastmod>
374
- <changefreq>${escapeXml(r.changefreq || 'monthly')}</changefreq>
375
- <priority>${escapeXml(r.priority || '0.5')}</priority>
376
- </url>`).join('')
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
- const primaryHref = escapeXml(notFoundConfig.primaryCta?.href || '/')
404
- const siteName = escapeXml(config.siteName || 'Site')
405
- const v = escapeReplacement
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;amp; Jerry&amp;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
- // Function replacer: bodyLines carry user text; string-form replacement
421
- // would expand $-sequences (same trap as replaceRootDiv).
422
- let html = shell.replace(/<div id="root"[^>]*><\/div>/, () => bodyLines.join('\n'))
423
- html = html.replace(/<title>[^<]*<\/title>/, () => `<title>Page Not Found | ${siteName}</title>`)
424
- html = html.replace(
425
- /(<meta\s+name="description"\s+content=")[^"]*(")/s,
426
- () => `$1The page you were looking for does not exist.$2`
427
- )
428
- html = html.replace(
429
- /(<meta\s+property="og:title"\s+content=")[^"]*(")/s,
430
- () => `$1Page Not Found | ${siteName}$2`
431
- )
432
- html = html.replace(
433
- /(<meta\s+name="twitter:title"\s+content=")[^"]*(")/s,
434
- () => `$1Page Not Found | ${siteName}$2`
435
- )
436
- html = html.replace(
437
- /(<link\s+rel="canonical"\s+href=")[^"]*(")/s,
438
- () => `$1${v(siteUrl)}/$2`
439
- )
440
- // noindex -- 404 pages should not appear in search results
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*\/?>/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"[^>]*><\/script>/, '')
455
- html = html.replace(/<link[^>]*rel="modulepreload"[^>]*>/g, '')
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
- const out = new Map((staticRoutes ?? []).map(r => [r.path, r]))
502
- for (const r of fetchedRoutes ?? []) {
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 StaticRouter
949
+ let mod
522
950
  try {
523
- ;({ StaticRouter } = await importer('react-router-dom/server.js'))
951
+ mod = await importer('react-router-dom/server.js')
524
952
  } catch {
525
- ;({ StaticRouter } = await importer('react-router-dom'))
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
- const StaticRouter = await loadStaticRouter()
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
- const vite = await createServer({
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
- fs.writeFileSync(distPath, cache[route.path].html, 'utf-8')
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
- html = injectMeta(html, route.meta || {}, route.path, siteUrl, cfg.ogImage)
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
- fs.writeFileSync(distPath, html, 'utf-8')
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/ with today's date
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: Object.keys(cache).length,
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: ROUTES.length - succeeded, failed: failures.length, failures }
1200
+ return { total: ROUTES.length, succeeded, cached: cacheHits, failed: failures.length, failures }
708
1201
 
709
1202
  } finally {
710
1203
  await vite.close()