From 7a1a6e9d571c2a53ba8cc3f9d085936b49ed4d93 Mon Sep 17 00:00:00 2001 From: miquelmatoses Date: Sun, 17 May 2026 00:24:01 +0200 Subject: [PATCH] feat(perf,seo): inject blog articles + beta status via window globals, optimize images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 in 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 --- .github/workflows/deploy-frontend.yml | 1 + public/sitemap.xml | 1000 ++++++++++++------------- scripts/prerender.mjs | 127 +++- src/components/BetaBanner.jsx | 11 +- src/pages/BlogIndexPage.jsx | 57 +- src/pages/blog/BlogArticlePage.jsx | 7 +- src/utils/__tests__/unsplash.test.js | 47 ++ src/utils/unsplash.js | 55 ++ 8 files changed, 758 insertions(+), 547 deletions(-) create mode 100644 src/utils/__tests__/unsplash.test.js create mode 100644 src/utils/unsplash.js diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml index 561e9c47..dc3e8fd1 100644 --- a/.github/workflows/deploy-frontend.yml +++ b/.github/workflows/deploy-frontend.yml @@ -12,6 +12,7 @@ on: - 'package.json' - 'package-lock.json' - '.env.production' + - 'scripts/**' # Build scripts (prerender, sitemap) affect output permissions: contents: write diff --git a/public/sitemap.xml b/public/sitemap.xml index e415f66c..2f467c7d 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -105,43 +105,30 @@ - https://cercol.team/blog/big-five-personality-across-cultures-what-research-shows - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/personality-and-feedback-reception-why-some-people-reject-feedback + https://cercol.team/blog/personality-and-team-size-what-changes-as-teams-grow 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/sales-personality-what-traits-predict-sales-performance + https://cercol.team/blog/trust-in-teams-personality-foundations 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/personality-and-negotiation-who-wins-and-why @@ -157,17 +144,30 @@ - https://cercol.team/blog/personality-and-team-size-what-changes-as-teams-grow + https://cercol.team/blog/personality-assessment-technology-future 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + + + + https://cercol.team/blog/anonymity-in-personality-assessment-why-it-matters + 2026-05-16 + monthly + 0.7 + + + + + + + https://cercol.team/blog/personality-and-mentoring-what-makes-a-good-mentor @@ -183,30 +183,30 @@ - https://cercol.team/blog/personality-and-leadership-styles-authoritative-coaching-democratic + https://cercol.team/blog/do-generational-differences-in-personality-actually-exist 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/using-cercol-for-team-development-a-practical-guide + https://cercol.team/blog/sales-personality-what-traits-predict-sales-performance 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/team-failure-modes-personality-perspective @@ -221,6 +221,32 @@ + + https://cercol.team/blog/groupthink-personality-causes-prevention + 2026-05-16 + monthly + 0.7 + + + + + + + + + + https://cercol.team/blog/founder-ceo-transition-personality-perspective + 2026-05-16 + monthly + 0.7 + + + + + + + + https://cercol.team/blog/personality-science-evidence-based-hr-why-it-matters 2026-05-16 @@ -235,17 +261,43 @@ - https://cercol.team/blog/personality-and-social-media-what-your-posts-reveal + https://cercol.team/blog/co-founder-compatibility-personality-due-diligence 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + + + + https://cercol.team/blog/using-cercol-for-team-development-a-practical-guide + 2026-05-16 + monthly + 0.7 + + + + + + + + + + https://cercol.team/blog/personality-and-leadership-styles-authoritative-coaching-democratic + 2026-05-16 + monthly + 0.7 + + + + + + + https://cercol.team/blog/personality-and-communication-style-direct-vs-diplomatic @@ -261,56 +313,56 @@ - https://cercol.team/blog/neurodiversity-and-personality-tests-what-to-know + https://cercol.team/blog/big-five-personality-across-cultures-what-research-shows 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/trust-in-teams-personality-foundations + https://cercol.team/blog/personality-and-social-media-what-your-posts-reveal 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/co-founder-compatibility-personality-due-diligence + https://cercol.team/blog/personality-of-entrepreneurs-what-research-says 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/personality-assessment-technology-future + https://cercol.team/blog/personality-science-replication-crisis 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/critiques-of-big-five-what-critics-say @@ -326,17 +378,17 @@ - https://cercol.team/blog/groupthink-personality-causes-prevention + https://cercol.team/blog/neurodiversity-and-personality-tests-what-to-know 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/gender-and-personality-what-big-five-research-says @@ -352,43 +404,17 @@ - https://cercol.team/blog/do-generational-differences-in-personality-actually-exist - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/personality-science-replication-crisis - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/personality-of-entrepreneurs-what-research-says + https://cercol.team/blog/personality-and-feedback-reception-why-some-people-reject-feedback 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/conflict-resolution-styles-personality @@ -403,32 +429,6 @@ - - https://cercol.team/blog/anonymity-in-personality-assessment-why-it-matters - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/founder-ceo-transition-personality-perspective - 2026-05-16 - monthly - 0.7 - - - - - - - - https://cercol.team/blog/how-personality-test-scores-are-calculated 2026-05-16 @@ -495,17 +495,17 @@ - https://cercol.team/blog/personality-and-motivation-what-drives-each-big-five-profile + https://cercol.team/blog/personality-and-learning-styles-what-research-supports 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/personality-and-career-choice-what-big-five-predicts @@ -534,17 +534,17 @@ - https://cercol.team/blog/personality-and-learning-styles-what-research-supports + https://cercol.team/blog/personality-and-motivation-what-drives-each-big-five-profile 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/personality-coaching-using-big-five-as-development-tool @@ -559,6 +559,32 @@ + + https://cercol.team/blog/personality-and-decision-making-how-big-five-shapes-judgment + 2026-05-16 + monthly + 0.7 + + + + + + + + + + https://cercol.team/blog/how-to-use-personality-data-without-labelling-people + 2026-05-16 + monthly + 0.7 + + + + + + + + https://cercol.team/blog/innovation-culture-and-personality-what-companies-get-wrong 2026-05-16 @@ -585,19 +611,6 @@ - - https://cercol.team/blog/how-to-use-personality-data-without-labelling-people - 2026-05-16 - monthly - 0.7 - - - - - - - - https://cercol.team/blog/creativity-and-personality-what-big-five-research-shows 2026-05-16 @@ -611,45 +624,6 @@ - - https://cercol.team/blog/personality-and-decision-making-how-big-five-shapes-judgment - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/how-to-run-a-team-personality-workshop - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/how-to-read-a-big-five-personality-report - 2026-05-16 - monthly - 0.7 - - - - - - - - https://cercol.team/blog/five-personality-science-myths-that-wont-die 2026-05-16 @@ -676,6 +650,19 @@ + + https://cercol.team/blog/how-to-read-a-big-five-personality-report + 2026-05-16 + monthly + 0.7 + + + + + + + + https://cercol.team/blog/what-the-cercol-witness-instrument-measures 2026-05-16 @@ -690,30 +677,30 @@ - https://cercol.team/blog/high-performing-team-structures-personality-perspective + https://cercol.team/blog/how-to-run-a-team-personality-workshop 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/personality-of-successful-ceos-what-research-says + https://cercol.team/blog/do-personality-traits-change-over-a-lifetime 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/team-diversity-personality-and-performance @@ -728,6 +715,19 @@ + + https://cercol.team/blog/personality-of-successful-ceos-what-research-says + 2026-05-16 + monthly + 0.7 + + + + + + + + https://cercol.team/blog/personality-testing-open-source-vs-commercial 2026-05-16 @@ -742,56 +742,17 @@ - https://cercol.team/blog/do-personality-traits-change-over-a-lifetime - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/conscientiousness-perfectionism-when-discipline-becomes-a-problem - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/agreeableness-at-work-the-hidden-cost-of-being-too-nice - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/what-openness-to-experience-means-for-team-innovation + https://cercol.team/blog/high-performing-team-structures-personality-perspective 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/personality-and-burnout-who-is-most-at-risk @@ -820,30 +781,56 @@ - https://cercol.team/blog/how-to-design-meetings-for-all-personality-types + https://cercol.team/blog/conscientiousness-perfectionism-when-discipline-becomes-a-problem 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/how-to-give-personality-informed-feedback + https://cercol.team/blog/what-openness-to-experience-means-for-team-innovation 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + + + + https://cercol.team/blog/agreeableness-at-work-the-hidden-cost-of-being-too-nice + 2026-05-16 + monthly + 0.7 + + + + + + + + + + https://cercol.team/blog/building-psychological-safety-personality-science + 2026-05-16 + monthly + 0.7 + + + + + + + https://cercol.team/blog/personality-conflict-in-teams-what-it-actually-looks-like @@ -872,43 +859,30 @@ - https://cercol.team/blog/building-psychological-safety-personality-science - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/personality-testing-in-hiring-what-is-legal-what-is-ethical + https://cercol.team/blog/how-to-design-meetings-for-all-personality-types 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/can-you-fake-a-personality-test + https://cercol.team/blog/how-to-give-personality-informed-feedback 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/how-personality-predicts-onboarding-success @@ -949,6 +923,32 @@ + + https://cercol.team/blog/personality-testing-in-hiring-what-is-legal-what-is-ethical + 2026-05-16 + monthly + 0.7 + + + + + + + + + + https://cercol.team/blog/can-you-fake-a-personality-test + 2026-05-16 + monthly + 0.7 + + + + + + + + https://cercol.team/blog/low-agreeableness-in-leadership-when-directness-helps-and-when-it-harms 2026-05-16 @@ -1014,19 +1014,6 @@ - - https://cercol.team/blog/how-many-peer-assessors-do-you-need-reliable-personality-data - 2026-05-16 - monthly - 0.7 - - - - - - - - https://cercol.team/blog/forced-choice-personality-assessment-more-honest-data 2026-05-16 @@ -1040,6 +1027,19 @@ + + https://cercol.team/blog/why-self-assessment-alone-isnt-enough-peer-personality-feedback + 2026-05-16 + monthly + 0.7 + + + + + + + + https://cercol.team/blog/self-other-agreement-big-five-where-gaps-are-biggest 2026-05-16 @@ -1054,17 +1054,17 @@ - https://cercol.team/blog/why-self-assessment-alone-isnt-enough-peer-personality-feedback + https://cercol.team/blog/how-many-peer-assessors-do-you-need-reliable-personality-data 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/social-desirability-bias-personality-tests @@ -1079,6 +1079,19 @@ + + https://cercol.team/blog/what-is-agreeableness-the-cooperative-dimension + 2026-05-16 + monthly + 0.7 + + + + + + + + https://cercol.team/blog/what-is-neuroticism-understanding-emotional-depth-at-work 2026-05-16 @@ -1092,19 +1105,6 @@ - - https://cercol.team/blog/what-is-extraversion-beyond-the-introvert-extrovert-binary - 2026-05-16 - monthly - 0.7 - - - - - - - - https://cercol.team/blog/what-is-conscientiousness-the-most-consistent-predictor-of-job-performance 2026-05-16 @@ -1132,17 +1132,30 @@ - https://cercol.team/blog/what-is-agreeableness-the-cooperative-dimension + https://cercol.team/blog/what-is-extraversion-beyond-the-introvert-extrovert-binary + 2026-05-16 + monthly + 0.7 + + + + + + + + + + https://cercol.team/blog/best-free-personality-tests-for-teams-2026 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/mbti-vs-big-five-which-should-your-team-use @@ -1170,19 +1183,6 @@ - - https://cercol.team/blog/best-free-personality-tests-for-teams-2026 - 2026-05-16 - monthly - 0.7 - - - - - - - - https://cercol.team/blog/16personalities-vs-big-five-the-viral-test-that-gets-it-half-right 2026-05-16 @@ -1222,58 +1222,6 @@ - - https://cercol.team/blog/introversion-energy-management-science - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/software-engineer-personality-what-research-shows - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/personality-in-agile-teams-scrum-and-big-five - 2026-05-16 - monthly - 0.7 - - - - - - - - - - https://cercol.team/blog/work-life-balance-personality-who-struggles-most - 2026-05-16 - monthly - 0.7 - - - - - - - - https://cercol.team/blog/personality-and-happiness-what-big-five-predicts 2026-05-16 @@ -1288,17 +1236,17 @@ - https://cercol.team/blog/personality-and-procrastination-what-research-says + https://cercol.team/blog/work-life-balance-personality-who-struggles-most 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/personality-diversity-in-technical-teams @@ -1326,6 +1274,32 @@ + + https://cercol.team/blog/introversion-energy-management-science + 2026-05-16 + monthly + 0.7 + + + + + + + + + + https://cercol.team/blog/personality-and-procrastination-what-research-says + 2026-05-16 + monthly + 0.7 + + + + + + + + https://cercol.team/blog/product-manager-personality-what-works 2026-05-16 @@ -1340,43 +1314,43 @@ - https://cercol.team/blog/big-five-vs-disc-vs-belbin + https://cercol.team/blog/personality-in-agile-teams-scrum-and-big-five 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/too-agreeable-why-high-bond-teams-struggle-with-honest-feedback + https://cercol.team/blog/software-engineer-personality-what-research-shows 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/the-vision-discipline-tension-innovation-vs-execution + https://cercol.team/blog/blind-spots-in-teams 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + https://cercol.team/blog/should-you-hire-for-personality-fit-or-personality-diversity @@ -1392,17 +1366,30 @@ - https://cercol.team/blog/blind-spots-in-teams + https://cercol.team/blog/how-to-build-a-balanced-team 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + + + + https://cercol.team/blog/building-a-team-from-scratch-what-personality-data-can-and-cant-tell-you + 2026-05-16 + monthly + 0.7 + + + + + + + https://cercol.team/blog/what-is-the-ipip @@ -1431,30 +1418,43 @@ - https://cercol.team/blog/how-to-build-a-balanced-team + https://cercol.team/blog/big-five-vs-disc-vs-belbin 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + - https://cercol.team/blog/building-a-team-from-scratch-what-personality-data-can-and-cant-tell-you + https://cercol.team/blog/too-agreeable-why-high-bond-teams-struggle-with-honest-feedback 2026-05-16 monthly 0.7 - - - - - - - + + + + + + + + + + https://cercol.team/blog/the-vision-discipline-tension-innovation-vs-execution + 2026-05-16 + monthly + 0.7 + + + + + + + diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 24a27b3e..e97ce6da 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -4,22 +4,36 @@ * Generates static HTML for public SEO-relevant routes. * Runs after `vite build`, before `gh-pages -d dist`. * - * Usage (called automatically by `npm run build:prerender`): + * Usage (called automatically by `npm run build:full`): * node scripts/prerender.mjs * * What it does: - * 1. Launches a local HTTP server serving ./dist - * 2. Opens each public route in headless Chrome via puppeteer-core - * 3. Waits for React to render (react-i18next loads, fonts settle) - * 4. Saves the fully-rendered HTML to dist//index.html + * 1. Fetches the article list and beta status from the live API ONCE + * 2. Launches a local HTTP server serving ./dist + * 3. Opens each public route in headless Chrome via puppeteer-core + * 4. Waits for React to render (react-i18next loads, fonts settle) + * 5. Injects window.__BLOG_ARTICLES__ and window.__BETA__ into + * 6. Saves the fully-rendered HTML to dist//index.html * * Why this matters for SEO: * - Google indexes the static HTML directly — no JS execution lag * - LLMs and Perplexity can scrape content without running JavaScript * - First Contentful Paint improves because browsers get real HTML instantly * - * The 404.html SPA redirect remains untouched — it handles routes that are - * NOT prerendered (instrument pages, auth, account pages). + * Why the window globals are injected: + * - BlogIndexPage reads window.__BLOG_ARTICLES__ on first render, eliminating + * the API call at hydration time. Root cause of "Soft 404" in Google Search + * Console: during the PR #20 CI build the concurrent pool caused the API to + * time out on the blog index page, and Puppeteer captured the error fallback + * HTML. With window.__BLOG_ARTICLES__ pre-injected, BlogIndexPage never hits + * the API during prerender — it renders from the global synchronously. + * - BetaBanner reads window.__BETA__ on first render, eliminating the 1300ms + * LCP delay caused by useState(null) unmounting the pre-rendered banner on + * hydration and re-mounting it after the /beta API round-trip. + * + * CRITICAL: fetchBlogArticles() throws on failure (does NOT fall back silently) + * so the build fails loudly rather than shipping blog index HTML with the error + * fallback "Could not load articles" baked into it. * * Concurrency: * - Static routes + blog index pages run sequentially (13 routes, fast). @@ -46,18 +60,55 @@ const CONCURRENCY = 4 const STATIC_ROUTES = ['/', '/about', '/instruments', '/roles', '/science', '/faq', '/privacy'] const BLOG_LANGS = ['en', 'ca', 'es', 'fr', 'de', 'da'] -async function fetchBlogSlugs() { +// --------------------------------------------------------------------------- +// API fetchers — called ONCE before any rendering +// --------------------------------------------------------------------------- + +/** + * Fetch the full article list from the API. + * THROWS on failure — an empty list would cause BlogIndexPage to render the + * "Could not load articles" error fallback and bake it into the pre-rendered + * HTML, which Google Search Console treats as a Soft 404. + */ +async function fetchBlogArticles() { + const res = await globalThis.fetch('https://api.cercol.team/blog', { + signal: AbortSignal.timeout(15000), + }) + if (!res.ok) { + throw new Error(`/blog returned HTTP ${res.status}`) + } + const articles = await res.json() + if (!Array.isArray(articles) || articles.length === 0) { + throw new Error('/blog returned an empty article list') + } + console.log(`[prerender] fetched ${articles.length} articles for window.__BLOG_ARTICLES__`) + return articles +} + +/** + * Fetch the beta-launch status. Falls back silently — the banner is + * best-effort and a failure here must not block the build. + */ +async function fetchBetaStatus() { try { - const res = await globalThis.fetch('https://api.cercol.team/blog') - const posts = await res.json() - return Array.isArray(posts) ? posts.map(p => p.slug) : [] - } catch (e) { - console.warn('[prerender] could not fetch blog slugs:', e.message) - return [] + const res = await globalThis.fetch('https://api.cercol.team/beta', { + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) throw new Error(`/beta returned HTTP ${res.status}`) + const data = await res.json() + console.log(`[prerender] fetched beta status: remaining=${data.remaining}/${data.total}`) + return data + } catch (err) { + console.warn(`[prerender] could not fetch beta status (${err.message}), using default`) + return { remaining: 500, total: 500, active: true } } } -async function buildRoutes(slugs) { +// --------------------------------------------------------------------------- +// Route plan +// --------------------------------------------------------------------------- + +function buildRoutes(slugs) { // Phase 1: static pages + blog index (one per language) — 13 routes total. const staticRoutes = STATIC_ROUTES.map(route => ({ route, lang: 'en' })) for (const lang of BLOG_LANGS) { @@ -138,7 +189,7 @@ function startServer() { // Render a single route — shared by sequential and concurrent workers // --------------------------------------------------------------------------- -async function renderOneRoute(browser, { route, lang }) { +async function renderOneRoute(browser, { route, lang }, { articles, betaStatus }) { const url = `${BASE_URL}${route}` const page = await browser.newPage() @@ -157,13 +208,11 @@ async function renderOneRoute(browser, { route, lang }) { // Extra settle time for React hydration and i18n loading await new Promise(r => setTimeout(r, 1500)) - // Blog routes fetch article data from the API after mount — the fixed - // 1500ms settle may not be enough when the API is slow in CI. - // Wait until either content is present or an error state is shown so - // we never capture a loading skeleton as the final HTML. - // Scoped to blog routes only; try-catch means a timeout (e.g. on - // individual article pages where the >5 threshold may not be met) falls - // through to page.content() with whatever rendered rather than hanging. + // Blog routes: guard against slow API responses / loading states. + // With window.__BLOG_ARTICLES__ now injected, the blog INDEX page no longer + // hits the API during render — it reads from the global synchronously. + // The guard remains for individual article pages where the body is still + // fetched from the API, and as a belt-and-suspenders safety net. if (route.includes('/blog')) { try { await page.waitForFunction( @@ -183,13 +232,20 @@ async function renderOneRoute(browser, { route, lang }) { const html = await page.content() await page.close() + // Inject window globals into so React reads them synchronously + // on first render, avoiding API calls and hydration flicker: + // window.__BLOG_ARTICLES__ — full article list for BlogIndexPage + // window.__BETA__ — beta launch status for BetaBanner + const injection = `` + const htmlWithGlobals = html.replace('', `${injection}\n`) + // Write to dist//index.html (root → dist/index.html) if (route === '/') { - writeFileSync(join(DIST_DIR, 'index.html'), html, 'utf8') + writeFileSync(join(DIST_DIR, 'index.html'), htmlWithGlobals, 'utf8') } else { const dir = join(DIST_DIR, route.slice(1)) mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'index.html'), html, 'utf8') + writeFileSync(join(dir, 'index.html'), htmlWithGlobals, 'utf8') } } @@ -197,7 +253,7 @@ async function renderOneRoute(browser, { route, lang }) { // Concurrency pool — processes a queue of routes with N parallel workers // --------------------------------------------------------------------------- -async function renderWithPool(browser, routes, concurrency) { +async function renderWithPool(browser, routes, concurrency, globals) { const queue = [...routes] let completed = 0 const total = queue.length @@ -207,7 +263,7 @@ async function renderWithPool(browser, routes, concurrency) { const item = queue.shift() if (!item) break console.log(`[prerender] → ${item.route} (${item.lang}) [${++completed}/${total}]`) - await renderOneRoute(browser, item) + await renderOneRoute(browser, item, globals) } } @@ -221,6 +277,16 @@ async function renderWithPool(browser, routes, concurrency) { // --------------------------------------------------------------------------- async function main() { + // Fetch API data ONCE before rendering any routes. + // fetchBlogArticles() throws on failure — a build with an empty or erroring + // article list would bake the error fallback into the blog index HTML. + const [articles, betaStatus] = await Promise.all([ + fetchBlogArticles(), + fetchBetaStatus(), + ]) + const globals = { articles, betaStatus } + const slugs = articles.map(a => a.slug) + const server = await startServer() const browser = await puppeteer.launch({ @@ -235,8 +301,7 @@ async function main() { ], }) - const slugs = await fetchBlogSlugs() - const { staticRoutes, articleRoutes } = await buildRoutes(slugs) + const { staticRoutes, articleRoutes } = buildRoutes(slugs) const total = staticRoutes.length + articleRoutes.length console.log(`[prerender] prerendering ${total} routes (${staticRoutes.length} static + ${articleRoutes.length} articles)…`) @@ -244,13 +309,13 @@ async function main() { console.log(`[prerender] phase 1: static routes (sequential)`) for (const item of staticRoutes) { console.log(`[prerender] → ${item.route} (${item.lang})`) - await renderOneRoute(browser, item) + await renderOneRoute(browser, item, globals) } // Phase 2: blog articles — concurrent pool if (articleRoutes.length > 0) { console.log(`[prerender] phase 2: blog articles (concurrency=${CONCURRENCY})`) - await renderWithPool(browser, articleRoutes, CONCURRENCY) + await renderWithPool(browser, articleRoutes, CONCURRENCY, globals) } await browser.close() diff --git a/src/components/BetaBanner.jsx b/src/components/BetaBanner.jsx index 53ca9000..1b9d3b1a 100644 --- a/src/components/BetaBanner.jsx +++ b/src/components/BetaBanner.jsx @@ -15,7 +15,16 @@ import { colors } from '../design/tokens' export default function BetaBanner({ userIsPremium }) { const { t } = useTranslation() - const [beta, setBeta] = useState(null) + + // Read from window.__BETA__ injected by prerender.mjs into every pre-rendered + // HTML. This eliminates the hydration flash: the pre-rendered banner is + // visible in the raw HTML, and React mounts with the same data so it doesn't + // unmount and re-mount after the API round-trip (~1300ms LCP delay removed). + // The useEffect below still refreshes from the API in the background. + const [beta, setBeta] = useState(() => { + if (typeof window !== 'undefined' && window.__BETA__) return window.__BETA__ + return null + }) useEffect(() => { getBetaStatus() diff --git a/src/pages/BlogIndexPage.jsx b/src/pages/BlogIndexPage.jsx index ce05b039..f9dcc281 100644 --- a/src/pages/BlogIndexPage.jsx +++ b/src/pages/BlogIndexPage.jsx @@ -11,6 +11,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { SectionLabel } from '../components/ui' import { getBlogPosts } from '../lib/api' +import { normalizeUnsplashUrl } from '../utils/unsplash' function formatDate(iso, lang) { if (!iso) return '' @@ -69,9 +70,22 @@ export default function BlogIndexPage() { if (urlLang !== i18n.language.slice(0, 2)) i18n.changeLanguage(urlLang) }, [urlLang]) - const [posts, setPosts] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + // Read articles from window.__BLOG_ARTICLES__ injected by prerender.mjs. + // This eliminates the API dependency during pre-rendering so Puppeteer + // never captures the error-fallback HTML (Soft 404 in Search Console). + // The useEffect below still refreshes from the API so live visitors + // always get up-to-date content. + const [posts, setPosts] = useState(() => { + if (typeof window !== 'undefined' && Array.isArray(window.__BLOG_ARTICLES__)) { + return window.__BLOG_ARTICLES__ + } + return [] + }) + const [loading, setLoading] = useState(() => { + // Skip the loading skeleton if we already have pre-rendered data + return typeof window === 'undefined' || !Array.isArray(window.__BLOG_ARTICLES__) + }) + const [error, setError] = useState(false) const [activeCategory, setActiveCategory] = useState('all') const [activeLevel, setActiveLevel] = useState('all') @@ -93,12 +107,23 @@ export default function BlogIndexPage() { }, []) useEffect(() => { - setLoading(true) - setError(null) + // Always refresh from the API — the pre-rendered window global may be + // stale if new articles were published since the last deploy. + // Only show the error fallback if we have no articles from any source. getBlogPosts() - .then(data => setPosts(Array.isArray(data) ? data : [])) - .catch(err => setError(err.message)) - .finally(() => setLoading(false)) + .then(data => { + setPosts(Array.isArray(data) ? data : []) + setLoading(false) + setError(false) + }) + .catch(() => { + setLoading(false) + // Don't clobber pre-rendered articles with an error state + setPosts(prev => { + if (prev.length === 0) setError(true) + return prev + }) + }) }, []) /** Resolve localised field (title or description), falling back to English. */ @@ -114,7 +139,7 @@ export default function BlogIndexPage() { return catOk && levelOk }) - const hasFilters = posts.length > 0 && !loading && !error + const hasFilters = posts.length > 0 && !loading return (
@@ -204,8 +229,8 @@ export default function BlogIndexPage() { )} - {/* Error state */} - {!loading && error && ( + {/* Error state — only shown when we have NO articles from any source */} + {!loading && error && posts.length === 0 && (

{t('blog.error')}

@@ -220,14 +245,14 @@ export default function BlogIndexPage() { )} {/* No results for active filters */} - {!loading && !error && posts.length > 0 && visiblePosts.length === 0 && ( + {!loading && posts.length > 0 && visiblePosts.length === 0 && (

No articles match the selected filters.

)} {/* Post grid */} - {!loading && !error && visiblePosts.length > 0 && ( + {!loading && visiblePosts.length > 0 && (
{visiblePosts.map(post => { const desc = localise(post.description) @@ -242,9 +267,13 @@ export default function BlogIndexPage() {
{post.coverUrl ? ( ) : (
{/* Gradient overlay for readability */}
diff --git a/src/utils/__tests__/unsplash.test.js b/src/utils/__tests__/unsplash.test.js new file mode 100644 index 00000000..03bf73bc --- /dev/null +++ b/src/utils/__tests__/unsplash.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { normalizeUnsplashUrl } from '../unsplash' + +describe('normalizeUnsplashUrl', () => { + it('returns null for null / undefined input', () => { + expect(normalizeUnsplashUrl(null)).toBeNull() + expect(normalizeUnsplashUrl(undefined)).toBeNull() + expect(normalizeUnsplashUrl('')).toBeNull() + }) + + it('passes through non-Unsplash URLs unchanged', () => { + const url = 'https://example.com/image.jpg' + expect(normalizeUnsplashUrl(url)).toBe(url) + }) + + it('fixes malformed double-? URLs from the database', () => { + const malformed = + 'https://images.unsplash.com/photo-abc?ixid=M3w5NDcwMjR8MHwxfHNlYXJjaHwxfA&ixlib=rb-4.1.0?w=1200&auto=format&fit=crop&q=80' + const result = normalizeUnsplashUrl(malformed, { w: 760 }) + // Must have exactly one `?` + expect(result.split('?').length).toBe(2) + // Must contain correct width + expect(result).toContain('w=760') + // Must not contain the broken second ? + expect(result).not.toMatch(/ixlib=[^&]+\?/) + // ixid param must be preserved + expect(result).toContain('ixid=') + expect(result).toContain('auto=format') + expect(result).toContain('fit=crop') + }) + + it('applies sizing to well-formed single-? URLs', () => { + const url = 'https://images.unsplash.com/photo-xyz?w=1200&auto=format&fit=crop&q=80' + const result = normalizeUnsplashUrl(url, { w: 400, q: 70 }) + expect(result.split('?').length).toBe(2) + expect(result).toContain('w=400') + expect(result).toContain('q=70') + expect(result).toContain('auto=format') + }) + + it('uses default w=760 q=80 when no options provided', () => { + const url = 'https://images.unsplash.com/photo-def?ixid=abc&ixlib=rb-4.1.0?w=1200&q=75' + const result = normalizeUnsplashUrl(url) + expect(result).toContain('w=760') + expect(result).toContain('q=80') + }) +}) diff --git a/src/utils/unsplash.js b/src/utils/unsplash.js new file mode 100644 index 00000000..76dfd2fd --- /dev/null +++ b/src/utils/unsplash.js @@ -0,0 +1,55 @@ +/** + * unsplash.js — Unsplash URL normalisation utility. + * + * Problem: 91 of 104 blog cover URLs in the database have a malformed + * double-? query string produced by string concatenation in the backend: + * + * …photo-abc?ixid=…&ixlib=rb-4.1.0?w=1200&auto=format&fit=crop&q=80 + * ^^^^ second ? — should be & + * + * Because the second `?w=…` is not a valid query parameter, Unsplash CDN + * ignores it and serves the original full-resolution image (3840px+), + * causing 700 KiB+ LCP payloads on blog pages. + * + * normalizeUnsplashUrl() strips the broken suffix, rebuilds clean params, + * and applies caller-specified sizing overrides. + */ + +/** + * Normalise an Unsplash image URL and apply sizing parameters. + * + * @param {string|null|undefined} url Raw coverUrl value from the API. + * @param {{ w?: number, q?: number }} [opts] + * w — desired pixel width (default 760) + * q — JPEG quality 1-100 (default 80) + * @returns {string|null} Clean URL, or null if input is falsy or non-Unsplash. + * + * @example + * normalizeUnsplashUrl('https://images.unsplash.com/photo-abc?ixid=X&ixlib=rb-4.1.0?w=1200&auto=format&fit=crop&q=80', { w: 760 }) + * // → 'https://images.unsplash.com/photo-abc?ixid=X&ixlib=rb-4.1.0&w=760&auto=format&fit=crop&q=80' + */ +export function normalizeUnsplashUrl(url, { w = 760, q = 80 } = {}) { + if (!url || typeof url !== 'string') return null + if (!url.includes('images.unsplash.com')) return url + + // Split at the FIRST `?` to isolate the base URL + const firstQ = url.indexOf('?') + const base = firstQ === -1 ? url : url.slice(0, firstQ) + const rawParams = firstQ === -1 ? '' : url.slice(firstQ + 1) + + // The malformed URLs look like: + // ixid=X&ixlib=rb-4.1.0?w=1200&auto=format&fit=crop&q=80 + // Strip everything from the second `?` onward (the broken suffix). + const cleanParams = rawParams.split('?')[0] + + // Parse what's left into a URLSearchParams so we can override safely + const sp = new URLSearchParams(cleanParams) + + // Apply/override sizing params + sp.set('w', String(w)) + sp.set('auto', 'format') + sp.set('fit', 'crop') + sp.set('q', String(q)) + + return `${base}?${sp.toString()}` +}