Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 21 additions & 15 deletions scripts/prerender-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,18 +132,16 @@ function escapeHtml(str) {
}

/**
* Build the pre-populated markup that goes inside <div id="app">.
* Mirrors the layout produced at runtime by renderHeader() + renderDocPage()
* + renderFooter() in website/src/main.js, but statically — so crawlers see
* real content in the initial HTML response.
* Wrap the doc fragment in the structure that website/src/components/doc-page.js
* produces at runtime: a centered article container with a #doc-content div.
* The content is injected into the shell's #page-content div so crawlers and
* non-JS fetchers see real content in the initial HTML response.
*/
function buildAppMarkup(fragmentHtml) {
function buildDocContentMarkup(fragmentHtml) {
return `
<main class="flex-1">
<article class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<div id="doc-content" class="asciidoc-content">${fragmentHtml}</div>
</article>
</main>
<article class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<div id="doc-content" class="asciidoc-content">${fragmentHtml}</div>
</article>
`
}

Expand Down Expand Up @@ -187,11 +185,19 @@ function prerenderRoute(shell, route) {
`<link rel="canonical" href="${canonicalUrl}" />`
)

// Inject pre-rendered content into #app
html = html.replace(
/<div\s+id="app"\s*>\s*<\/div>/,
`<div id="app">${buildAppMarkup(fragment)}</div>`
)
// Inject pre-rendered content into the static shell's #page-content div.
// The shell (set up in website/index.html and preserved through vite build)
// contains:
// <div id="page-content" ... style="..."></div>
// We match that empty div by id and fill it with the doc content so crawlers
// receive real HTML while JS users still get the SPA hydration on top.
const pageContentRegex = /(<div\s+id="page-content"[^>]*>)\s*(<\/div>)/
if (!pageContentRegex.test(html)) {
throw new Error(
`Shell #page-content div not found in dist/index.html. Did website/index.html lose the skeleton structure?`
)
}
html = html.replace(pageContentRegex, `$1${buildDocContentMarkup(fragment)}$2`)

const outDir = path.join(DIST, route.path)
const outFile = path.join(outDir, 'index.html')
Expand Down
15 changes: 14 additions & 1 deletion website/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,20 @@
</script>
</head>
<body>
<div id="app"></div>
<!--
Skeleton shell: reserves viewport-height layout space so first paint
already has the expected header/content/footer footprint. Without this
the SPA boot caused CLS ~0.975 (empty #app → populated #app). main.js
detects the skeleton via #shell-header / #shell-footer and replaces the
header + footer in place, leaving #page-content untouched. See #432.
-->
<div id="app">
<div id="shell-root" class="flex flex-col" style="min-height: 100vh;">
<div id="shell-header" class="border-b border-[var(--color-border)]" style="min-height: 10.5rem;" aria-hidden="true"></div>
<div id="page-content" class="flex-1" style="min-height: calc(100vh - 16.5rem);"></div>
<div id="shell-footer" class="border-t border-[var(--color-border)]" style="min-height: 6rem;" aria-hidden="true"></div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
38 changes: 33 additions & 5 deletions website/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,38 @@ function triggerSearchIndexBuild() {
})
}

/**
* Hydrate the #app element on boot.
*
* The production build ships website/index.html with a static skeleton
* inside #app: a #shell-header placeholder, a real #page-content div, and
* a #shell-footer placeholder. Reserving those boxes at first paint
* eliminates the empty-→-populated layout shift that used to dominate CLS
* on the homepage (see #432).
*
* If the skeleton is present, replace just the header + footer placeholders
* in place — #page-content remains untouched so the route handlers can
* populate it normally.
*
* If the skeleton is absent (dev server / non-prerendered load) we still
* need the shell, so fall back to the previous full rewrite of #app.
*/
function hydrateShell(app) {
const shellHeader = document.getElementById('shell-header')
const shellFooter = document.getElementById('shell-footer')
if (shellHeader && shellFooter) {
shellHeader.outerHTML = renderHeader()
shellFooter.outerHTML = renderFooter(APP_VERSION)
return
}
const shellHtml = `
${renderHeader()}
<div id="page-content"></div>
${renderFooter(APP_VERSION)}
`
app.innerHTML = shellHtml
}

function initApp() {
i18n.init()
initTheme()
Expand All @@ -117,11 +149,7 @@ function initApp() {
const app = document.querySelector('#app')
if (!app) return

app.innerHTML = `
${renderHeader()}
<div id="page-content"></div>
${renderFooter(APP_VERSION)}
`
hydrateShell(app)

applyTranslations()
updateThemeIcon()
Expand Down
Loading