From Terminal-Styled SPA to Static Site — A 4 AM Journey
It's 4 AM. I've been talking to Claude for hours. The first version of this site was a pure JavaScript SPA — terminal aesthetics, dark/light themes, keyboard navigation, mermaid diagrams — all powered by marked.js rendering markdown at runtime. Lighthouse loved it. Google didn't.
What started as "make npm run build:static work" turned into a full rewrite: shared code architecture, event-driven scroll spy, pre-rendered mermaid SVGs in two themes, consent-gated analytics, an interactive build workflow with audit wizards, and fixing roughly 47 bugs along the way.
This is the story of what we built.
The Problem
View Source on any page: empty.
<!-- What Google saw -->
<article id="markdown-output"></article>
<!-- What humans saw (after JS loaded) -->
<article id="markdown-output">
<h1>Stéphane ERARD</h1>
<p>Full-stack web developer with 15+ years...</p>
...thousands of lines of content...
</article><!-- What Google saw -->
<article id="markdown-output"></article>
<!-- What humans saw (after JS loaded) -->
<article id="markdown-output">
<h1>Stéphane ERARD</h1>
<p>Full-stack web developer with 15+ years...</p>
...thousands of lines of content...
</article>A CV site that recruiters can't find via search is missing the point.
The Architecture: HTML First, JS Second
Without JavaScript:
Browser loads /content/blog/ddd.html
→ Full HTML with pre-rendered content, TOC, meta tags
→ All links are real <a href> — clicking navigates normally
→ Mermaid diagrams are pre-rendered SVG images
With JavaScript (progressive enhancement):
app-static.js loads
→ Intercepts TOC link clicks → fetch .html → swap <article>
→ history.pushState() → no page reload
→ Wires copy buttons, scroll spy, theme switching, overlaysWithout JavaScript:
Browser loads /content/blog/ddd.html
→ Full HTML with pre-rendered content, TOC, meta tags
→ All links are real <a href> — clicking navigates normally
→ Mermaid diagrams are pre-rendered SVG images
With JavaScript (progressive enhancement):
app-static.js loads
→ Intercepts TOC link clicks → fetch .html → swap <article>
→ history.pushState() → no page reload
→ Wires copy buttons, scroll spy, theme switching, overlaysTwo completely different experiences, same HTML source. The crawler sees content. The user gets SPA speed.
The Build Pipeline
A single Node.js script (build-static.js, ~600 lines) with an opt-in flag system:
--- build options (toggle 1-7, Enter to build, q to cancel) ---
1 [ ] Rebuild TOC
2 [ ] Clean output
3 [ ] Copy content images
4 [ ] Copy static files
5 [ ] Render mermaid SVGs
6 [x] Minify CSS/JS
7 [x] Generate HTML pages
0 Select all
build>--- build options (toggle 1-7, Enter to build, q to cancel) ---
1 [ ] Rebuild TOC
2 [ ] Clean output
3 [ ] Copy content images
4 [ ] Copy static files
5 [ ] Render mermaid SVGs
6 [x] Minify CSS/JS
7 [x] Generate HTML pages
0 Select all
build>Default: just pages + minify (~1 second). Toggle on what you need. Full build with mermaid: ~20 seconds.
Server-Side Markdown Rendering
Same marked.js + highlight.js at build time. Custom renderer mirrors the client exactly — same hierarchical slugs, same code blocks with copy buttons, same image size parsing.
One lesson learned the hard way: marked.js v17 passes raw text to renderers, not parsed HTML. ### [Part I: Vision](01-vision.md) rendered as literal [Part I: Vision](01-vision.md) until we added this.parser.parseInline(tokens) to both the heading and link renderers.
The Absolute Path Decision
First attempt: relative links (../blog/ddd.html). Worked until SPA navigation via history.pushState changed the URL depth — clicking from /index.html to /content/homelab.html meant all TOC links resolved against /content/ instead of /. Result: content/content/blog/ddd.html.
Fix: absolute paths everywhere. TOC links, content links, all /content/.... Works from any URL depth, before and after SPA navigation.
Mermaid: Dark + Light SVGs
The SPA loads mermaid.min.js (3.2 MB) to render diagrams at runtime. For static: pre-render each diagram as two SVGs using @mermaid-js/mermaid-cli:
<img src="/mermaid-svg/about--0-dark.svg"
data-src-dark="/mermaid-svg/about--0-dark.svg"
data-src-light="/mermaid-svg/about--0-light.svg"
class="mermaid-static"><img src="/mermaid-svg/about--0-dark.svg"
data-src-dark="/mermaid-svg/about--0-dark.svg"
data-src-light="/mermaid-svg/about--0-light.svg"
class="mermaid-static">A MutationObserver on data-color-mode swaps src when the theme changes. No 3.2 MB bundle needed.
Gotchas we hit:
- SVGs with
width="100%"render at 0 height as<img>— post-process to replace with viewBox width - Mermaid source with
<in flowchart arrows broke the regex — changed[^<]*to[\s\S]*? - 16 parallel mmdc processes crashed Chrome — lowered to 4 with retry-once
- Dark-theme colors invisible on light background — fixed all 144 style declarations across 20 files with a consistent color palette
- Image drag in fullscreen overlay — needed
e.preventDefault()on mousedown,dragstartprevention, ANDel.draggable = false
Per-Page SEO
Every page gets <title>, <meta description>, <meta keywords>, OG tags, Twitter cards, canonical URL, and JSON-LD structured data. Descriptions extracted from frontmatter (added to all 42 .md files) with auto-fallback to first paragraph.
Search Module
Extracted from app.js into standalone search.js (shared between dev and static). Enriched from title-only to: title + description + tags + company + role + stack. Results scored by field relevance.
The SOLID Refactor
The biggest architectural change: splitting duplicated code between app.js (dev SPA) and app-static.js (static SPA) into a shared layer.
js/
├── theme-switcher.js ← Shared: theme toggle
├── search.js ← Shared: search module
├── app-shared.js ← Shared: overlays, fade transitions, content
│ interactivity, favicon, scroll spy, parallax,
│ SidebarResize, TocTooltip, keyboard shortcuts
├── app-dev.js ← Dev SPA: fetches .md, renders with marked.js
├── app-static.js ← Static SPA: fetches .html, swaps <article>
└── markdown-renderer.js ← Dev only: marked.js custom rendererjs/
├── theme-switcher.js ← Shared: theme toggle
├── search.js ← Shared: search module
├── app-shared.js ← Shared: overlays, fade transitions, content
│ interactivity, favicon, scroll spy, parallax,
│ SidebarResize, TocTooltip, keyboard shortcuts
├── app-dev.js ← Dev SPA: fetches .md, renders with marked.js
├── app-static.js ← Static SPA: fetches .html, swaps <article>
└── markdown-renderer.js ← Dev only: marked.js custom renderer~550 lines of duplicated code eliminated. The scroll spy, overlay system, content wiring, sidebar resize, tooltip, keyboard shortcuts, and fade transitions are written once, used everywhere.
Event-Driven Scroll Spy
The scroll spy went through several iterations:
- v1:
setTimeout(100), setTimeout(500), setTimeout(1500)— unreliable - v2: Custom events
toc-headings-renderedandtoc-animation-done— better - v3: Event +
transitionendon the headings panel — no timers, fires exactly when the CSS grid transition completes
When the active heading is h4 (not in the inner TOC), the spy walks up the hierarchical slug: a--b--c--d → a--b--c → a--b until it finds a matching h2/h3 entry.
Progressive SPA Navigation
async function navigateTo(href) {
const html = preloadCache.get(href)
|| await fetch(href).then(r => r.text());
const doc = new DOMParser().parseFromString(html, 'text/html');
const newArticle = doc.getElementById('markdown-output');
await fadeOutContent(outputEl); // 80ms fade
outputEl.innerHTML = newArticle.innerHTML;
fadeInContent(outputEl); // 150ms slide-in
wireContentInteractivity(outputEl); // from app-shared.js
updateMermaidTheme();
scrollSpy.setup();
}async function navigateTo(href) {
const html = preloadCache.get(href)
|| await fetch(href).then(r => r.text());
const doc = new DOMParser().parseFromString(html, 'text/html');
const newArticle = doc.getElementById('markdown-output');
await fadeOutContent(outputEl); // 80ms fade
outputEl.innerHTML = newArticle.innerHTML;
fadeInContent(outputEl); // 150ms slide-in
wireContentInteractivity(outputEl); // from app-shared.js
updateMermaidTheme();
scrollSpy.setup();
}TOC links prefetch on hover ({ once: true }). By the time you click, the page is cached.
Same-page anchor links (#slug) use explicit contentEl.scrollTo() with getBoundingClientRect for reliable smooth scrolling — scrollIntoView was inconsistent inside the overflow-y: auto container.
Feature Parity
| Feature | Dev SPA | Static SPA |
|---|---|---|
| Content loading | XHR + marked.js | fetch .html + DOMParser |
| Code highlighting | Runtime hljs | Pre-highlighted |
| Mermaid | Runtime JS (3.2 MB) | Pre-rendered SVG dark+light |
| TOC | Built from JSON | Pre-rendered HTML |
| Inner heading TOC | From toc.json | From toc.json (async) |
| Scroll spy | Shared (app-shared.js) | Same |
| Image/diagram zoom | Shared overlay | Same |
| Keyboard shortcuts | Shared | Same |
| Sidebar resize | Shared | Same |
| TOC tooltip | Shared (inner + outer) | Same |
| Search | Shared (search.js) | Same + SPA callback |
| Fade transitions | Shared | Same |
| Analytics | Consent-gated, prod only | Same |
The Workflow
The interactive CLI (npm run work) evolved from a simple menu into a multi-server, guided-audit, opt-in build system:
--- workflow ---
DEV http://localhost:3000 STATIC http://localhost:3001
t Build TOC
b Build static (public/) ← opt-in flag menu
p Build + serve static
1 Restart DEV
2 Restart STATIC
a Audit (a11y / SEO / all) ← guided: what → themes → where
q Exit--- workflow ---
DEV http://localhost:3000 STATIC http://localhost:3001
t Build TOC
b Build static (public/) ← opt-in flag menu
p Build + serve static
1 Restart DEV
2 Restart STATIC
a Audit (a11y / SEO / all) ← guided: what → themes → where
q ExitBoth servers run simultaneously on auto-assigned ports. The audit wizard asks three questions: what (a11y / SEO+perf / all), themes (default / all 4), where (DEV / STATIC / PROD / all). A11y uses pa11y with axe+htmlcs across 4 theme combinations. SEO uses Lighthouse on every page from toc.json.
CLI mode for CI/CD:
npm run work -- --static --a11y-matrix # Start STATIC, run all themes
npm run work -- --prod --seo-matrix # SEO audit on production
npm run work -- --help # Show all opsnpm run work -- --static --a11y-matrix # Start STATIC, run all themes
npm run work -- --prod --seo-matrix # SEO audit on production
npm run work -- --help # Show all opsAnalytics with Consent
Vercel Web Analytics, but only on stephane-erard-cv.vercel.app (no localhost). Script loads only after explicit user consent (localStorage-persisted). Events tracked: pageview, toc_click, scroll_depth, copy_code, copy_link, diagram_view, contact_email, theme_change, search_click.
The Bugs
Some highlights from the debugging marathon:
MarkdownRendererbefore initialization — terser merged all JS into one scope,constTDZ broke cross-file references. Fix: minify each file separately- Double
content/content/— relative TOC links broke after SPApushState. Fix: absolute paths - SVG renders at 0×0 px — mmdc outputs
width="100%",<img>has no container. Fix: post-process SVGs to set pixel width from viewBox - Native browser tooltips on inner TOC —
text-overflow: ellipsison<a>triggers browser tooltip. Fix: move truncation to inner<span> #undefinedin URL — closure capturedpathparameter that was undefined in async context. Fix: use module-levelcurrentPathwith fallback- Heading links toggle TOC —
window.location.hash = ...triggered hashchange →loadPagewith same path → toggle. Fix:history.replaceStateinstead - No CSS variables without JS —
[data-color-mode]selector never matches without JS setting the attribute. Fix: add:rootdefaults for dark theme - a11y false positives — axe flags
color-contrastonaria-hidden="true"decorative elements. Accepted as known behavior.
The Numbers
| Metric | Value |
|---|---|
| Content pages | 43 |
| Pre-rendered mermaid SVGs | 120+ (60+ × 2 themes) |
| JS bundle (static) | ~25 KB (no marked/hljs/mermaid) |
| JS bundle (dev) | ~190 KB |
| Shared code (app-shared.js) | ~320 lines |
| CSS bundle | ~23 KB |
| Build time (pages only) | ~1s |
| Build time (full + mermaid) | ~20s |
| Bugs fixed in session | 47+ |
| Time | Started at midnight, finished at 4 AM |
What I Learned
Progressive enhancement is pragmatic, not retro. Same CSS, same DOM structure, same keyboard shortcuts. The difference is where the HTML comes from.
Pre-rendering mermaid was the biggest win. 3.2 MB of JavaScript replaced by cached SVGs. Dark/light variants via data attributes and a MutationObserver.
Events over timers. Every setTimeout we used for timing was eventually replaced by a proper event: transitionend, custom toc-headings-rendered, custom toc-animation-done. More reliable, self-documenting, and doesn't break when animations change duration.
Shared code needs boundaries. The SOLID refactor into app-shared.js + app-dev.js + app-static.js eliminated ~550 lines of duplication. The key insight: parametrize the differences (slug format path::slug vs slug, asset prefix, content source) and share everything else.
Build flags should be opt-in. Starting with "everything on, toggle off what you don't need" led to slow iteration. Flipping to "minimal by default, toggle on what you need" made the feedback loop instant.
Built at 4 AM with Node.js, marked.js, highlight.js, @mermaid-js/mermaid-cli, clean-css, terser, Claude, and too much coffee.