Skip to content

Commit 042e741

Browse files
raifdmuellerrdmuellerclaude
authored
feat(seo): pre-render the landing answer block for AI/search discoverability (#593)
The home page rendered its hero client-side, so crawlers and LLM fetchers saw only the empty skeleton — the worst case for the query "What is Semantic Anchors?". prerender-routes.js now fills the home #page-content with the hero copy (title + definition + emphasis), single-sourced from the EN translations so it never drifts from the live hero. The SPA overwrites #page-content with the full interactive hero + card grid on boot, so users are unaffected; the home is written separately so other routes keep their empty-skeleton assumption. Implements the landing-block half of #580; the per-anchor answer-block sweep is intentionally skipped — anchor definitions already ship crawlable via /all-anchors. Verified with a full build: the home #page-content carries the definition plus the DefinedTermSet/Organization, and a control route (/about) neither leaks the home block nor loses its own doc content. Co-authored-by: Ralf D. Müller <ralf.d.mueller@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c5d4735 commit 042e741

2 files changed

Lines changed: 47 additions & 2 deletions

File tree

docs/changelog.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ A chronological record of all semantic anchors added to the catalog. Community c
88

99
* *Structured data* — added a standalone `Organization` entity and a `DefinedTermSet` with a `DefinedTerm` for every anchor (name, canonical URL, and a definition where available), generated at build time from `anchors.json`. Lets search engines and retrieval-grounded AI resolve "Semantic Anchors" as a distinct entity and each anchor as a defined term (#579).
1010
* *Fixed:* the _An Anchor Delivers Only as Far as the Prior Reaches_ article was not pre-rendered and was therefore invisible to search engines and LLM crawlers. It is now pre-rendered like every other doc page.
11+
* *Landing answer block* — the home page rendered its hero (the "what are Semantic Anchors?" definition) only client-side, so crawlers and LLM fetchers saw an empty shell. The hero copy is now pre-rendered into the static home page, single-sourced from the translations so it never drifts from the live hero (#580).
1112

1213
== 2026-06-09
1314

scripts/prerender-routes.js

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,50 @@ function prerenderRoute(shell, route) {
264264
}
265265

266266
/**
267-
* Entry point: read the shell once, then pre-render every route in ROUTES.
267+
* Pre-render a crawlable "answer block" into the home page (dist/index.html).
268+
*
269+
* The landing page renders its hero client-side, so crawlers and LLM fetchers
270+
* see only the empty skeleton — the worst case for the query "What is Semantic
271+
* Anchors?". This fills the empty #page-content with the hero copy (title +
272+
* definition + emphasis), single-sourced from the EN translations so it never
273+
* drifts from the live hero. On boot the SPA's home route overwrites
274+
* #page-content with the full interactive hero + card grid, so users are
275+
* unaffected. See issue #580.
276+
*
277+
* Runs after the ROUTES loop and writes only dist/index.html, so the other
278+
* routes (already written from the cached shell) keep their empty-skeleton
279+
* assumption.
280+
*/
281+
function prerenderHome() {
282+
const enPath = path.join(__dirname, '..', 'website', 'src', 'translations', 'en.json')
283+
const en = JSON.parse(fs.readFileSync(enPath, 'utf-8'))
284+
const title = en['hero.title'] || ''
285+
const intro = en['hero.intro'] || ''
286+
const emphasis = en['hero.introEmphasis'] || ''
287+
288+
const block = `
289+
<section class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
290+
<h1 class="text-3xl sm:text-4xl font-bold mb-3 leading-tight">${escapeHtml(title)}</h1>
291+
<p class="mb-2 max-w-3xl">${escapeHtml(intro)}</p>
292+
<p class="font-semibold max-w-3xl">${escapeHtml(emphasis)}</p>
293+
</section>
294+
`
295+
296+
let html = fs.readFileSync(SHELL, 'utf-8')
297+
const pageContentRegex = /(<div\s+id="page-content"[^>]*>)\s*(<\/div>)/
298+
if (!pageContentRegex.test(html)) {
299+
throw new Error(
300+
'Home #page-content div not found (or not empty) in dist/index.html — cannot inject the landing answer block.'
301+
)
302+
}
303+
html = html.replace(pageContentRegex, `$1${block}$2`)
304+
fs.writeFileSync(SHELL, html, 'utf-8')
305+
console.log(' ✓ pre-rendered / (home answer block)')
306+
}
307+
308+
/**
309+
* Entry point: read the shell once, then pre-render every route in ROUTES,
310+
* plus a crawlable answer block on the home page.
268311
* Throws (via prerenderRoute) if any fragment is missing, so the build
269312
* fails non-zero instead of shipping an incomplete set of static pages.
270313
*/
@@ -274,7 +317,8 @@ function main() {
274317
prerenderRoute(shell, route)
275318
console.log(` ✓ pre-rendered ${route.path}`)
276319
}
277-
console.log(`\n✓ Pre-rendered ${ROUTES.length} routes to dist/<route>/index.html`)
320+
prerenderHome()
321+
console.log(`\n✓ Pre-rendered ${ROUTES.length} routes + home to dist/<route>/index.html`)
278322
}
279323

280324
main()

0 commit comments

Comments
 (0)