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
1 change: 1 addition & 0 deletions .github/workflows/deploy-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
- 'package.json'
- 'package-lock.json'
- '.env.production'
- 'scripts/**' # Build scripts (prerender, sitemap) affect output

permissions:
contents: write
Expand Down
1,000 changes: 500 additions & 500 deletions public/sitemap.xml

Large diffs are not rendered by default.

127 changes: 96 additions & 31 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<route>/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 <head>
* 6. Saves the fully-rendered HTML to dist/<route>/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).
Expand All @@ -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) {
Expand Down Expand Up @@ -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()

Expand All @@ -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(
Expand All @@ -183,21 +232,28 @@ async function renderOneRoute(browser, { route, lang }) {
const html = await page.content()
await page.close()

// Inject window globals into <head> 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 = `<script>window.__BETA__=${JSON.stringify(betaStatus)};window.__BLOG_ARTICLES__=${JSON.stringify(articles)};</script>`
const htmlWithGlobals = html.replace('</head>', `${injection}\n</head>`)

// Write to dist/<route>/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')
}
}

// ---------------------------------------------------------------------------
// 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
Expand All @@ -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)
}
}

Expand All @@ -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({
Expand All @@ -235,22 +301,21 @@ 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)…`)

// Phase 1: static + blog index — sequential, short list
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()
Expand Down
11 changes: 10 additions & 1 deletion src/components/BetaBanner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
57 changes: 43 additions & 14 deletions src/pages/BlogIndexPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
Expand Down Expand Up @@ -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')

Expand 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. */
Expand All @@ -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 (
<main className="py-12">
Expand Down Expand Up @@ -204,8 +229,8 @@ export default function BlogIndexPage() {
</div>
)}

{/* Error state */}
{!loading && error && (
{/* Error state — only shown when we have NO articles from any source */}
{!loading && error && posts.length === 0 && (
<p className="text-sm text-red-500 py-8 text-center">
{t('blog.error')}
</p>
Expand All @@ -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 && (
<div className="py-16 text-center rounded-2xl border border-dashed border-gray-200">
<p className="text-sm text-gray-400">No articles match the selected filters.</p>
</div>
)}

{/* Post grid */}
{!loading && !error && visiblePosts.length > 0 && (
{!loading && visiblePosts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{visiblePosts.map(post => {
const desc = localise(post.description)
Expand All @@ -242,9 +267,13 @@ export default function BlogIndexPage() {
<div className="relative h-40 overflow-hidden">
{post.coverUrl ? (
<img
src={post.coverUrl}
src={normalizeUnsplashUrl(post.coverUrl, { w: 400 })}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
width="400"
height="160"
loading="lazy"
decoding="async"
/>
) : (
<div
Expand Down
7 changes: 6 additions & 1 deletion src/pages/blog/BlogArticlePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useParams, useNavigate, Link, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { marked } from 'marked'
import { getBlogPost, getBlogPosts, trackBlogView } from '../../lib/api'
import { normalizeUnsplashUrl } from '../../utils/unsplash'

// Configure marked with custom renderers once at module load time
marked.use({
Expand Down Expand Up @@ -369,9 +370,13 @@ export default function BlogArticlePage() {
{post.coverUrl ? (
<>
<img
src={post.coverUrl}
src={normalizeUnsplashUrl(post.coverUrl, { w: 760 })}
alt=""
className="absolute inset-0 w-full h-full object-cover"
width="760"
height="428"
fetchpriority="high"
loading="eager"
/>
{/* Gradient overlay for readability */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/10 to-transparent" />
Expand Down
Loading
Loading