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.
- package/README.md +37 -46
- package/bin/cli.mjs +218 -10
- package/engine/scripts/audit.js +530 -0
- package/engine/scripts/hooks.js +93 -0
- package/engine/scripts/indexnow.js +139 -0
- package/engine/scripts/inject-brand.js +112 -10
- package/engine/scripts/ogimage.js +165 -0
- package/engine/scripts/prerender.js +632 -104
- package/engine/scripts/validate-config.js +103 -1
- package/engine/src/hooks/usePageMeta.js +11 -2
- package/engine/src/islands.js +23 -4
- package/engine/ssr.config.example.js +196 -0
- package/init/VERSION +1 -1
- package/init/ecosystem.cjs +1 -1
- package/init/package-lock.json +375 -407
- package/init/package.json +14 -9
- package/init/scripts/audit-if-configured.cjs +26 -0
- package/init/scripts/audit.js +530 -0
- package/init/scripts/hooks.js +93 -0
- package/init/scripts/indexnow.js +139 -0
- package/init/scripts/inject-brand.js +112 -10
- package/init/scripts/ogimage.js +165 -0
- package/init/scripts/prerender.js +632 -104
- package/init/scripts/proxy.js +100 -17
- package/init/scripts/proxy.worker.js +150 -33
- package/init/scripts/validate-config.js +103 -1
- package/init/src/App.jsx +7 -1
- package/init/src/AppLayout.jsx +8 -1
- package/init/src/hooks/usePageMeta.js +11 -2
- package/init/src/islands.js +23 -4
- package/init/ssr.config.example.js +45 -0
- package/package.json +24 -10
|
@@ -18,9 +18,13 @@
|
|
|
18
18
|
* Module surface (importable for tests; CLI logic only runs when executed):
|
|
19
19
|
* replaceRootDiv(shell, appHtml) -- root-div injector, attribute-tolerant
|
|
20
20
|
* injectMeta(html, meta, routePath, siteUrl, ogImage)
|
|
21
|
-
*
|
|
21
|
+
* assertXmlSafe(value, field) -- XML escape + control-char loud-fail
|
|
22
|
+
* generateSitemap(routes, siteUrl, routeMtimes?)
|
|
22
23
|
* generate404(shell, config, siteUrl)
|
|
23
24
|
* readCache() / writeCache(cache) / cleanCache()
|
|
25
|
+
* rewriteRrdNamedImports(source) -- react-router named-import interop rewrite
|
|
26
|
+
* esmTwinSource(id, opts) -- react-router CJS -> ESM twin redirect (#68)
|
|
27
|
+
* loadStaticRouter(importer?) -- StaticRouter across the v6/v7 peer range
|
|
24
28
|
* prerender() -- full pipeline
|
|
25
29
|
*
|
|
26
30
|
* Output (for a 3-route app with /, /about, /contact):
|
|
@@ -40,6 +44,7 @@ import crypto from 'crypto'
|
|
|
40
44
|
import { fileURLToPath, pathToFileURL } from 'url'
|
|
41
45
|
|
|
42
46
|
import { validateConfig, formatDiagnostics } from './validate-config.js'
|
|
47
|
+
import { resolveOgImageGeneration, renderRouteOgImage, imageSlug } from './ogimage.js'
|
|
43
48
|
|
|
44
49
|
// Root-package re-export: `import { defineConfig } from 'prestruct'`
|
|
45
50
|
// (binding stays out of this file's scope to avoid an unused-import)
|
|
@@ -112,8 +117,6 @@ export function escapeHtml(s) {
|
|
|
112
117
|
// as stale and get rerendered once, then upgraded in place.
|
|
113
118
|
//
|
|
114
119
|
// The helper is exported (not inlined in the loop) so the battery can pin
|
|
115
|
-
// the stale-path without running the whole vite pipeline.
|
|
116
|
-
// The helper is exported (not inlined in the loop) so the battery can pin
|
|
117
120
|
// the stale path without running the whole vite pipeline.
|
|
118
121
|
/**
|
|
119
122
|
* Compute the cache signature for a route render. Covers every input whose
|
|
@@ -124,13 +127,19 @@ export function escapeHtml(s) {
|
|
|
124
127
|
* @param {string} shell - dist/index.html contents
|
|
125
128
|
* @returns {string} stable hash signature
|
|
126
129
|
*/
|
|
127
|
-
export function cacheSignature(route, siteUrl, shell) {
|
|
130
|
+
export function cacheSignature(route, siteUrl, shell, extras = null) {
|
|
128
131
|
return crypto.createHash('sha256').update(JSON.stringify({
|
|
129
132
|
path: route.path,
|
|
130
133
|
siteUrl,
|
|
131
134
|
meta: route.meta ?? null,
|
|
132
135
|
ogImage: route.ogImage ?? null,
|
|
133
136
|
shell,
|
|
137
|
+
// Config-gated head additions (the 2026-10 ride-along set): a host that
|
|
138
|
+
// flips feed / hreflang / verification / searchUrl on between builds
|
|
139
|
+
// must re-render the routes that carry them. The QC sweep caught the
|
|
140
|
+
// signature covering only the shell-side inputs, so newly enabled
|
|
141
|
+
// extras rode a valid-cache hit and never reached the shipped head.
|
|
142
|
+
extras: extras ?? null,
|
|
134
143
|
})).digest('hex')
|
|
135
144
|
}
|
|
136
145
|
|
|
@@ -144,24 +153,92 @@ export function cacheSignature(route, siteUrl, shell) {
|
|
|
144
153
|
* @param {string} shell - current dist/index.html contents
|
|
145
154
|
* @returns {boolean} true when the cache may be reused
|
|
146
155
|
*/
|
|
147
|
-
export function cacheEntryValid(entry, route, siteUrl, shell) {
|
|
156
|
+
export function cacheEntryValid(entry, route, siteUrl, shell, extras = null) {
|
|
148
157
|
if (!entry || typeof entry !== 'object' || typeof entry.html !== 'string') return false
|
|
149
158
|
// Legacy entries predate signatures: treat as stale once, rerender, and
|
|
150
159
|
// the write below upgrades them in place.
|
|
151
160
|
if (!entry.sig) return false
|
|
152
|
-
return entry.sig === cacheSignature(route, siteUrl, shell)
|
|
161
|
+
return entry.sig === cacheSignature(route, siteUrl, shell, extras)
|
|
153
162
|
}
|
|
154
163
|
|
|
155
|
-
|
|
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.
|
|
164
|
-
* content in appHtml stays literal.
|
|
241
|
+
* carrying the prerendered markup.
|
|
165
242
|
*
|
|
166
243
|
* @param {string} shell - dist/index.html contents
|
|
167
244
|
* @param {string} appHtml - renderToString output for the route
|
|
@@ -169,20 +246,30 @@ export function cacheEntryValid(entry, route, siteUrl, shell) {
|
|
|
169
246
|
* @throws when the shell has no root div
|
|
170
247
|
*/
|
|
171
248
|
export function replaceRootDiv(shell, appHtml) {
|
|
172
|
-
const
|
|
173
|
-
if (!
|
|
249
|
+
const span = findRootDivSpan(shell)
|
|
250
|
+
if (!span) {
|
|
174
251
|
throw new Error('<div id="root"></div> not found in shell -- check index.html')
|
|
175
252
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
253
|
+
const closeIdx = shell.indexOf('>', span[0]) + 1
|
|
254
|
+
// Carry the open tag through UNCHANGED except for the render stamp: drop any
|
|
255
|
+
// stamp a previous pass wrote (so re-entry cannot stack duplicate stamps),
|
|
256
|
+
// then append one. Preserving the original open tag verbatim is what makes
|
|
257
|
+
// this attribute-tolerant -- the earlier form sliced attrs at the fixed
|
|
258
|
+
// `<div id="root"`.length offset, so a reflowed shell (`class="app"
|
|
259
|
+
// id="root"`, `id = "root"`, single quotes) shipped CORRUPT markup: a
|
|
260
|
+
// duplicated id, half an attribute name as garbage, or dropped attrs.
|
|
261
|
+
const base = shell
|
|
262
|
+
.slice(span[0], closeIdx)
|
|
263
|
+
.replace(/\s+data-server-rendered\s*=\s*("true"|'true'|true)/, '')
|
|
264
|
+
.replace(/>$/, '')
|
|
265
|
+
.replace(/\s+$/, '')
|
|
266
|
+
const open = base + ' data-server-rendered="true">'
|
|
267
|
+
// appHtml MUST NOT be $-expanded: it is okay for route content to contain
|
|
268
|
+
// "$1"/"$&"-shaped text (prices, code samples, quotes) and a replacement
|
|
269
|
+
// string would expand those into capture references, mangling the SSR
|
|
270
|
+
// output. Same lesson as the $120 meta-description bug.
|
|
271
|
+
// (Slice-assembly instead: no replace() in the swap at all.)
|
|
272
|
+
return shell.slice(0, span[0]) + open + appHtml + '</div>' + shell.slice(span[1])
|
|
186
273
|
}
|
|
187
274
|
|
|
188
275
|
// -- Meta injection ------------------------------------------------------------
|
|
@@ -199,27 +286,195 @@ export function replaceRootDiv(shell, appHtml) {
|
|
|
199
286
|
* @param {?string} globalOgImage - config-level ogImage fallback
|
|
200
287
|
* @returns {string} updated html
|
|
201
288
|
*/
|
|
202
|
-
|
|
289
|
+
// llms.txt (#49 family): one H1, a blockquote tagline, then a Docs section
|
|
290
|
+
// listing every indexable route's title, absolute URL and description - the
|
|
291
|
+
// AI-consumable site summary in the llmstxt.org shape. The prerender pass
|
|
292
|
+
// already carries every route's meta, so this costs no new scanning.
|
|
293
|
+
// Generated-only: an existing llms.txt (hand-rolled or SSG-written) is
|
|
294
|
+
// never touched, same contract as sitemap.xml. Opt-out: `llms: false`.
|
|
295
|
+
/**
|
|
296
|
+
* One line of plain text, whatever it was given: newlines split list
|
|
297
|
+
* entries silently, control characters ride in as garbage. Bad bytes
|
|
298
|
+
* drop, whitespace collapses.
|
|
299
|
+
*/
|
|
300
|
+
function oneline(value) {
|
|
301
|
+
return String(value)
|
|
302
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
303
|
+
.replace(/[\r\n]+/g, ' ')
|
|
304
|
+
.trim()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Write llms.txt into dist. Routes only: the prerendered route set (the
|
|
309
|
+
* same gate sitemap honors). @returns {string|null} the written path.
|
|
310
|
+
*/
|
|
311
|
+
export function generateLlms(routes, config, distDir) {
|
|
312
|
+
if (config && config.llms === false) return null
|
|
313
|
+
const target = path.join(distDir, 'llms.txt')
|
|
314
|
+
if (fs.existsSync(target)) {
|
|
315
|
+
console.log('[prerender] llms.txt exists - leaving it untouched')
|
|
316
|
+
return null
|
|
317
|
+
}
|
|
318
|
+
const base = ((config && config.siteUrl) || '').replace(/\/$/, '')
|
|
319
|
+
const lines = []
|
|
320
|
+
lines.push('# ' + oneline((config && config.siteName) || 'Site'))
|
|
321
|
+
const tagline = config && config.tagline
|
|
322
|
+
if (tagline) lines.push('', '> ' + oneline(tagline))
|
|
323
|
+
lines.push('', '## Docs', '')
|
|
324
|
+
let count = 0
|
|
325
|
+
for (const r of (routes || [])) {
|
|
326
|
+
if (!r || typeof r.path !== 'string') continue
|
|
327
|
+
const title = oneline((r.meta && r.meta.title) || r.path)
|
|
328
|
+
const desc = (r.meta && r.meta.description) ? ': ' + oneline(r.meta.description) : ''
|
|
329
|
+
lines.push('- [' + title + '](' + base + (r.path === '/' ? '/' : r.path) + ')' + desc)
|
|
330
|
+
count++
|
|
331
|
+
}
|
|
332
|
+
fs.writeFileSync(target, lines.join('\n') + '\n', 'utf-8')
|
|
333
|
+
console.log('[prerender] llms.txt: emitted for ' + count + ' route(s)')
|
|
334
|
+
return target
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function injectMeta(html, meta, routePath, siteUrl, globalOgImage, extras = null) {
|
|
203
338
|
const title = escapeReplacement(escapeHtml(meta.title))
|
|
204
339
|
const desc = escapeReplacement(escapeHtml(meta.description))
|
|
205
|
-
|
|
206
|
-
|
|
340
|
+
// og:image per-route override, string shorthand OR object form (object
|
|
341
|
+
// contract mirrors inject-brand's): object resolution takes `.url` so a
|
|
342
|
+
// structured global or route value lands as a URL, never as the string
|
|
343
|
+
// "[object Object]" corrupting every head silently.
|
|
344
|
+
const globalImgUrl = (typeof globalOgImage === 'object' && globalOgImage !== null) ? globalOgImage.url : globalOgImage
|
|
345
|
+
const routeImgUrl = (typeof meta.ogImage === 'object' && meta.ogImage !== null) ? meta.ogImage.url : meta.ogImage
|
|
346
|
+
const ogImage = escapeReplacement(escapeHtml(String(routeImgUrl || globalImgUrl || '')))
|
|
347
|
+
// Per-route og:image OBJECT companions (#audit B4): a route that overrides
|
|
348
|
+
// og:image with a structured object owns its width/height/alt/type too. The
|
|
349
|
+
// companions are reconciled below (the route's replace the site-wide ones
|
|
350
|
+
// inject-brand baked; absent ones are stripped so a stale global never
|
|
351
|
+
// describes a different raster). String-form overrides keep the global
|
|
352
|
+
// companions, which still describe the same site-wide image.
|
|
353
|
+
const routeOgObject = (meta.ogImage && typeof meta.ogImage === 'object' && !Array.isArray(meta.ogImage)) ? meta.ogImage : null
|
|
354
|
+
// Trailing-slash normalization (#53): a `siteUrl` written with its trailing
|
|
355
|
+
// slash would otherwise join into `https://site//about` on every page's
|
|
356
|
+
// canonical + og:url.robots.txt already strips; this is the same contract
|
|
357
|
+
// at the injection seam.
|
|
358
|
+
const url = escapeReplacement(escapeHtml(`${(siteUrl || '').replace(/\/$/, '')}${routePath === '/' ? '' : routePath}`))
|
|
207
359
|
|
|
208
360
|
if (title) {
|
|
209
361
|
html = html.replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`)
|
|
210
|
-
html = html.replace(/(<meta\s+property="og:title"\s+content=")[^"]*(")
|
|
211
|
-
html = html.replace(/(<meta\s+name="twitter:title"\s+content=")[^"]*(")
|
|
362
|
+
html = html.replace(/(<meta\s+property="og:title"\s+content=")[^"\r\n]*(")/, `$1${title}$2`)
|
|
363
|
+
html = html.replace(/(<meta\s+name="twitter:title"\s+content=")[^"\r\n]*(")/, `$1${title}$2`)
|
|
212
364
|
}
|
|
213
365
|
if (desc) {
|
|
214
|
-
html = html.replace(/(<meta\s+name="description"\s+content=")[^"]*(")
|
|
215
|
-
html = html.replace(/(<meta\s+property="og:description"\s+content=")[^"]*(")
|
|
216
|
-
html = html.replace(/(<meta\s+name="twitter:description"\s+content=")[^"]*(")
|
|
366
|
+
html = html.replace(/(<meta\s+name="description"\s+content=")[^"\r\n]*(")/, `$1${desc}$2`)
|
|
367
|
+
html = html.replace(/(<meta\s+property="og:description"\s+content=")[^"\r\n]*(")/, `$1${desc}$2`)
|
|
368
|
+
html = html.replace(/(<meta\s+name="twitter:description"\s+content=")[^"\r\n]*(")/, `$1${desc}$2`)
|
|
217
369
|
}
|
|
218
|
-
html = html.replace(/(<meta\s+property="og:url"\s+content=")[^"]*(")
|
|
219
|
-
html = html.replace(/(<link\s+rel="canonical"\s+href=")[^"]*(")
|
|
370
|
+
html = html.replace(/(<meta\s+property="og:url"\s+content=")[^"\r\n]*(")/, `$1${url}$2`)
|
|
371
|
+
html = html.replace(/(<link\s+rel="canonical"\s+href=")[^"\r\n]*(")/, `$1${url}$2`)
|
|
220
372
|
if (ogImage) {
|
|
221
|
-
html = html.replace(/(<meta\s+property="og:image"\s+content=")[^"]*(")
|
|
222
|
-
html = html.replace(/(<meta\s+name="twitter:image"\s+content=")[^"]*(")
|
|
373
|
+
html = html.replace(/(<meta\s+property="og:image"\s+content=")[^"\r\n]*(")/, `$1${ogImage}$2`)
|
|
374
|
+
html = html.replace(/(<meta\s+name="twitter:image"\s+content=")[^"\r\n]*(")/, `$1${ogImage}$2`)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Reconcile og:image companion tags against the route's object override.
|
|
378
|
+
// Only runs for the object form; a string override leaves the global
|
|
379
|
+
// companions in place (they describe the same site-wide image). All stale
|
|
380
|
+
// companions are stripped first (so the global width/height/alt/type/
|
|
381
|
+
// secure_url can never describe a different raster), then the route's own
|
|
382
|
+
// companions are re-emitted.
|
|
383
|
+
if (routeOgObject) {
|
|
384
|
+
html = html.replace(/\n?\s*<meta\s+property="og:image:(?:secure_url|type|width|height|alt)"\s+content="[^"\r\n]*"\s*\/?>/g, '')
|
|
385
|
+
const companions = []
|
|
386
|
+
if (typeof routeImgUrl === 'string' && routeImgUrl.startsWith('http://')) {
|
|
387
|
+
companions.push(['og:image:secure_url', 'https://' + routeImgUrl.slice('http://'.length)])
|
|
388
|
+
}
|
|
389
|
+
if (typeof routeOgObject.type === 'string' && routeOgObject.type) companions.push(['og:image:type', routeOgObject.type])
|
|
390
|
+
if (/^[0-9]+$/.test(String(routeOgObject.width ?? ''))) companions.push(['og:image:width', String(routeOgObject.width)])
|
|
391
|
+
if (/^[0-9]+$/.test(String(routeOgObject.height ?? ''))) companions.push(['og:image:height', String(routeOgObject.height)])
|
|
392
|
+
if (typeof routeOgObject.alt === 'string' && routeOgObject.alt) companions.push(['og:image:alt', routeOgObject.alt])
|
|
393
|
+
for (const [prop, value] of companions) {
|
|
394
|
+
const tag = ` <meta property="${prop}" content="${escapeReplacement(escapeHtml(value))}" />`
|
|
395
|
+
html = html.replace(/(<meta\s+property="og:image"\s+content="[^"\r\n]*"\s*\/>)/, `$1\n${tag}`)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// -- Grouped head additions (missing-only, config-gated: a host that opts
|
|
400
|
+
// out renders identical HTML to before). These ride a FUNCTION replacer
|
|
401
|
+
// (`</head>` swapped for tail + `</head>`), and a function replacer does
|
|
402
|
+
// NOT process `$`-syntax in its return value. So the values here need only
|
|
403
|
+
// escapeHtml (markup class); applying escapeReplacement too would ship the
|
|
404
|
+
// doubled `$$` literally (a feed URL `.../feed$1.xml` -> `.../feed$$1.xml`).
|
|
405
|
+
let tail = ''
|
|
406
|
+
const headAdd = (line) => { tail += '\n ' + line }
|
|
407
|
+
|
|
408
|
+
// Feed autodiscovery: feed readers only learn a feed exists from <head>.
|
|
409
|
+
const feed = extras && extras.feed
|
|
410
|
+
if (feed && typeof feed.url === 'string' && feed.url) {
|
|
411
|
+
const fType = feed.type || 'application/rss+xml'
|
|
412
|
+
const fTitle = feed.title ? ` title="${escapeHtml(feed.title)}"` : ''
|
|
413
|
+
headAdd(`<link rel="alternate" type="${escapeHtml(fType)}"${fTitle} href="${escapeHtml(feed.url)}" />`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// hreflang alternates: site-wide set, per-route override (swap semantics).
|
|
417
|
+
const alternates = (meta && meta.alternates) || (extras && extras.alternates)
|
|
418
|
+
if (Array.isArray(alternates)) {
|
|
419
|
+
for (const a of alternates) {
|
|
420
|
+
if (!a || typeof a.href !== 'string' || !a.href || typeof a.hreflang !== 'string' || !a.hreflang) continue
|
|
421
|
+
headAdd(`<link rel="alternate" hreflang="${escapeHtml(a.hreflang)}" href="${escapeHtml(a.href)}" />`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Verification metas (site verifiers for search consoles, tooling): keys
|
|
426
|
+
// must be plain meta-name shapes, values escaped. A $ or quote inside a
|
|
427
|
+
// key would otherwise become markup or replacement syntax.
|
|
428
|
+
const verification = extras && extras.verification
|
|
429
|
+
if (verification && typeof verification === 'object' && !Array.isArray(verification)) {
|
|
430
|
+
for (const [vName, vValue] of Object.entries(verification)) {
|
|
431
|
+
if (typeof vValue !== 'string' || vValue === '') continue
|
|
432
|
+
if (!/^[A-Za-z0-9._-]+$/.test(vName)) continue
|
|
433
|
+
headAdd(`<meta name="${escapeHtml(vName)}" content="${escapeHtml(vValue)}" />`)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Per-route JSON-LD (#35): route-scoped schema nodes ship on the pages
|
|
438
|
+
// that carry them (FAQPage belongs on the FAQ page, not site-wide). The
|
|
439
|
+
// home route may also wrap the site's query interface (SearchAction) when
|
|
440
|
+
// the host configures a searchUrl with a {query} slot.
|
|
441
|
+
const routeNodes = []
|
|
442
|
+
const searchUrl = extras && routePath === '/' && typeof extras.searchUrl === 'string' ? extras.searchUrl : null
|
|
443
|
+
if (searchUrl && searchUrl.includes('{query}')) {
|
|
444
|
+
routeNodes.push({
|
|
445
|
+
'@context': 'https://schema.org',
|
|
446
|
+
'@type': 'WebSite',
|
|
447
|
+
url: `${(siteUrl || '').replace(/\/$/, '')}/`,
|
|
448
|
+
potentialAction: {
|
|
449
|
+
'@type': 'SearchAction',
|
|
450
|
+
target: { '@type': 'EntryPoint', urlTemplate: searchUrl },
|
|
451
|
+
'query-input': 'required name=query',
|
|
452
|
+
},
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
const routeJsonLd = meta && meta.jsonLd
|
|
456
|
+
if (Array.isArray(routeJsonLd)) {
|
|
457
|
+
for (const n of routeJsonLd) {
|
|
458
|
+
if (n && typeof n === 'object' && !Array.isArray(n)) routeNodes.push(n)
|
|
459
|
+
}
|
|
460
|
+
} else if (routeJsonLd && typeof routeJsonLd === 'object') {
|
|
461
|
+
routeNodes.push(routeJsonLd)
|
|
462
|
+
}
|
|
463
|
+
if (routeNodes.length > 0) {
|
|
464
|
+
const payload = routeNodes.length === 1
|
|
465
|
+
? routeNodes[0]
|
|
466
|
+
: { '@context': 'https://schema.org', '@graph': routeNodes }
|
|
467
|
+
// Rebuild idempotence: strip any previously injected route block first.
|
|
468
|
+
html = html.replace(/\n\s*<!-- JSON-LD \(route\) -->[\s\S]*?<\/script>(?=[\s\S]*?<\/head>)/, '')
|
|
469
|
+
const script = '<script type="application/ld+json">'
|
|
470
|
+
+ JSON.stringify(payload, null, 2).replace(/</g, '\\u003c')
|
|
471
|
+
+ '</script>'
|
|
472
|
+
headAdd('<!-- JSON-LD (route) -->')
|
|
473
|
+
headAdd(script)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (tail) {
|
|
477
|
+
html = html.replace('</head>', function () { return tail + '\n </head>' })
|
|
223
478
|
}
|
|
224
479
|
|
|
225
480
|
return html
|
|
@@ -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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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')
|
|
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, ''')
|
|
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
|
|
337
|
-
const urls = routes.map(r =>
|
|
338
|
-
|
|
339
|
-
<
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
710
|
+
// CTA href scheme guard (#walk 2026-10): escapeXml fixes the markup
|
|
711
|
+
// breaking class (" in the attribute) but nothing can make a
|
|
712
|
+
// javascript:/data: URL into a safe destination -- it would ship as a
|
|
713
|
+
// WORKING script href on the one page whose whole contract is zero JS.
|
|
714
|
+
// Allow only real anchors (relative, absolute http(s), mailto:, tel:
|
|
715
|
+
// and a plain hash); anything else falls back to '/'. Config is
|
|
716
|
+
// author-owned, so this is hardening, not a trust boundary.
|
|
717
|
+
const rawHref = notFoundConfig.primaryCta?.href || '/'
|
|
718
|
+
const primaryHref = /^(\/|https?:\/\/|mailto:|tel:|#)/i.test(String(rawHref))
|
|
719
|
+
? escapeXml(rawHref)
|
|
720
|
+
: escapeXml('/')
|
|
721
|
+
// siteName is NOT escaped here: it flows into injectMeta's title, which
|
|
722
|
+
// escapes it (escapeHtml + escapeReplacement) exactly once. Pre-escaping
|
|
723
|
+
// it here double-escaped the 404 title ("Tom & Jerry's" shipped as
|
|
724
|
+
// "Tom &amp; Jerry&apos;s").
|
|
725
|
+
const siteName = config.siteName || 'Site'
|
|
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
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
html = html
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
)
|
|
407
|
-
//
|
|
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
|
|
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"[
|
|
422
|
-
html = html.replace(/<link[
|
|
779
|
+
html = html.replace(/<script type="module"[^>\r\n]*><\/script>/, '')
|
|
780
|
+
html = html.replace(/<link[^>\r\n]*rel="modulepreload"[^>\r\n]*>/g, '')
|
|
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
|
-
|
|
469
|
-
|
|
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
|
|
949
|
+
let mod
|
|
489
950
|
try {
|
|
490
|
-
|
|
951
|
+
mod = await importer('react-router-dom/server.js')
|
|
491
952
|
} catch {
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
653
|
-
//
|
|
654
|
-
//
|
|
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:
|
|
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.
|
|
664
|
-
fs.writeFileSync(path.join(p.
|
|
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:
|
|
1200
|
+
return { total: ROUTES.length, succeeded, cached: cacheHits, failed: failures.length, failures }
|
|
673
1201
|
|
|
674
1202
|
} finally {
|
|
675
1203
|
await vite.close()
|