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()}` +}