feat(perf,seo): inject blog articles + beta status via window globals, optimize images#21
Merged
Merged
Conversation
…, optimize images Production audit caught three remaining issues: 1. /blog pre-rendered HTML in production contains the 'Could not load articles' error fallback. Google Search Console rejects indexing with 'Soft 404'. Root cause: during the PR #20 CI build, BlogIndexPage's API call during prerender failed (likely due to the 4-page concurrent pool hammering api.cercol.team with too many simultaneous requests). Puppeteer captured the error state. Commit 131c986 (waitForFunction guard) was on main but never deployed (scripts/ missing from deploy path filter), AND even if deployed wouldn't help in this case — if the API stays failed during build, the guard times out and the error HTML is captured anyway. 2. 91 of 104 blog cover image URLs in the database have malformed double-? query strings, causing Unsplash to serve 3840px+ images at 380x214 display. The blog slug LCP is dominated by this: 722 KiB cover image, LCP 9.1s. 3. BetaBanner causes 1300ms LCP delay on the landing page because useState(null) unmounts the pre-rendered banner during React hydration; the API roundtrip then re-mounts it 1300ms later. The unified solution: stop depending on API calls at render time for pre-rendered routes. The prerender script fetches the article list and beta status ONCE and injects them as window globals into every pre-rendered HTML. React reads from window synchronously, eliminating the hydration flicker and the API failure mode. Changes: deploy-frontend.yml: add scripts/** to path filter so prerender changes trigger fresh deploys. src/utils/unsplash.js (new): normalizeUnsplashUrl() fixes the double-? URLs and applies w/q/fit overrides. 5 unit tests. BlogArticlePage.jsx: cover uses normalizeUnsplashUrl(coverUrl, {w:760}) + fetchpriority='high' + loading='eager' + explicit width/height. BlogIndexPage.jsx: - useState reads from window.__BLOG_ARTICLES__ on first render - useEffect still refreshes from API for stale-data resilience - Error state only shown when we have NO articles from ANY source - Thumbnails use normalizeUnsplashUrl(coverUrl, {w:400}) + lazy BetaBanner.jsx: useState reads from window.__BETA__ on first render. scripts/prerender.mjs: - fetchBlogArticles() called once at start (throws on failure to fail the build loudly rather than silently shipping broken HTML) - fetchBetaStatus() called once at start (silent fallback to {remaining:500,total:500,active:true}) - Both values injected as <script>window.__BETA__=...; window.__BLOG_ARTICLES__=...;</script> in <head> of every HTML Expected outcomes: - /blog and 5 language variants: HTML contains 104 article cards, no 'Could not load' fallback. Google Search Console accepts indexing. - Blog slug LCP: 9.1s -> ~2.5s - Blog slug Performance: 66 -> ~85-90 - Landing LCP: 4.4s -> ~2.5-3.0s - Landing Performance: 76 -> ~82-86 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
miquelmatoses
added a commit
that referenced
this pull request
May 17, 2026
…r post-process (#22) Closes the remaining performance gap from the PR #21 PageSpeed follow-up: render-blocking CSS (~1100ms) and undiscovered critical fonts (~400ms). Approach: instead of running beasties as a Vite plugin (which sees only the empty SPA shell), invoke beasties as a Node.js library inside scripts/prerender.mjs, after each route's full DOM is captured by puppeteer. Each of the 637 routes gets its own critical CSS extraction matched to that page's above-the-fold content. Font preloads are injected by the same prerender step. The 4 latin woff2 files (Playfair Display 400, Roboto 400/500/700) are content-hashed by Vite so the URLs change every build; the script extracts them from dist/assets/index-*.css at runtime. scripts/prerender.mjs changes: - New helper extractCriticalFontUrls() parses the built CSS file to find the 4 critical font URLs with their content hashes - New helper buildFontPreloadTags() generates the <link rel=preload> tags (with crossorigin, required by browser spec) - New Beasties instance configured with pruneSource:false (the shared CSS file must remain complete — 637 HTMLs reference it), preload:'swap' (external CSS becomes non-blocking), and inlineThreshold:0 - startServer() now accepts and uses a frozen copy of the original Vite-built index.html as the SPA shell fallback; without this, routes processed after '/' would pick up the already-injected preload tags and produce 8 duplicate preloads instead of 4 - renderOneRoute now: injects font preloads + window globals into the <head>, then runs beasties.process() on the result, then writes the final HTML to disk - If beasties fails on a specific page, log and fall back to the un-processed HTML rather than failing the build package.json: beasties ^0.4.2 added as devDependency (no runtime dependency). Local verification: - 4 font preloads on every route (/, /about, /blog, /ca/blog, slugs) - 18-21 KiB critical CSS inlined per page - CSS swap (preload + onload) pattern present - Shared CSS file unchanged: 90,056 bytes (pruneSource:false) - All 6 blog indexes: 0 errors, 104 links each - 199/199 tests passing Expected outcomes: - Landing LCP: 4.5s → ~2.0-2.5s - Landing Performance: 75 → ~85-90 - Blog slug LCP: 6.5s → ~2.5-3.5s - Blog slug Performance: 66 → ~78-85 - Build time: +~14min on the prerender phase (acceptable for CI) Co-authored-by: miquelmatoses <miquelmatoses@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Consolidated fix for three production issues found in the audit:
Soft 404 in Google Search Console:
/blog(all 6 languages) pre-rendered with the "Could not load articles" error fallback because the concurrent Puppeteer pool caused theapi.cercol.team/blogAPI call to fail during the CI build. The131c986waitForFunction guard was also never deployed (scripts/ was missing from the deploy path filter). Fix:prerender.mjsnow fetches the article list ONCE at build start (fetchBlogArticles()throws on failure to fail the build loudly), injects it aswindow.__BLOG_ARTICLES__in every pre-rendered HTML.BlogIndexPagereads fromwindowsynchronously — no API call at render time.Blog slug LCP 9.1s (722 KiB Unsplash image): 91 of 104
coverUrlvalues in the DB have malformed double-?query strings (...ixlib=rb-4.1.0?w=1200...), causing Unsplash CDN to ignore the size param and serve full-resolution images. Fix: newsrc/utils/unsplash.jsnormalizeUnsplashUrl()utility strips the broken suffix and applies correct?w=params.BlogArticlePageusesw=760 + fetchpriority=high + loading=eager.BlogIndexPagethumbnails usew=400 + loading=lazy. 5 unit tests.Landing LCP 4.4s (BetaBanner):
useState(null)unmounts the pre-rendered banner during React hydration; the/betaAPI round-trip (~1300ms) then re-mounts it, shifting LCP. Fix:BetaBanneruseStatenow initialises fromwindow.__BETA__injected by prerender, so React mounts with the same data as the pre-rendered HTML and never unmounts the banner.Deploy path filter:
scripts/**added so future prerender changes trigger automatic deploys.Verification (local)
window.__BETA__andwindow.__BLOG_ARTICLES__present indist/index.html✓window.__BLOG_ARTICLES__(104 articles) present indist/blog/index.html✓<img>: single?,w=760,fetchpriority=\"high\"✓Test plan
/blogin all 6 languages returns 200 with >50 article links, 0 error fallbackwindow.__BLOG_ARTICLES__visible in page source atcercol.team/blogwindow.__BETA__visible in page source atcercol.team/?andw=760/blog— should now pass (no Soft 404)🤖 Generated with Claude Code