Skip to content

sphinx-gp-mermaid: build-time Mermaid diagrams as dual inline SVG#61

Open
tony wants to merge 5 commits into
mainfrom
mermaid
Open

sphinx-gp-mermaid: build-time Mermaid diagrams as dual inline SVG#61
tony wants to merge 5 commits into
mainfrom
mermaid

Conversation

@tony

@tony tony commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Add sphinx-gp-mermaid, a workspace package rendering MyST mermaid fences to inline SVG at build time via mmdc (@mermaid-js/mermaid-cli) — no client-side mermaid runtime, no async pop-in, no layout shift, and diagrams ride SPA navigation as finished DOM.
  • Render twice, toggle with CSS: each diagram produces a light and a dark SVG (palettes mapped to gp-furo tokens), both inlined and switched on body[data-theme]. Mermaid bakes id-scoped !important colors into its SVGs, so a single render cannot follow the theme toggle.
  • Cache aggressively: SVGs are content-addressed under <confdir>/_mermaid_cache, keyed on render version + theme + full mermaid config JSON + source — styling changes bust the cache, and the cache survives rm -rf docs/_build. The extension excludes the directory from source discovery automatically.
  • Degrade, don't fail: a missing or failing mmdc warns once per builder and falls back to the escaped diagram source; text/latex/man/texinfo builders emit a [diagram: <alt>] stand-in instead of crashing on the node.
  • Route plain fences at conf level: merge_sphinx_config() sets myst_fence_as_directive = ["mermaid"] whenever the extension lands in the final extension list; explicit overrides still win.
  • Provenance: extracted from tmuxp's docs/_ext/mermaid_inline.py (Docs: build-time mermaid + tmux-layout diagram rendering tmux-python/tmuxp#1071), where it is proven in production docs. tmuxp adopts this package in the companion PR.

Changes by area

