diff --git a/scripts/prerender-routes.js b/scripts/prerender-routes.js index a532d9e..476145b 100644 --- a/scripts/prerender-routes.js +++ b/scripts/prerender-routes.js @@ -132,18 +132,16 @@ function escapeHtml(str) { } /** - * Build the pre-populated markup that goes inside
. - * 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 ` -
-
-
${fragmentHtml}
-
-
+
+
${fragmentHtml}
+
` } @@ -187,11 +185,19 @@ function prerenderRoute(shell, route) { `` ) - // Inject pre-rendered content into #app - html = html.replace( - /\s*<\/div>/, - `
${buildAppMarkup(fragment)}
` - ) + // 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: + //
+ // 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 = /(]*>)\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') diff --git a/website/index.html b/website/index.html index 26f76d5..0ad79a3 100644 --- a/website/index.html +++ b/website/index.html @@ -67,7 +67,20 @@ -
+ +
+
+ +
+ +
+
diff --git a/website/src/main.js b/website/src/main.js index 0ebbea8..6c8058f 100644 --- a/website/src/main.js +++ b/website/src/main.js @@ -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()} +
+ ${renderFooter(APP_VERSION)} + ` + app.innerHTML = shellHtml +} + function initApp() { i18n.init() initTheme() @@ -117,11 +149,7 @@ function initApp() { const app = document.querySelector('#app') if (!app) return - app.innerHTML = ` - ${renderHeader()} -
- ${renderFooter(APP_VERSION)} - ` + hydrateShell(app) applyTranslations() updateThemeIcon()