Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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>

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, overlays

Two 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>

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">

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, dragstart prevention, AND el.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 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:

  1. v1: setTimeout(100), setTimeout(500), setTimeout(1500) — unreliable
  2. v2: Custom events toc-headings-rendered and toc-animation-done — better
  3. v3: Event + transitionend on 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--da--b--ca--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();
}

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

Both 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 ops

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:

  • MarkdownRenderer before initialization — terser merged all JS into one scope, const TDZ broke cross-file references. Fix: minify each file separately
  • Double content/content/ — relative TOC links broke after SPA pushState. 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 TOCtext-overflow: ellipsis on <a> triggers browser tooltip. Fix: move truncation to inner <span>
  • #undefined in URL — closure captured path parameter that was undefined in async context. Fix: use module-level currentPath with fallback
  • Heading links toggle TOCwindow.location.hash = ... triggered hashchange → loadPage with same path → toggle. Fix: history.replaceState instead
  • No CSS variables without JS[data-color-mode] selector never matches without JS setting the attribute. Fix: add :root defaults for dark theme
  • a11y false positives — axe flags color-contrast on aria-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.