CRUMB a card from devarno-cloud

Client-Side Mermaid Render Pipeline

meridian beginner 4 min read

ELI5

Astro hands the browser a fenced code block tagged “mermaid”. The browser then dynamically loads the mermaid library, scoops out the lines from each block, makes a fresh <div class="mermaid">, and tells mermaid to redraw — once on first load, and again every time you click a sidebar link without a full reload.

Technical Deep Dive

The render hook is registered in astro.config.mjs as a Starlight head script.

Render Sequence

sequenceDiagram
autonumber
participant U as User
participant SL as Starlight page
participant SCR as head script
participant CDN as cdn.jsdelivr.net mermaid@11
U->>SL: navigate (hard load)
SL->>SCR: script tag executes
SCR->>CDN: import mermaid.esm.min.mjs
CDN-->>SCR: mermaid module
SCR->>SCR: mermaid.initialize startOnLoad:false theme:dark
SCR->>SCR: renderMermaid()
loop each pre[data-language="mermaid"]
SCR->>SCR: read .ec-line spans (or fallback textContent)
SCR->>SCR: replace .expressive-code wrapper with div.mermaid
end
SCR->>SCR: mermaid.run({ querySelector: '.mermaid' })
U->>SL: click internal link
SL-->>SCR: astro:after-swap
SCR->>SCR: renderMermaid() again

Why .ec-line Reconstruction

Starlight wraps fenced code blocks in expressive-code, which splits the source into per-line <span class="ec-line"> elements for syntax highlighting. Reading pre.textContent would still work but include zero-width whitespace and copy-button affordances; the per-line join produces a clean newline-separated string that mermaid’s parser tolerates.

const lines = pre.querySelectorAll('.ec-line');
const code = lines.length > 0
? Array.from(lines).map(l => l.textContent).join('\n')
: pre.textContent;

The lines.length > 0 guard preserves a fallback for blocks that arrive un-expressivecoded (e.g. dynamically injected at runtime).

View-Transition Survivability

Starlight (Astro) uses view transitions for soft navigation. The script’s module-scoped state (mermaid library + initialise call) survives, but the DOM is swapped — every navigation calls renderMermaid again via the astro:after-swap listener so newly-arrived <pre data-language="mermaid"> blocks get rewritten before they ever paint as plain code.

Initialisation

mermaid.initialize({ startOnLoad: false, theme: 'dark' }) disables mermaid’s own DOM scan; the script drives every render explicitly. Theme is hard-dark to match the Mars surface (see meridian-001).

Authoring Path

Markdown authors still write fenced ```mermaid blocks. remark-mermaidjs is in the dependency list as a build-time fallback rendering route, but the runtime script is what visibly renders the diagrams a reader sees on a Starlight page.

Key Terms

  • expressive-code → Starlight’s syntax-highlighting wrapper; emits <pre data-language="..."> with .ec-line children.
  • astro:after-swap → DOM event Astro dispatches after a soft view-transition completes.
  • startOnLoad → mermaid option that, when true, auto-renders on DOMContentLoaded. Forced false here to keep render under the script’s control.

Q&A

Q: What if the CDN is blocked by a CSP? A: The dynamic import() rejects, the script throws once, and every mermaid block stays as a plain code block. There is no inline fallback bundled with meridian today.

Q: Why replace pre.closest('.expressive-code') rather than pre itself? A: The expressive-code wrapper carries the copy button, frame, and title chrome — replacing only pre leaves orphaned chrome around the rendered SVG. closest('.expressive-code') || pre falls back when the wrapper is absent.

Q: Does the rerender cost grow with page size? A: Yes — mermaid.run({ querySelector: '.mermaid' }) walks every .mermaid div on the page each astro:after-swap. For pages with many diagrams the cost is real but bounded by current page contents, not history.

Examples

Author writes:

```mermaid
flowchart LR
A --> B
```

The build emits <pre data-language="mermaid"><span class="ec-line">flowchart LR</span><span class="ec-line"> A --> B</span></pre>. On first paint the script reads the two .ec-line spans, joins them with \n, replaces the .expressive-code wrapper with <div class="mermaid">flowchart LR\n A --> B</div>, and mermaid.run paints SVG into it.

neighbors on the map