prestruct 1.0.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)
153
162
  }
154
163
 
155
- // -- Root-div injection (W8 fix) -----------------------------------------------
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
+ }
198
+ }
199
+
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
@@ -272,6 +527,30 @@ export function cleanCache({ cacheFile = enginePaths().cacheFile } = {}) {
272
527
 
273
528
  // -- Sitemap -------------------------------------------------------------------
274
529
 
530
+ //
531
+ // AI-crawler policy preset (aeo option):
532
+ // Search-visibility bots get explicit Allow groups; TRAINING-crawl preference
533
+ // is a separate choice via config.robots.aiBots ('allow' | 'block', default
534
+ // 'block'). Bot names are the token each vendor documents for its own robots
535
+ // product (2026: OpenAI OAI-SearchBot/GPTBot, Anthropic
536
+ // Claude-SearchBot/ClaudeBot, PerplexityBot, Apple Applebot/Applebot-Extended,
537
+ // Common Crawl CCBot, Google-Extended)
538
+ const AEO_SEARCH_BOTS = [
539
+ 'OAI-SearchBot', // OpenAI: surfaces sites in ChatGPT search
540
+ 'PerplexityBot', // Perplexity: search index, not model training
541
+ 'Claude-SearchBot', // Anthropic: Claude's search
542
+ 'Applebot', // Apple: Siri/Spotlight/Safari search
543
+ 'Googlebot', // Google Search incl. AI Overviews eligibility
544
+ 'Bingbot', // Bing / Copilot discovery
545
+ ]
546
+ const AEO_TRAINING_BOTS = [
547
+ 'GPTBot', // OpenAI: model training crawl
548
+ 'ClaudeBot', // Anthropic: model development crawl
549
+ 'Google-Extended', // Gemini/Vertex grounding (product token, no HTTP hits)
550
+ 'Applebot-Extended', // Apple: foundation model training
551
+ 'CCBot', // Common Crawl: open corpus crawlers
552
+ ]
553
+
275
554
  /**
276
555
  * Generate robots.txt when config.robots is opted in; otherwise null so the
277
556
  * author's own public/robots.txt is left untouched. The Sitemap: line points
@@ -290,16 +569,25 @@ export function generateRobots(config) {
290
569
  const cfgBody = typeof cfg === 'string' ? cfg : (cfg.body || '')
291
570
  const cfgSitemap = cfgBool || (cfg && typeof cfg === 'object' ? cfg.sitemap !== false : true)
292
571
  const siteUrl = (config.siteUrl || '').replace(/\/$/, '')
293
-
294
- if (cfgBool) {
295
- const lines = [
296
- 'User-agent: *',
297
- 'Allow: /',
298
- ]
299
- if (cfgSitemap && siteUrl) {
300
- lines.push('', `Sitemap: ${siteUrl}/sitemap.xml`)
572
+ const sitemapLine = cfgSitemap && siteUrl ? ['', `Sitemap: ${siteUrl}/sitemap.xml`] : []
573
+
574
+ // { aeo: true | {} } -> search bots allowed, training crawls blocked by
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).
578
+ const aeo = !cfgBool && cfg && cfg.aeo
579
+ const aeoCli = aeo ? (aeo.aiBots === 'allow' ? 'allow' : 'block') : null
580
+
581
+ if (cfgBool || aeo) {
582
+ const lines = []
583
+ if (cfgBool) {
584
+ lines.push('User-agent: *', 'Allow: /')
585
+ } else {
586
+ for (const bot of AEO_SEARCH_BOTS) lines.push(`User-agent: ${bot}`, 'Allow: /', '')
587
+ const verdict = aeoCli === 'allow' ? 'Allow: /' : 'Disallow: /'
588
+ for (const bot of AEO_TRAINING_BOTS) lines.push(`User-agent: ${bot}`, verdict, '')
301
589
  }
302
- return lines.join('\n') + '\n'
590
+ return lines.concat(sitemapLine).join('\n').replace(/\n+$/, '\n')
303
591
  }
304
592
 
305
593
  // Custom body; sitemap pointer appended unless { sitemap: false }
@@ -324,26 +612,78 @@ export function escapeXml(unsafe) {
324
612
  .replace(/'/g, '&apos;')
325
613
  }
326
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
+
327
640
  /**
328
641
  * Build sitemap.xml from the merged route list, XML-escaped, no trailing
329
642
  * slash on the home URL.
330
643
  *
331
644
  * @param {Array<{path: string}>} routes - merged route definitions
332
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
333
649
  * @returns {string} sitemap.xml contents
334
650
  */
335
- export function generateSitemap(routes, siteUrl) {
336
- const now = new Date().toISOString().split('T')[0]
337
- const urls = routes.map(r => `
338
- <url>
339
- <loc>${escapeXml(siteUrl)}${r.path === '/' ? '' : escapeXml(r.path)}</loc>
340
- <lastmod>${now}</lastmod>
341
- <changefreq>${escapeXml(r.changefreq || 'monthly')}</changefreq>
342
- <priority>${escapeXml(r.priority || '0.5')}</priority>
343
- </url>`).join('')
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('')
344
663
  return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}\n</urlset>`
345
664
  }
346
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
+
347
687
  // -- 404 page ------------------------------------------------------------------
348
688
  // Uses id="root-404" (not "root") so main.jsx does NOT trigger hydrateRoot.
349
689
  // The React bundle script tag is stripped -- no JS loads at all on 404 pages.
@@ -367,9 +707,22 @@ export function generate404(shell, config, siteUrl) {
367
707
  const heading = escapeXml(notFoundConfig.heading || 'Page not found.')
368
708
  const body = escapeXml(notFoundConfig.body || "That page doesn't exist -- or it moved.")
369
709
  const primaryLabel = escapeXml(notFoundConfig.primaryCta?.label || 'Go home')
370
- const primaryHref = escapeXml(notFoundConfig.primaryCta?.href || '/')
371
- const siteName = escapeXml(config.siteName || 'Site')
372
- const v = escapeReplacement
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'
373
726
 
374
727
  const bodyLines = [
375
728
  '<div id="root-404">',
@@ -384,42 +737,47 @@ export function generate404(shell, config, siteUrl) {
384
737
  '</div>',
385
738
  ]
386
739
 
387
- // Function replacer: bodyLines carry user text; string-form replacement
388
- // would expand $-sequences (same trap as replaceRootDiv).
389
- let html = shell.replace(/<div id="root"[^>]*><\/div>/, () => bodyLines.join('\n'))
390
- html = html.replace(/<title>[^<]*<\/title>/, () => `<title>Page Not Found | ${siteName}</title>`)
391
- html = html.replace(
392
- /(<meta\s+name="description"\s+content=")[^"]*(")/s,
393
- () => `$1The page you were looking for does not exist.$2`
394
- )
395
- html = html.replace(
396
- /(<meta\s+property="og:title"\s+content=")[^"]*(")/s,
397
- () => `$1Page Not Found | ${siteName}$2`
398
- )
399
- html = html.replace(
400
- /(<meta\s+name="twitter:title"\s+content=")[^"]*(")/s,
401
- () => `$1Page Not Found | ${siteName}$2`
402
- )
403
- html = html.replace(
404
- /(<link\s+rel="canonical"\s+href=")[^"]*(")/s,
405
- () => `$1${v(siteUrl)}/$2`
406
- )
407
- // noindex -- 404 pages should not appear in search results
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.
408
769
  html = html.replace(
409
- /<meta\s+name="robots"\s+content="[^"]*"\s*\/?>/s,
770
+ /<meta\s+name="robots"\s+content="[^"\r\n]*"\s*\/?>/,
410
771
  ''
411
772
  )
412
- html = html.replace(
413
- '<meta name="author"',
414
- '<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex" />\n <meta name="author"'
415
- )
773
+ html = html.replace('</head>', '<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex" />\n </head>')
416
774
  // Strip the React bundle -- 404 is pure static HTML, no React needed.
417
775
  // modulepreload must go too: vite emits one `<link rel=modulepreload>` per
418
776
  // imported chunk next to the entry script, and a modulepreload link makes
419
777
  // the browser PREFETCH the 160KB vendor bundle on a page whose whole
420
778
  // contract is zero JS loads (caught by the 2026-10 perf/page-weight audit).
421
- html = html.replace(/<script type="module"[^>]*><\/script>/, '')
422
- html = html.replace(/<link[^>]*rel="modulepreload"[^>]*>/g, '')
779
+ html = html.replace(/<script type="module"[^>\r\n]*><\/script>/, '')
780
+ html = html.replace(/<link[^>\r\n]*rel="modulepreload"[^>\r\n]*>/g, '')
423
781
 
424
782
  return html
425
783
  }
@@ -465,13 +823,116 @@ export function mergeRoutes(staticRoutes = [], fetchedRoutes) {
465
823
  // Static routes keep both precedence (path collisions) and position
466
824
  // (static first, CMS routes appended). A hook that resolves to nothing
467
825
  // (null/undefined) degrades to the static list unchanged.
468
- const out = new Map((staticRoutes ?? []).map(r => [r.path, r]))
469
- for (const r of fetchedRoutes ?? []) {
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
+ }
470
842
  if (!out.has(r.path)) out.set(r.path, r)
471
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)`)
472
847
  return [...out.values()]