New package: packages/sphinx-gp-mermaid

  • sphinx_gp_mermaid/__init__.py: mermaid directive (:caption:, :alt:, :name:) → marker node → HTML write-phase visitor → mmdc subprocess → dual normalized SVGs in a <figure>. Renderer resolution walks mermaid_cmd config → <confdir>/node_modules/.bin/mmdcPATH; puppeteer config is generated with --no-sandbox and a Chrome discovered in puppeteer's cache. SVG normalization rewrites mermaid's hardcoded my-svg id (attribute, scoped <style>, url(#…) marker refs) to a per-diagram-per-theme id, replaces width="100%" with explicit width/height from the root viewBox, and strips the inline max-width.
  • _static/css/sphinx_gp_mermaid.css: ships inside the package and registers at builder-inited. Classes use the workspace namespace: gp-sphinx-diagram, gp-sphinx-diagram__variant--theme-{light,dark}, gp-sphinx-diagram__fallback.

Coordinator: gp_sphinx/config.py

  • Fence routing auto-set when sphinx_gp_mermaid is active; documented with a doctest.

Workspace wiring

  • Root pyproject.toml (uv source, dev group, isort first-party, doctest testpath), wheel smoke runner in scripts/ci/package_tools.py, ux cluster assignment in docs/_ext/package_reference.py, docs landing stub, and a legacy extensions/ redirect.

Tests: tests/ext/mermaid/

  • Unit: SVG normalization, digest stability, palettes, visitor markup, renderer-missing fallback, cache idempotency, setup registration.
  • Integration: a full HTML build over a stub fake_mmdc.py that honors the -i/-o/-c contract and bakes themeVariables.primaryColor into the SVG — proving the light and dark configs each cross the subprocess boundary; plus a text-builder build asserting the alt-text stand-in.
  • Palette tripwire: scrapes gp-furo-tokens light.ts/dark.ts and asserts every non-font themeVariable equals its token value, so a token retune fails in CI instead of drifting silently.

Design decisions

  • Dual render instead of client-side theming: mermaid's theming engine only accepts literal hex (derived colors are computed at render time), and its output styles are id-scoped with !important — one SVG cannot be re-themed by page CSS. Two renders share identical geometry, so the CSS display toggle never shifts layout.
  • Conf-level fence routing: myst-parser snapshots myst_* config at its own config-inited, so an extension mutating config from setup() lands too late; merge_sphinx_config is the deterministic place.
  • Opt-in, not DEFAULT_EXTENSIONS: rendering requires a node toolchain (mermaid-cli + headless Chrome). Auto-loading would push that onto every consumer for a capability few docs use today.
  • Warn-once memo on the builder rather than a module global, keeping the extension's parallel_write_safe declaration honest.

Test plan

  • uv run ruff check . and uv run ruff format . — clean
  • uv run mypy . — strict, clean
  • uv run py.test --reruns 0 — full suite including module doctests
  • just build-docs — landing page renders; setup() replays cleanly through the package-reference recorder
  • tests/ext/mermaid/test_integration.py — both fence spellings render, dual variants carry per-theme fills, normalization holds, options pass through, packaged CSS ships, cache is excluded and populated, figure markup snapshot is stable
  • tests/ext/mermaid/test_palette_sync.py — palettes match gp-furo tokens in both themes
  • Consumed end-to-end from tmuxp (companion PR): all existing production diagrams render from cache with zero re-renders

Companion PR

@codecov-commenter

codecov-commenter commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.99037% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.50%. Comparing base (f554d4c) to head (e12a0ca).

Files with missing lines Patch % Lines
...phinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py 89.05% 22 Missing ⚠️
scripts/ci/package_tools.py 20.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #61      +/-   ##
==========================================
+ Coverage   92.43%   92.50%   +0.06%     
==========================================
  Files         258      263       +5     
  Lines       20327    20846     +519     
==========================================
+ Hits        18790    19283     +493     
- Misses       1537     1563      +26     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added 5 commits July 1, 2026 19:42
why: Build-time mermaid rendering is proven in tmuxp's docs but lives in
its docs/_ext, unavailable to other gp-sphinx consumers. Scaffold the
workspace package so the renderer can move in and be reused.

what:
- packages/sphinx-gp-mermaid: pyproject (sphinx>=8.1, hatchling),
  README, py.typed, skeleton setup() returning parallel-safe metadata
- Root pyproject: uv source, dev group, isort first-party, pytest
  testpath entries
- package_tools: smoke_sphinx_gp_mermaid wheel smoke runner
- package_reference: assign the ux cluster
- docs: landing stub + legacy extensions/ redirect
- tests: add to the publishable-package set
why: The renderer is proven in tmuxp production docs; moving it into the
workspace package makes it installable by every gp-sphinx consumer.

what:
- Full pipeline: mermaid directive -> mermaid_inline node -> HTML
  write-phase visitor -> mmdc subprocess -> dual light/dark inline SVG
- Content-hash SVG cache under <confdir>/_mermaid_cache, keyed on
  render version, theme, full mermaid config JSON, and source; cache
  key inputs unchanged from the tmuxp version so existing caches hit
- mmdc resolution (config -> confdir node_modules -> PATH), puppeteer
  config generation with cached-Chrome discovery, graceful degradation
  to escaped source when the renderer is missing
- Config values: mermaid_cmd, mermaid_puppeteer_config
- CSS classes join the workspace namespace: gp-sphinx-diagram,
  gp-sphinx-diagram__variant--theme-{light,dark},
  gp-sphinx-diagram__fallback; stylesheet ships in the package's
  _static and registers at builder-inited
- Unit tests: SVG normalization, digest, palettes, visitor, fallback,
  cache idempotency, setup registration, static-path idempotence
why: The HTML-only visitor left text/latex/man/texinfo builds to crash
on an unhandled node, and the module-global warn-once flag contradicted
the extension's parallel_write_safe declaration.

what:
- text/man/latex/texinfo visitors emit an "[diagram: <alt>]" stand-in
  (alt falling back to caption), following sphinx.ext.graphviz
- Warn-once memo moves to a builder attribute (imgmath's pattern), so
  each writer process warns at most once and fresh builds start clean
- setup() excludes _mermaid_cache from source discovery at config-inited
- _render treats OSError (e.g. a resolved but non-executable command)
  as MermaidRendererMissing instead of crashing the build
- Tests: fallback-text cases, all four visitors, per-builder memo,
  PermissionError degradation, and a text-builder integration scenario
why: The unit suite never exercises the fence -> directive -> subprocess
-> dual-figure HTML pipeline, and the palette hexes are hand copies of
gp-furo-tokens values that could drift silently.

what:
- Full HTML build over a stub mmdc (a python script honouring the
  -i/-o/-c contract) that bakes themeVariables.primaryColor into the
  SVG, proving both theme configs cross the subprocess boundary
- Asserts both fence spellings render, SVG normalization, option
  passthrough (caption/alt/name), packaged CSS shipping, cache
  exclusion and population, and a stable figure-markup snapshot
- Palette tripwire: scrapes light.ts/dark.ts custom properties and
  checks every non-font themeVariable against its token (black/white
  keywords normalized), plus a coverage guard for new palette keys
why: myst-parser snapshots myst_* config at its own config-inited, so
sphinx_gp_mermaid cannot reliably register its fence routing from
setup(); conf level is the deterministic place, and consumers should
not need boilerplate for it.

what:
- merge_sphinx_config() sets myst_fence_as_directive = ["mermaid"]
  whenever sphinx_gp_mermaid lands in the final extension list
- Explicit myst_fence_as_directive overrides still win (overrides
  apply last)
- Tests: routing on with the extension, absent without it, override
  wins; docstring doctest documents the behavior
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants