diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c589d98ef8..a0df03bdec 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,6 +4,10 @@ on: push: branches: - master + # TEMPORARY [DO NOT MERGE]: deploy the sphinx-gp-mermaid migration + # branch so the packaged renderer can be verified on the live site + # (Cloudflare in front; local builds can't reproduce that). + - mermaid permissions: contents: read @@ -35,7 +39,9 @@ jobs: - uv.lock - name: Should publish - if: steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true' + # TEMPORARY [DO NOT MERGE]: the ref_name clause forces a deploy from + # the mermaid branch even when the push only touches this workflow. + if: steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true' || github.ref_name == 'mermaid' run: echo "PUBLISH=$(echo true)" >> $GITHUB_ENV - name: Install uv @@ -78,7 +84,7 @@ jobs: restore-keys: | puppeteer-${{ runner.os }}- - # Diagrams (docs/_ext/mermaid_inline.py) are rendered at build time by + # Diagrams (sphinx-gp-mermaid) are rendered at build time by # mmdc, which drives a headless Chrome. Without it the build still # succeeds but degrades diagrams to a text fallback, so provision both. - name: Install diagram toolchain (mermaid-cli + Chrome) @@ -121,12 +127,15 @@ jobs: aws s3 sync docs/_build/html "s3://${{ secrets.TMUXP_DOCS_BUCKET }}" \ --delete --follow-symlinks + # TEMPORARY [DO NOT MERGE]: invalidate everything — this branch swaps + # the diagram markup on every page carrying a mermaid fence, and the + # standard 4-path invalidation leaves those pages stale for a day. - name: Invalidate CloudFront if: env.PUBLISH == 'true' run: | aws cloudfront create-invalidation \ --distribution-id "${{ secrets.TMUXP_DOCS_DISTRIBUTION }}" \ - --paths "/index.html" "/history.html" "/objects.inv" "/searchindex.js" + --paths "/*" - name: Purge cache on Cloudflare if: env.PUBLISH == 'true' diff --git a/.gitignore b/.gitignore index 192e58cfc8..7e36db2f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -59,7 +59,7 @@ coverage.xml # Sphinx documentation docs/_build/ -# Mermaid diagram tooling (docs/_ext/mermaid_inline.py) +# Mermaid diagram tooling (sphinx-gp-mermaid) docs/node_modules/ docs/_mermaid_cache/ diff --git a/docs/AGENTS.md b/docs/AGENTS.md index e6c1142552..39b5c6f860 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -87,8 +87,9 @@ page. Two mechanical conventions, separate from voice: -- **Mermaid diagrams** render to inline SVG at build time (see - `docs/_ext`). Tag any node whose label is a command, code identifier, +- **Mermaid diagrams** render to inline SVG at build time (via the + `sphinx-gp-mermaid` package). Tag any node whose label is a command, + code identifier, config key, or other symbol with `:::cmd` so it renders monospace — the way that text reads as code inline; leave prose and concept nodes unstyled. Prefer top-to-bottom (`flowchart TD`); wide left-to-right diff --git a/docs/_ext/mermaid_inline.py b/docs/_ext/mermaid_inline.py deleted file mode 100644 index 6a1f86e736..0000000000 --- a/docs/_ext/mermaid_inline.py +++ /dev/null @@ -1,513 +0,0 @@ -"""Build-time mermaid rendering for Sphinx, producing inline SVG. - -Renders fenced ``mermaid`` blocks to inline ```` at build time via -``mmdc`` (`@mermaid-js/mermaid-cli`_), so diagrams paint with the page: there is -no client-side mermaid runtime, no asynchronous pop-in, and no layout shift. The -finished SVG ships inside ``.article-container``, so it also rides through -gp-sphinx's SPA navigation as live DOM with zero re-initialisation. - -Each diagram is rendered twice — a light and a dark variant — and both are -inlined, toggled by CSS on ``body[data-theme]`` (see ``gp-diagram.css``). Mermaid -bakes literal colours into an id-scoped, ``!important`` ``
' + html.escape(source) + "
", - ) - raise nodes.SkipNode from None - - digest = _diagram_digest(source, "") - light = _normalize_svg(light, svg_id=_svg_element_id(digest, _THEME_LIGHT)) - dark = _normalize_svg(dark, svg_id=_svg_element_id(digest, _THEME_DARK)) - - caption: str = node.get("caption", "") - alt = node.get("alt", "") or caption - aria = f' aria-label="{html.escape(alt, quote=True)}"' if alt else "" - ids: list[str] = node.get("ids", []) - fig_id = f' id="{ids[0]}"' if ids else "" - - parts = [ - f'
', - f'', - ( - '' - ), - ] - if caption: - parts.append(f"
{html.escape(caption)}
") - parts.append("
") - self.body.append("".join(parts)) - raise nodes.SkipNode - - -def _depart_mermaid_inline(self: HTML5Translator, node: mermaid_inline) -> None: - """No-op; :func:`html_visit_mermaid_inline` raises ``SkipNode``.""" - - -def setup(app: Sphinx) -> dict[str, t.Any]: - """Register the directive, node, config values, and stylesheet.""" - app.add_node( - mermaid_inline, - html=(html_visit_mermaid_inline, _depart_mermaid_inline), - ) - app.add_directive("mermaid", MermaidDirective) - app.add_config_value("mermaid_inline_cmd", "", "env") - app.add_config_value("mermaid_inline_puppeteer_config", "", "env") - app.add_css_file("css/gp-diagram.css") - return { - "version": "0.1.0", - "parallel_read_safe": True, - "parallel_write_safe": True, - } diff --git a/docs/_static/css/gp-diagram.css b/docs/_static/css/gp-diagram.css deleted file mode 100644 index c032e4332a..0000000000 --- a/docs/_static/css/gp-diagram.css +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Build-time mermaid diagrams (docs/_ext/mermaid_inline.py). - * - * Each diagram is inlined twice — a light and a dark SVG — and this stylesheet - * shows exactly one. Mermaid bakes literal colours into an id-scoped, - * !important " - 'a' - '' - "" -) - - -class NormalizeCase(t.NamedTuple): - """A ``_normalize_svg`` scenario and its expected substrings.""" - - test_id: str - raw_svg: str - svg_id: str - expect_contains: tuple[str, ...] - expect_absent: tuple[str, ...] - - -_NORMALIZE_CASES: list[NormalizeCase] = [ - NormalizeCase( - test_id="width-and-height-from-viewbox", - raw_svg=( - '' - ), - svg_id="mermaid-deadbeef-light", - expect_contains=( - 'width="200"', - 'height="80"', - 'id="mermaid-deadbeef-light"', - "#mermaid-deadbeef-light{", - ), - expect_absent=("my-svg", "max-width", 'width="100%"'), - ), - NormalizeCase( - test_id="marker-references-rewritten", - raw_svg=( - '' - '' - ), - svg_id="mermaid-abc-dark", - expect_contains=("url(#mermaid-abc-dark_end)", 'width="10"', 'height="10"'), - expect_absent=("my-svg",), - ), - NormalizeCase( - test_id="decimal-viewbox-dimensions", - raw_svg=( - '' - ), - svg_id="mermaid-x-light", - expect_contains=('width="1141.25"', 'height="94"'), - expect_absent=("max-width",), - ), - NormalizeCase( - test_id="block-negative-viewbox-origin", - raw_svg=( - '' - '' - ), - svg_id="mermaid-blk-light", - expect_contains=('width="148"', 'height="194"'), - expect_absent=('width="100%"', 'width="10"'), - ), -] - - -@pytest.mark.parametrize( - "case", - _NORMALIZE_CASES, - ids=[c.test_id for c in _NORMALIZE_CASES], -) -def test_normalize_svg(case: NormalizeCase) -> None: - """``_normalize_svg`` rewrites the id, sets size, and drops ``max-width``.""" - out = mi._normalize_svg(case.raw_svg, svg_id=case.svg_id) - for needle in case.expect_contains: - assert needle in out, f"{case.test_id}: expected {needle!r}" - for needle in case.expect_absent: - assert needle not in out, f"{case.test_id}: unexpected {needle!r}" - - -class DigestCase(t.NamedTuple): - """Two ``_diagram_digest`` inputs and whether their hashes should match.""" - - test_id: str - a_source: str - a_theme: str - b_source: str - b_theme: str - expect_equal: bool - - -_DIGEST_CASES: list[DigestCase] = [ - DigestCase( - "identical-inputs-match", - "flowchart LR a-->b", - "default", - "flowchart LR a-->b", - "default", - True, - ), - DigestCase( - "theme-differs", - "flowchart LR a-->b", - "default", - "flowchart LR a-->b", - "dark", - False, - ), - DigestCase( - "source-differs", - "flowchart LR a-->b", - "default", - "flowchart LR a-->c", - "default", - False, - ), -] - - -@pytest.mark.parametrize( - "case", - _DIGEST_CASES, - ids=[c.test_id for c in _DIGEST_CASES], -) -def test_diagram_digest(case: DigestCase) -> None: - """``_diagram_digest`` is stable per input and varies by theme and source.""" - a = mi._diagram_digest(case.a_source, case.a_theme) - b = mi._diagram_digest(case.b_source, case.b_theme) - assert (a == b) is case.expect_equal - assert len(a) == 40 - - -def test_svg_element_id_is_themed_and_unique() -> None: - """``_svg_element_id`` yields distinct, theme-suffixed ids per variant.""" - digest = mi._diagram_digest("flowchart LR a-->b", "") - light = mi._svg_element_id(digest, "light") - dark = mi._svg_element_id(digest, "dark") - assert light != dark - assert light.startswith("mermaid-") - assert light.endswith("-light") - - -class PaletteCase(t.NamedTuple): - """A theme name whose furo palette must define the flowchart variables.""" - - test_id: str - theme: str - - -_PALETTE_CASES: list[PaletteCase] = [ - PaletteCase(test_id=f"{theme}-palette", theme=theme) - for theme in (mi._THEME_LIGHT, mi._THEME_DARK) -] - -_REQUIRED_PALETTE_KEYS = ( - "primaryColor", - "primaryBorderColor", - "primaryTextColor", - "lineColor", - "textColor", -) - - -@pytest.mark.parametrize( - "case", - _PALETTE_CASES, - ids=[c.test_id for c in _PALETTE_CASES], -) -def test_palette_defines_flowchart_colors(case: PaletteCase) -> None: - """Each theme palette defines the mermaid colour variables as hex values.""" - palette = mi._PALETTES[case.theme] - for key in _REQUIRED_PALETTE_KEYS: - assert key in palette, f"{case.test_id}: missing {key}" - assert palette[key].startswith("#"), f"{case.test_id}: {key} not hex" - - -def _make_translator(tmp_path: Path) -> types.SimpleNamespace: - """Return a minimal stand-in for the HTML translator the visitor needs.""" - config = types.SimpleNamespace( - mermaid_inline_cmd="", - mermaid_inline_puppeteer_config="", - ) - app = types.SimpleNamespace(confdir=str(tmp_path), config=config) - builder = types.SimpleNamespace(app=app) - return types.SimpleNamespace(builder=builder, body=[]) - - -def test_visitor_emits_dual_themed_svg( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - """The visitor inlines one light and one dark SVG with shared geometry.""" - seen: list[str] = [] - - def fake_render_cached(app: object, source: str, theme: str) -> str: - seen.append(theme) - return _FAKE_MMDC_SVG - - monkeypatch.setattr(mi, "_render_cached", fake_render_cached) - translator = _make_translator(tmp_path) - node = mi.mermaid_inline() - node["mermaid_source"] = "flowchart LR a-->b" - node["caption"] = "How it flows" - node["alt"] = "" - - with pytest.raises(nodes.SkipNode): - mi.html_visit_mermaid_inline(t.cast("t.Any", translator), node) - - html = "".join(translator.body) - assert seen == [mi._THEME_LIGHT, mi._THEME_DARK] - assert html.count("gp-diagram--light") == 1 - assert html.count("gp-diagram--dark") == 1 - assert "
How it flows
" in html - assert "my-svg" not in html - # Both variants normalized to identical geometry -> shift-free toggle. - assert html.count('viewBox="0 0 200 80"') == 2 - - -def test_visitor_falls_back_when_renderer_missing( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - caplog: pytest.LogCaptureFixture, -) -> None: - """A missing renderer degrades to a text fallback and warns once.""" - monkeypatch.setattr(mi, "_render_warned", False) - - def boom(app: object, source: str, theme: str) -> str: - msg = "no mmdc" - raise mi.MermaidRendererMissing(msg) - - monkeypatch.setattr(mi, "_render_cached", boom) - translator = _make_translator(tmp_path) - node = mi.mermaid_inline() - node["mermaid_source"] = "flowchart LR a-->b" - node["caption"] = "" - node["alt"] = "" - - with caplog.at_level(logging.WARNING), pytest.raises(nodes.SkipNode): - mi.html_visit_mermaid_inline(t.cast("t.Any", translator), node) - - html = "".join(translator.body) - assert 'class="gp-diagram-fallback"' in html - assert "flowchart LR a-->b" in html - warnings = [r for r in caplog.records if r.levelno == logging.WARNING] - assert any("mermaid render unavailable" in r.getMessage() for r in warnings) - - -def test_render_cache_is_idempotent( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - """``_render_cached`` invokes mmdc once, then serves from the disk cache.""" - calls = {"n": 0} - - def fake_render(app: object, source: str, config_json: str) -> str: - calls["n"] += 1 - return _FAKE_MMDC_SVG - - monkeypatch.setattr(mi, "_render", fake_render) - config = types.SimpleNamespace( - mermaid_inline_cmd="", - mermaid_inline_puppeteer_config="", - ) - app = types.SimpleNamespace(confdir=str(tmp_path), config=config) - - first = mi._render_cached( - t.cast("t.Any", app), "flowchart LR a-->b", mi._THEME_LIGHT - ) - second = mi._render_cached( - t.cast("t.Any", app), "flowchart LR a-->b", mi._THEME_LIGHT - ) - - assert first == second == _FAKE_MMDC_SVG - assert calls["n"] == 1 - assert list((tmp_path / "_mermaid_cache").glob("*.svg")) - - -def test_setup_registers_components() -> None: - """``setup`` registers the node, directive, config values, css, and is safe.""" - recorded: dict[str, list[t.Any]] = { - "nodes": [], - "directives": [], - "config": [], - "css": [], - } - - def add_node(node: object, **kwargs: object) -> None: - recorded["nodes"].append(node) - - def add_directive(name: str, cls: object) -> None: - recorded["directives"].append((name, cls)) - - def add_config_value(name: str, default: object, rebuild: object) -> None: - recorded["config"].append(name) - - def add_css_file(name: str, **kwargs: object) -> None: - recorded["css"].append(name) - - app = types.SimpleNamespace( - add_node=add_node, - add_directive=add_directive, - add_config_value=add_config_value, - add_css_file=add_css_file, - ) - meta = mi.setup(t.cast("t.Any", app)) - - assert meta["parallel_read_safe"] is True - assert meta["parallel_write_safe"] is True - assert mi.mermaid_inline in recorded["nodes"] - assert ("mermaid", mi.MermaidDirective) in recorded["directives"] - assert "mermaid_inline_cmd" in recorded["config"] - assert "mermaid_inline_puppeteer_config" in recorded["config"] - assert "css/gp-diagram.css" in recorded["css"] diff --git a/uv.lock b/uv.lock index 11ede46c19..27bf8ed359 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P3D" [options.exclude-newer-package] +sphinx-gp-mermaid = false libtmux = false gp-sphinx = false sphinx-autodoc-sphinx = false @@ -1447,6 +1448,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/6c/44f85843c51a4ca2f40de16d380260d9e59cba6b5859d052052a37c9a6b3/sphinx_gp_llms-0.0.1a31-py3-none-any.whl", hash = "sha256:7c1639bc3c6a065125235977fc5b3783bdda236aa96f4f3bbfdffdfc6f94512f", size = 12637, upload-time = "2026-06-16T01:38:00.839Z" }, ] +[[package]] +name = "sphinx-gp-mermaid" +version = "0.0.1a31" +source = { git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-gp-mermaid&branch=mermaid#fb5e50ee4068b934766fa1eac8ec35807fd56ce1" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + [[package]] name = "sphinx-gp-opengraph" version = "0.0.1a31" @@ -1639,6 +1649,7 @@ dev = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-argparse" }, + { name = "sphinx-gp-mermaid" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1652,6 +1663,7 @@ docs = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-argparse" }, + { name = "sphinx-gp-mermaid" }, ] lint = [ { name = "mypy" }, @@ -1697,6 +1709,7 @@ dev = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a31" }, { name = "sphinx-autodoc-argparse", specifier = "==0.0.1a31" }, + { name = "sphinx-gp-mermaid", git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-gp-mermaid&branch=mermaid" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1709,6 +1722,7 @@ docs = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a31" }, { name = "sphinx-autodoc-argparse", specifier = "==0.0.1a31" }, + { name = "sphinx-gp-mermaid", git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-gp-mermaid&branch=mermaid" }, ] lint = [ { name = "mypy" },