473
848
  }
474
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
+
475
936
  // StaticRouter loader: react-router-dom's exports map differs by version.
476
937
  // v6 exposes the SSR entry as a './server.js' subpath; v7 re-exports
477
938
  // StaticRouter from the package root. `importer` is injectable so the v7
@@ -485,12 +946,15 @@ export function mergeRoutes(staticRoutes = [], fetchedRoutes) {
485
946
  * @throws when neither entry provides StaticRouter
486
947
  */
487
948
  export async function loadStaticRouter(importer = (m) => import(m)) {
488
- let StaticRouter
949
+ let mod
489
950
  try {
490
- ;({ StaticRouter } = await importer('react-router-dom/server.js'))
951
+ mod = await importer('react-router-dom/server.js')
491
952
  } catch {
492
- ;({ StaticRouter } = await importer('react-router-dom'))
953
+ mod = await importer('react-router-dom')
493
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))
494
958
  if (!StaticRouter) {
495
959
  throw new Error('[prerender] Could not load StaticRouter from react-router-dom -- is it installed?')
496
960
  }
@@ -537,16 +1001,22 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
537
1001
  const { createServer } = await import('vite')
538
1002
  const { renderToString } = await import('react-dom/server')
539
1003
  const React = (await import('react')).default
540
- 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()
541
1010
 
542
1011
  // Vite dev server in SSR mode.
543
1012
  // ssrLoadModule resolves all imports through Vite's unified module registry,
544
1013
  // guaranteeing a single react-router-dom instance. StaticRouter's location
545
1014
  // context reaches useLocation() inside Routes correctly.
546
- const vite = await createServer({
1015
+ const vite = await createServer({
547
1016
  root: p.root,
548
1017
  server: { middlewareMode: true },
549
1018
  appType: 'custom',
1019
+ plugins: [rrdInteropPlugin, rrdEsmTwinPlugin],
550
1020
  customLogger: {
551
1021
  info: () => {},
552
1022
  warn: (msg) => { if (!msg.includes('ExperimentalWarning')) process.stderr.write('[prerender:warn] ' + msg + '\n') },
@@ -561,6 +1031,22 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
561
1031
  try {
562
1032
  const { default: AppLayout } = await vite.ssrLoadModule(appLayoutPath)
563
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
+
564
1050
  // Validate dist/index.html exists (vite build may have failed)
565
1051
  const indexPath = path.join(p.dist, 'index.html')
566
1052
  if (!fs.existsSync(indexPath)) {
@@ -570,8 +1056,42 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
570
1056
 
571
1057
  const shell = fs.readFileSync(indexPath, 'utf-8')
572
1058
  let succeeded = 0
1059
+ let cacheHits = 0
573
1060
  const failures = []
574
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
+ }
575
1095
 
576
1096
  console.log(`[prerender] Cache: ${Object.keys(cache).length} routes cached${force ? ' (forcing rebuild)' : ''}`)
577
1097
 
@@ -593,10 +1113,11 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
593
1113
  // path-keyed check rewrote stale HTML over fresh builds whenever the
594
1114
  // shell or route meta changed. cacheEntryValid sees signatures over
595
1115
  // every rendered input; legacy entries (no sig) render once.
596
- if (!force && cacheEntryValid(cache[route.path], route, siteUrl, shell)) {
1116
+ if (!force && cacheEntryValid(cache[route.path], route, siteUrl, shell, headExtras)) {
597
1117
  fs.mkdirSync(path.dirname(distPath), { recursive: true })
598
- fs.writeFileSync(distPath, cache[route.path].html, 'utf-8')
1118
+ writeRouteFile(distPath, cache[route.path].html, routeMtimes, route.path)
599
1119
  console.log(`[prerender] cached ${route.path}`)
1120
+ cacheHits++
600
1121
  succeeded++
601
1122
  continue
602
1123
  }
@@ -613,13 +1134,15 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
613
1134
 
614
1135
  // Attribute-tolerant root-div injection (carries inject-brand attrs)
615
1136
  let html = replaceRootDiv(shell, appHtml)
616
- 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)
617
1140
 
618
1141
  // Save to cache with a signature over exactly these rendered inputs
619
- 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) }
620
1143
 
621
1144
  fs.mkdirSync(path.dirname(distPath), { recursive: true })
622
- fs.writeFileSync(distPath, html, 'utf-8')
1145
+ writeRouteFile(distPath, html, routeMtimes, route.path)
623
1146
 
624
1147
  console.log(`[prerender] ok ${route.path}`)
625
1148
  succeeded++
@@ -636,8 +1159,11 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
636
1159
  fs.writeFileSync(path.join(p.dist, '404.html'), generate404(shell, cfg || config, siteUrl), 'utf-8')
637
1160
  console.log('[prerender] ok /404.html')
638
1161
 
639
- // sitemap.xml -- overwrites the static one in public/ with today's date
640
- fs.writeFileSync(path.join(p.dist, 'sitemap.xml'), generateSitemap(ROUTES, siteUrl), 'utf-8')
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)
641
1167
  console.log('[prerender] ok /sitemap.xml')
642
1168
 
643
1169
  // robots.txt -- opt-in only (config.robots): without it the author-owned
@@ -649,19 +1175,21 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
649
1175
  console.log('[prerender] ok /robots.txt')
650
1176
  }
651
1177
 
652
- // Cache stats -- static-sites-safe monitor: a JSON snapshot beside dist/
653
- // (Intentionally not a runtime endpoint; static hosts serve files, not
654
- // processes. Follows the PRD "static-sites-safe option preferred" note.)
1178
+ // Cache stats -- static-sites-safe monitor: a JSON snapshot in the
1179
+ // project's .prestruct/ dir, NOT dist/. Build telemetry must not become a
1180
+ // deployed URL (issue 23): dist/ is the public artifact on every static
1181
+ // host, and the old wall-clock generatedAt made two byte-identical
1182
+ // builds differ. Same monitor, invisible to deploys, no opt-out needed
1183
+ // because there is nothing to opt out of shipping.
655
1184
  const stats = {
656
- generatedAt: new Date().toISOString(),
657
1185
  routeCount: ROUTES.length,
658
1186
  succeeded,
659
- cached: Object.keys(cache).length,
1187
+ cached: cacheHits,
660
1188
  force,
661
1189
  routes: ROUTES.map(r => ({ path: r.path, cached: Boolean(cache[r.path]) })),
662
1190
  }
663
- fs.mkdirSync(path.join(p.dist, '.prestruct'), { recursive: true })
664
- fs.writeFileSync(path.join(p.dist, '.prestruct', 'status.json'), JSON.stringify(stats, null, 2), 'utf-8')
1191
+ fs.mkdirSync(path.join(p.cacheDir, '..'), { recursive: true })
1192
+ fs.writeFileSync(path.join(p.root, '.prestruct', 'status.json'), JSON.stringify(stats, null, 2), 'utf-8')
665
1193
 
666
1194
  console.log(`[prerender] Done. ${succeeded}/${ROUTES.length} pages rendered.\n`)
667
1195
 
@@ -669,7 +1197,7 @@ export async function prerender(cfg = config, { force = false, root } = {}) {
669
1197
  // decide the exit code. Live-fire (2026-10): every prerendering route
670
1198
  // is a silent-empty-page site; prerender used to end on a green-ok line while
671
1199
  // the routes errorred out. Return, never process.exit: library-safety.
672
- 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 }
673
1201
 
674
1202
  } finally {
675
1203
  await vite.close()