@wave-rf/astro-themed-mermaid
v0.3.1
Published
Build-time, theme-aware Mermaid diagrams for Astro / Starlight — color-agnostic SVG rewriting (CSS-variable theming, cluster-title pills, edge-label pills) on top of rehype-mermaid.
Maintainers
Readme
@wave-rf/astro-themed-mermaid
Build-time, theme-aware Mermaid diagrams for Astro /
Starlight docs sites. Renders diagrams with
rehype-mermaid (inline SVG,
SSR'd at build), then rewrites the emitted SVG to:
- survive Chromium's HTML parser —
<br></br>→<br>(Mermaid emits the former; the void end tag otherwise renders an extra line and overflows the<foreignObject>); - respond to light/dark themes — baked colors are rewritten to
var(--…)references you supply, so a runtime stylesheet drives them; - polish flowcharts — cluster-title pills are re-centered over the subgraph border and lifted above edges/nodes; Mermaid's forced white label color is stripped so themed text works in light mode; the viewBox is expanded so the straddling title pill isn't clipped.
It is color-agnostic: the module defines no colors. You pass the Mermaid theme, the classDef palette, and the hex→CSS-var replacement map; the displayed colors live wherever those CSS variables are defined (your stylesheet). That's what makes it shareable across docs sites with different brands.
Install
pnpm add @wave-rf/astro-themed-mermaid rehype-mermaidrehype-mermaid (and its peer mermaid) is a peer dependency — you wire it up
yourself (see below).
Published under semver: ^0.3.0 tracks features and fixes without breaking
changes (during 0.x, breaking changes bump the minor). For the bleeding edge,
the dev dist-tag follows main: pnpm add @wave-rf/astro-themed-mermaid@dev.
Diagram SSR needs a headless Chromium.
rehype-mermaiduses Playwright —pnpm exec playwright install chromium(Astro/Starlight setups usually do this already).
Usage
// astro.config.mjs
import { defineConfig } from "astro/config";
import { themedMermaid } from "@wave-rf/astro-themed-mermaid";
const mermaid = themedMermaid({
font: { family: '"Inter Variable", sans-serif', woff2: "/abs/path/to/inter.woff2" },
themeVariables: { primaryColor: "#14171C", lineColor: "#6B7280", /* … */ },
classDefs: ["classDef wh fill:#0e7f8f,stroke:#5bbfcf,color:#fff,stroke-width:3px", /* … */],
colorReplacements: [
["#14171C", "var(--mermaid-surface)"],
["#0e7f8f", "var(--mermaid-wh-bg)"],
// …baked hex (exactly as Mermaid serializes) → your CSS variable
],
flowchart: { curve: "basis", useMaxWidth: true /* … */ },
sequence: { useMaxWidth: true, wrap: false },
});
export default defineConfig({
markdown: {
remarkPlugins: [mermaid.remarkInjectClassdefs],
rehypePlugins: [mermaid.rehypeMermaid],
},
integrations: [/* starlight(...), */ mermaid.integration],
});Then author diagrams normally, using the injected classes:
```mermaid
flowchart TD
A["Client"]:::client --> B["WaveHouse"]:::wh
```A complete, copy-pasteable config + stylesheet lives in example/.
Render cache
mermaid.rehypeMermaid wraps rehype-mermaid with a per-diagram render cache,
because diagram SSR goes through a headless Chromium and dominates the build
time of any site that didn't touch its diagrams — i.e. almost every build. The
cache is content-addressed: each entry is keyed on
sha256(diagram source + render options + package versions), so theme,
config, and toolchain changes invalidate automatically, and the entry stores
the rendered hast element exactly as rehype-mermaid would have spliced it
(ids are rewritten to content-derived ones so entries from different builds
can't collide on one page). A document whose diagrams all hit never launches
the browser — nor even imports rehype-mermaid; a document with any miss is
rendered by rehype-mermaid as one normal batch and harvested back into the
cache. Cache I/O is best-effort: a corrupt or unwritable cache degrades to a
normal render, never a failed build.
Entries land in node_modules/.cache/astro-themed-mermaid/ by default —
delete it freely. Configure via cache:
themedMermaid({ cache: ".mermaid-cache" }); // custom dir (resolved from cwd)
themedMermaid({ cache: false }); // disable; render every buildIn CI the directory is cold unless you persist it (e.g. actions/cache keyed
on the lockfile); with it persisted, diagram-free doc changes skip Chromium
entirely. The uncached spelling
rehypePlugins: [[rehypeMermaid, mermaid.rehypeMermaidOptions]] keeps working
if you prefer to wire rehype-mermaid yourself.
Styling
The plugin rewrites SVG geometry and swaps in your CSS variables; the matching visuals (pill chips, drop-shadows, typography) are CSS. Two options:
Use the bundled stylesheet (fastest):
// in your global CSS, or customCss in Starlight import "@wave-rf/astro-themed-mermaid/styles.css";then define the color variables it reads (
--mermaid-surface,--mermaid-ink,--mermaid-ink-muted,--mermaid-border,--mermaid-cluster-border) plus thecolorReplacementsright-hand sides (--mermaid-wh-bg, …) for light/dark. Seeexample/mermaid.css.Write your own scoped via
svg[aria-roledescription^="flowchart"]— copystyles.cssas a starting point and tune freely.
Paired magic numbers. The plugin expands the flowchart viewBox up by 22px (
PAD_TOPinindex.mjs) precisely so the cluster-title pill — shifted uptranslateY(-17px)in the CSS — isn't clipped. If you change one, revisit the other.
Config
| key | type | purpose |
|---|---|---|
| font.family | string | font for build-time SSR measurement |
| font.woff2 | string (abs path) | woff2 inlined into build Chromium so it measures with the real font |
| measurementCss | string | label-metric CSS injected at measure time (see Label clipping) |
| themeVariables | object | Mermaid base theme variables (build-time hex) |
| classDefs | string[] | classDef … lines injected into every flowchart/graph block |
| colorReplacements | [from,to][] | baked color → runtime CSS var; the only place colors enter |
| flowchart, sequence | object | non-color Mermaid config |
| securityLevel | string | Mermaid securityLevel (default "strict") |
| mermaidConfig | object | escape hatch for other non-color Mermaid settings; merged beneath the module's own config (which wins) |
mermaidConfigis for Mermaid settings this plugin doesn't surface directly (e.g.gantt,er,pie,htmlLabels,maxTextSize). It's merged beneath the module's own config, so it can't override the theme/color/security settings — the color-agnostic invariant holds.
The factory returns four things to wire up:
| return value | wire into |
|---|---|
| remarkInjectClassdefs | markdown.remarkPlugins |
| rehypeMermaid | markdown.rehypePlugins (recommended — the cached drop-in) |
| rehypeMermaidOptions | markdown.rehypePlugins: [[rehypeMermaid, …]] (uncached; wire rehype-mermaid yourself) |
| integration | integrations |
Label clipping
Mermaid sizes each node's box by measuring the label in the build-time
Chromium; the browser then displays it. If display is even ~1px wider than
the measurement, the <foreignObject> crops the last glyph (Buffer Consumer
→ Buffer Consume). Two things cause that drift, and both are fixed by feeding
the build the same inputs the browser uses:
Font — set
font.woff2so the real font is loaded at measure time (a fallback like Arial measures ~6–8% narrower than Inter).Label metrics — if your stylesheet renders labels at, say,
font-weight: 500(or anyletter-spacing), Mermaid measured them at the default400and every box is a hair too narrow. Pass those rules asmeasurementCss:themedMermaid({ font: { family: '"Inter Variable", sans-serif', woff2: "/abs/inter.woff2" }, // matches what your global CSS applies to node labels at display time, // plus a hair of padding for sub-pixel safety: measurementCss: ".nodeLabel p{font-weight:500;letter-spacing:-0.005em;padding-inline:1.5px;}", });Selectors must be bare. Mermaid measures the label before it's parented by the final
svg[aria-roledescription="flowchart…"], so a rule scoped assvg[aria-roledescription^="flowchart"] .nodeLabel p { … }matches at display time but is a no-op at measure time. Drop the ancestor:.nodeLabel p { … }. (Edge/cluster labels usually render asoverflow: visiblepills and don't need this — scope to.nodeLabel p.)
How it works
classDefs are injected (via the remark plugin) after each flowchart/graph
header, so source diagrams use :::name without restating the palette. Mermaid
renders to inline SVG at build time with concrete hex (it needs real colors to
lay out geometry). The Astro integration then post-processes the built HTML:
normalizes <br></br>, swaps each baked hex for your var(--…), strips
Mermaid's forced-white label color, and (for flowcharts) lifts + centers the
cluster-title pills and pads the viewBox. The hex are effectively sentinels —
only the right-hand side of colorReplacements (your var names) reaches the
browser, where your stylesheet drives the actual colors in both themes.
Caveats
The SVG rewriting is regex over Mermaid's output, which can change between
Mermaid versions (developed against Mermaid v11.x). Pin mermaid and
re-verify diagrams after upgrades. pnpm test runs a smoke test over the
rewrite passes to catch gross breakage.
License
MIT © Wave RF
