This package exposes the Mermaid CLI as a repository-wide Bazel tool. Bazel provisions the pinned Node toolchain, JavaScript dependency graph, Mermaid CLI, and Chrome-for-Testing browser used by Puppeteer. Rendering runs as a Bazel target or action; it does not use a host browser, download one during an npm lifecycle hook, or fetch scripts or fonts from a CDN.
theme.json owns the diagram appearance. It applies a
documentation palette, typography, layout, and the handDrawn look to every
diagram, so rendered work shares one visual contract instead of Mermaid’s
built-in default. Shapes stay semantic: () and other inherently rounded
shapes render rounded, while [] boxes render as sketched rectangles. Nothing
forces every node into a rounded outline.
The mermaid_svg rule and the mmdc target both apply the theme by default.
A diagram can override any part of it with its own Mermaid directive, which
Mermaid merges over the file config:
%%{init: {"look": "classic"}}%%
flowchart LR
a[Square] --> b(Rounded)
Node label paragraphs have 2px of horizontal padding. The pinned handwriting
font can paint beyond a string’s measured advance: openai.com measures 78px
but its final glyph reaches 79px. Mermaid clips HTML labels to their measured
foreignObject, so the padding reserves room for that overhang before layout.
Label foreignObject elements also use overflow: visible: if an SVG viewer
falls back to a wider font, its glyphs can extend into the node’s existing
padding instead of being cut off at the narrower measured frame. A fallback
Arial render reproduces right-edge clipping on the hostname without this rule.
The rule selects .label > * because Mermaid lowercases element selectors;
foreignobject would not match the case-sensitive SVG foreignObject name.
themeCSS boxes every edge label and every cluster title, so an annotation
reads as a caption instead of sitting on the edge line. The rule styles the
<p> inside a real label and leaves .labelBkg transparent; styling
.labelBkg directly would draw a stray box wherever an edge carries no text.
Mermaid emits an empty label plate for unlabelled edges under the dagre
renderer but omits it under ELK, so the rule is what the test asserts rather
than any particular empty element.
Each boxed label carries a 3px margin. Under the dagre renderer Mermaid sizes
a label’s <p> to the text only, so the frame clips the outer half of the 1px
border and a label appears to have a missing right edge; the margin keeps the
border inside the clip rect. Under ELK the frame already includes the margin,
so the value there is breathing room rather than a correctness fix.
The cluster title plate pins font-size: 13px. ELK sizes a container from the
width of its own title plate, so a child container whose title is wider than
its parent’s escapes the parent on both sides. Nesting a hostname title inside
a short parent title is the case that exposes this: at the inherited 16px
the child measured 333px against a 302px parent and escaped 20px per side,
while 13px fits it with 7px of clearance per side. The plate is a container
label, not body text, so the smaller size also keeps it visually subordinate.
flowchart.defaultRenderer selects dagre-wrapper, Mermaid’s Dagre renderer.
Diagrams use left-to-right flow by default. Dagre’s nodeSpacing and
rankSpacing control spacing between nodes and ranks, while the title styling
below keeps nested containers readable.
flowchart.subGraphTitleMargin.top is negative on purpose. Under dagre a
nested cluster keeps a fixed 20px inset from its parent that does not grow with
nodeSpacing, rankSpacing, padding, or this margin, so the parent title
would be overlapped by the container nested inside it, and the negative margin
is the only thing that lifts it clear. ELK does not need the lift, but both
renderers share this one config, so the value stays and keeps the title on the
cluster border. Remove it only after re-checking a diagram with nested
subgraphs.
The cluster title additionally forces line-height: 1, which keeps the plate
short enough to sit on the border consistently instead of spilling past it.
handDrawnSeed is fixed, so the sketch jitter is stable rather than different
on every render. Pass theme on the rule or a later -c argument to the CLI
to replace the whole config for one diagram; because a second -c file
replaces rather than merges, override only the specific keys you need with a
directive.
Paint order
Mermaid emits a container title in <g class="cluster-label"> inside
<g class="clusters">, and emits that group before the edges, the edge labels,
and the nodes. SVG paints in document order and offers no other layer control:
z-index needs a CSS-positioned box and does not reorder SVG elements, and
paint-order only orders fill, stroke, and markers within a single element.
An edge that crosses a container border therefore paints over the title plate
and appears to run through the title text. Mermaid exposes no z-order, layer,
or paint-order configuration key, so this is fixed by rewriting the SVG rather
than by a theme setting.
The current theme already does everything CSS can do here, so the remaining
crossing is the one case CSS cannot reach. The title plate is the topmost paint
at its own plate coordinates, and on the host_bot diagram one edge crosses the
External services plate and two cross the LLM BM plate. A rasterised check
draws a white plate and then a red line over it and reads the pixel at the
crossing: reordering the document turns that pixel white, while z-index,
z-index with position:relative or position:absolute, isolation:isolate,
paint-order, transform:translateZ(0), will-change:transform, a
filter:drop-shadow stacking context, and opacity:0.999 all leave it red.
Only document order moves an SVG group above a later sibling, which is why the
lift is a rewrite and not a rule.
cmd/render/paint_order.mjs moves every title group to the end of the outer
root group after Mermaid writes the file. Dagre places nested containers in
translated groups; the lift composes their ancestor transforms onto each title
so its position stays unchanged. Empty, self-closing paint layers do not affect
the group scan. A browser test checks both title coordinates and visible
stacking. Both entry points apply the lift, so a diagram rendered through
mermaid_svg and the same diagram rendered through mmdc paint identically.
mmdc rewrites the file named by -o or --output; an SVG printed to stdout
is not rewritten.
Diagram direction
Write flowchart LR for left-to-right flow. Direction belongs to the diagram
source; the shared theme selects Dagre but does not override an explicit
diagram direction. Use another direction when the diagram or the user requires
it.
Subgraphs can declare direction TB, but Mermaid ignores that direction when
their internal nodes connect outside the group. Both Dagre and ELK exhibit
this limitation on the T3 Code graph, so verify the rendered arrangement rather
than assuming a direction declaration took effect.
Actors
An actor is an ordinary rounded node marked with the actor class. The theme
owns the figure: a .actor div rule paints a hand-drawn stick figure as a
background-image and reserves room with padding-left. A diagram therefore
carries only its label text:
remote("Actor")
classDef actor font-style:normal;
class remote,intranet actor
The classDef is required even though the theme supplies the appearance.
Mermaid emits a node’s class attribute only when a classDef declares it, so a
bare class statement leaves the node unstyled and the figure does not paint.
Keeping the figure in the theme rather than in the diagram means the ~1.2 KB of base64 SVG is written once, every actor node shares it, and the diagram source states topology only. Sizing is fixed by the theme, so all actors match: the figure plus the renderer’s padding lands an actor node at the same height as a single-line service node.
The rule rides in every rendered document, including diagrams with no actor
node, where it adds about 1.3 KB to the inline stylesheet. That is the cost of
owning appearance centrally; it is acceptable because the rule is inert without
an actor node and every diagram already carries a much larger stylesheet. The wobble is baked into the path coordinates with a
fixed seed rather than applied through an SVG filter, so the shared figure
carries no <defs> id that would collide when the background repeats.
Branching edges
Use a visible junction node when several outgoing edges share one label:
codex ---|"Inference"| inference@{ shape: junction }
inference --> openai
inference --> openrouter
The incoming segment carries the shared label without an arrowhead, and the
outgoing segments point to their respective targets. The junction is a real
flowchart node and affects layout, so inspect the rendered spacing. This is
the form used by the maintained T3 Code diagram. The separate
architecture-beta diagram type has its own junction declaration syntax.
Layout width
Dagre uses nodeSpacing between nodes in a rank and rankSpacing between
ranks. For an LR diagram, rank spacing contributes to width and node spacing
contributes to height. These settings do not impose a maximum canvas width;
labels, topology, and nested groups also affect the result. Check the rendered
diagram at its intended display size before adjusting spacing or direction.
Plain renders
mermaid_svg(..., plain = True) and MERMAID_PLAIN=1 render with Mermaid’s own
defaults instead of the repository appearance: no theme, no paint-order
post-processing, and no pinned fonts. The output is deliberately neither themed
nor hermetic, and exists to compare a diagram against the upstream appearance.
A plain action declares neither the theme nor the fonts as inputs, so it cannot
depend on them by accident; //users/simeonwarren/host_bot:rendered_plain is a
worked example. Plain output is not published and not committed.
upstream-theme.json is the themed counterpart to that
opt-out. It selects Mermaid’s built-in default theme and pins the label family
to a font the renderer already carries, so the result is hermetic like any other
render and shows the upstream appearance rather than a host-dependent one. Use
it when a consumer has to show the contrast between Mermaid’s own look and the
repository theme, as the blog post on this work does:
mermaid_svg(
name = "example_default",
src = "example.mmd",
out = "rendered/example-default.svg",
theme = "//tools/mermaid:upstream-theme.json",
)
This is a comparison input, not a second appearance the repository maintains. It
exists so a before-and-after pair renders from one source through the same
pipeline; a diagram’s own look still comes from theme.json.
Native site diagrams
The main site’s mermaid_site_svg macro lives in
projects/alwaldend.com/pkg/mermaid/defs.bzl.
It selects mermaid_svg(native = True) with the site’s own configuration,
compiled palette, and pinned Liberation Sans font. It does not inherit the
historical theme or actor decoration. Its own preset restores rounded caption
plates using Mermaid’s themeCSS and flowchart settings. The site’s compact
preset uses basis curves and flowchart.edgeSpacing; the
pinned dependency patch
passes the latter through to Dagre before layout, including nested subgraphs.
Native mode requires a palette CSS input. mermaid_svg also requires a
dark_out SVG output; mermaid_webp encodes the selected color_scheme
(light by default, or dark). The
stylesheet exposes --mermaid-* custom properties named after Mermaid theme
variables. Chrome resolves them in light and dark media, and the renderer
passes the resulting values to Mermaid’s native configuration and exposes the
same --mermaid-* properties on its diagram root for input themeCSS. Font embedding
uses the CLI’s CSS input. The returned SVG bytes are written directly: there
is no color rewriting, paint-order change, or stylesheet insertion afterwards.
Both outputs are packaged beside each other. The site selects the .dark.svg
sibling using its existing theme attribute and always prints the light image.
A diagram used by documentation and a historical blog post can expose both
render targets from one authoritative .mmd source. Blog consumers keep their
original mermaid_svg target and its existing appearance.
Raster renders
mermaid_webp renders the same diagram source to a WebP image for a consumer
that has to upload the file to a service. Some upload endpoints accept only a
fixed set of raster media types and reject image/svg+xml, so such a consumer
cannot use the SVG rule at all.
load("//tools/mermaid:defs.bzl", "mermaid_webp")
mermaid_webp(
name = "rendered",
src = "architecture.mmd",
out = "rendered/architecture.webp",
)
The rule projects the mermaid_svg document to a raster. By default it renders
the historical themed SVG first — same shared theme, same pinned browser and
fonts, same paint-order pass — then has the pinned browser encode that document
to WebP. Set native = True and pass the same palette, theme, and
label_font as a native SVG target to encode that target’s appearance.
Set color_scheme = "dark" for its dark appearance; the default remains
"light". Native rendering reads the palette’s selected media values, embeds
the selected font through Mermaid’s CSS input, and sends the returned SVG straight to the
existing encoder without SVG post-processing. native and palette must be
set together; selecting "dark" requires native rendering. The site macro
mermaid_site_webp accepts the same color_scheme option. A WebP carries one
fixed appearance for page embeds, social cards, and service uploads.
Encoding stays inside the hermetic graph and adds no image-conversion
dependency. The raster is authored at the diagram’s
natural size and scaled by the rule’s scale (default 1). Set scale = 2
when a consumer needs higher pixel density for text or re-encoding. A raster render
always uses the maintained appearance; it has no plain opt-out, because a
plain raster could not show the contrast a comparison needs any more reliably
than the SVG rule’s.
WebP canvases default to aspect_ratio = [5, 2]. The
encoder reduces the two positive integer terms and adds the selected theme’s
background around the scaled diagram. It uses the smallest whole-pixel canvas
with that exact ratio that contains the original image, centers the diagram,
and never crops or stretches it. SVG output is unaffected. Set aspect_ratio = []
for the natural raster canvas, or select another positive integer pair. The CLI
uses the same default; --aspect-ratio natural selects natural output, and two
repeated numeric --aspect-ratio flags select another ratio.
scale must be a positive integer, and the
final image must fit WebP’s 16383-pixel limit in each dimension.
Consumers that must keep the image as a checked-in asset obtain it from the
rule’s output rather than by hand, so the asset can be regenerated and compared
against a fresh render. The .mmd source stays authoritative. Blog posts keep
the raster render beside index.md by copying it through write_source_files,
as the diagram skill describes.
Tests
test/renderer/renderer_test.go renders fixture diagrams through the
mermaid_svg rule and asserts the contract of the theme: the documentation
palette and hand-drawn node class survive, the built-in lavender colors do not,
labels are measured through the pinned fonts, and the label plates keep their
border slack. It also asserts that every container title is written after the
nodes, which is the paint order cmd/render/paint_order.mjs produces and the
property that keeps an edge from drawing over a title. The same package renders
a WebP fixture and asserts the raster reproduces the SVG canvas at the rule’s
scale, so a raster that stopped following the maintained document fails there.
test/renderer/native_raster_test.mjs compares light and dark native WebPs with
their separately built SVGs, checks each selected site palette, visible label
pixels, and the embedded sans-serif font, and verifies unchanged scaled canvas
geometry. The test saves each reference and actual image and a JSON measurement
report in its undeclared outputs for repeatable inspection.
test/renderer/aspect_ratio_test.mjs checks horizontal and vertical padding,
minimal exact-ratio dimensions, unchanged content scale, and theme backgrounds
against independently rendered SVG references. It also exercises invalid size
and ratio inputs, and saves reference rasters and measurement reports.
cmd/mmdc/mmdc_test.go
drives the interactive CLI and re-renders under a Fontconfig pointed at a
monospace-only pool; a byte-identical result shows the CLI resolved text
through the pinned fonts.
Hermetic rendering
Mermaid measures label text through Fontconfig, so a renderer that follows the
host would produce different canvas geometry on every machine. Both entry
points instead expose only the repository’s pinned fonts to Chrome:
cmd/render/fontconfig.mjs writes a Fontconfig file whose sole <dir> values
are the directory holding the pinned handwriting face and the directory holding
the pinned Liberation set, and whose aliases map the families the theme requests
onto them. The same file is applied by the build action and by an interactive
mmdc run, so both resolve identical text metrics.
The generated Fontconfig must use absolute paths. Chrome is a separate process
from the launcher, and a relative <dir> resolves against whatever working
directory it inherits rather than the action’s execroot. Passing a caller’s own
-p/--puppeteerConfigFile hands full control of the browser launch, and
including the font aliases in it, to that caller.
Pinned fonts fix the metrics a render measures with, but not the metrics a
consumer displays those glyphs with. A documentation page embeds an SVG through
<img>, and an SVG loaded that way is an isolated document: it cannot see the
page’s webfonts, loads no network font, and falls back to whatever the viewer’s
system provides. A host font is wider than the pinned handwriting face, so
github.com overflowed the label box Mermaid sized with the pinned font and
appeared clipped, even though the file was correct. cmd/render/embed_font.mjs
therefore inlines the pinned face as a @font-face data URI in every themed
document, so the glyphs travel with the SVG and the label metrics match
everywhere, including in a viewer that can load no webfont at all. The rule
adds about 50 KB to a rendered diagram; that is the cost of an image that is
correct wherever it is displayed. Its family name is read from the font’s own
name table rather than hard-coded, so a font swap cannot silently desync it
from the theme.
The JavaScript launcher runs from Bazel’s output tree. Pass absolute paths when rendering source-tree files directly:
repo_root="$PWD"
bazel_agent bazel run //tools/mermaid:mmdc -- \
-i "${repo_root}/path/to/diagram.mmd" \
-o "${repo_root}/path/to/diagram.svg"
Declare a maintained diagram as a mermaid_svg action and feed the output to
its consumer, which uses declared Bazel paths and needs no absolute-path
handling. The diagram skill defines direct
documentation consumption and checked-in publication assets for blog posts.
The .mmd remains the authoritative diagram source in both cases.
The pinned upstream binary is owned by
third_party/com_google_chrome_headless_shell.