From e30bb850fbfa1116b3ff11555cdd818b22a74485 Mon Sep 17 00:00:00 2001 From: Michael Gardner Date: Fri, 15 May 2026 00:01:28 -0700 Subject: [PATCH 1/2] feat(compile_formal_docs): mirror docs/ subtree so formal docs can embed assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adafmt#56 needs the SDS to embed architecture diagrams via image("../diagrams/foo.svg"). The previous build copied .typ sources flat into the temp directory, so any ..// asset reference pointed outside the Typst project root — typst rejected it with "cannot read file outside of project root". Fix: mirror the project's whole docs/ subtree into the temp build (generated *.pdf excluded — build outputs, not inputs), overlay the shared templates next to the mirrored .typ sources, and compile each document with `--root` at the docs mirror. Cross-directory asset references now resolve while #import "core.typ" (a same-directory import) still works. This is a general docs-asset solution: any asset under docs/ is reachable, not just docs/diagrams/. No project-specific special case. Tests: tests/test_compile_formal_docs.py — find_project_root, dry-run, plain-doc compile (regression guard), embedded-diagram compile (the adafmt#56 regression — fails pre-fix with the outside-root error), and stale-PDF-in-docs tolerance. typst-dependent tests skip cleanly when typst is absent. Full suite 139 passing. Refs: adafmt#56 --- makefile/compile_formal_docs.py | 49 +++++++++--- tests/test_compile_formal_docs.py | 123 ++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 tests/test_compile_formal_docs.py diff --git a/makefile/compile_formal_docs.py b/makefile/compile_formal_docs.py index c9e91fa..b2e7f1d 100644 --- a/makefile/compile_formal_docs.py +++ b/makefile/compile_formal_docs.py @@ -29,9 +29,18 @@ # The Typst formal docs use #import "core.typ" which requires core.typ # to be in the same directory at compile time. Rather than maintaining # symlinks or gitmodule mounts, this script creates a temporary working -# directory, copies the shared templates and project sources into it, -# compiles each document, and writes the PDFs to the project's -# docs/formal/ directory. The temporary directory is always cleaned up. +# directory, mirrors the project's docs/ subtree into it, overlays the +# shared templates next to the mirrored .typ sources, compiles each +# document with --root at the docs mirror, and writes the PDFs to the +# project's docs/formal/ directory. The temporary directory is always +# cleaned up. +# +# Mirroring the whole docs/ subtree (rather than copying .typ files flat) +# lets a formal document embed assets from sibling directories with +# docs-relative paths, e.g. image("../diagrams/foo.svg"). Compiling +# with --root at the mirror keeps those cross-directory references +# inside the Typst project root. Generated PDFs are excluded from the +# mirror — they are build outputs, not compile inputs. # # See Also: # /Users/mike/shared_docs/templates/formal/ - shared Typst templates @@ -130,30 +139,46 @@ def compile_formal_docs( return 0 # Create temporary build directory, compile, clean up. + # + # The project's docs/ subtree is mirrored into the temp build so a + # formal document can reference sibling asset directories with + # docs-relative paths (e.g. image("../diagrams/foo.svg")). Compiling + # with --root at the mirror keeps those cross-directory references + # inside the Typst project root while still colocating the shared + # templates with the .typ sources. + docs_dir = formal_dir.parent with tempfile.TemporaryDirectory(prefix="typst_build_") as tmp: tmp_dir = Path(tmp) + docs_mirror = tmp_dir / "docs" + + # Mirror docs/ — generated PDFs are build outputs, not inputs. + shutil.copytree( + docs_dir, docs_mirror, + ignore=shutil.ignore_patterns("*.pdf"), + ) + mirror_formal = docs_mirror / "formal" - # Copy shared templates into the build directory. + # Overlay shared templates next to the mirrored .typ sources so + # #import "core.typ" (a same-directory import) resolves. for template_name in SHARED_TEMPLATES: src_path = templates_dir / template_name if src_path.is_file(): - shutil.copy2(src_path, tmp_dir / template_name) - - # Copy project .typ sources into the build directory. - for src in project_sources: - shutil.copy2(src, tmp_dir / src.name) + shutil.copy2(src_path, mirror_formal / template_name) - # Compile each document. + # Compile each document. --root is the docs mirror so a formal + # doc may reference any asset under docs/ (../diagrams/, ...). succeeded = 0 failed = 0 for src in project_sources: - typ_path = tmp_dir / src.name + typ_path = mirror_formal / src.name pdf_name = src.with_suffix(".pdf").name pdf_path = formal_dir / pdf_name result = subprocess.run( - ["typst", "compile", str(typ_path), str(pdf_path)], + ["typst", "compile", + "--root", str(docs_mirror), + str(typ_path), str(pdf_path)], capture_output=True, text=True, ) diff --git a/tests/test_compile_formal_docs.py b/tests/test_compile_formal_docs.py new file mode 100644 index 0000000..71b2e03 --- /dev/null +++ b/tests/test_compile_formal_docs.py @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Michael Gardner, A Bit of Help, Inc. +"""Tests for ``makefile.compile_formal_docs``. + +Covers the docs-asset-aware temp build (adafmt#56): a formal ``.typ`` +that embeds a sibling-directory asset via ``image("../diagrams/foo.svg")`` +must compile cleanly. The pre-#56 script copied ``.typ`` sources flat +into the temp build directory, so any ``..//`` asset reference +pointed outside the Typst project root and failed with +``cannot read file outside of project root``. + +The fix mirrors the project's ``docs/`` subtree into the temp build and +compiles with ``--root`` at that mirror, so cross-directory asset +references resolve. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "makefile")) + +import compile_formal_docs # type: ignore # noqa: E402 + + +MINIMAL_SVG = ( + '' + '\n' +) + +typst_required = pytest.mark.skipif( + shutil.which("typst") is None, + reason="typst compiler not on PATH", +) + + +def _make_project(root: Path, *, with_diagram: bool) -> None: + """Create a minimal project tree (docs/formal/ + optional docs/diagrams/).""" + formal = root / "docs" / "formal" + formal.mkdir(parents=True) + if with_diagram: + diagrams = root / "docs" / "diagrams" + diagrams.mkdir(parents=True) + (diagrams / "dot.svg").write_text(MINIMAL_SVG, encoding="utf-8") + (formal / "mini.typ").write_text( + '= Mini\n\n#image("../diagrams/dot.svg", width: 20%)\n', + encoding="utf-8", + ) + else: + (formal / "mini.typ").write_text( + "= Mini\n\nPlain body, no cross-directory assets.\n", + encoding="utf-8", + ) + + +# ---------------------------------------------------------------------- +# find_project_root +# ---------------------------------------------------------------------- + +def test_find_project_root_from_formal_dir(tmp_path): + _make_project(tmp_path, with_diagram=False) + found = compile_formal_docs.find_project_root(tmp_path / "docs" / "formal") + assert found == tmp_path.resolve() + + +def test_find_project_root_returns_none_when_absent(tmp_path): + assert compile_formal_docs.find_project_root(tmp_path) is None + + +# ---------------------------------------------------------------------- +# compile_formal_docs — render behavior +# ---------------------------------------------------------------------- + +def test_dry_run_does_not_compile(tmp_path): + _make_project(tmp_path, with_diagram=True) + templates = tmp_path / "templates" + templates.mkdir() + rc = compile_formal_docs.compile_formal_docs(tmp_path, templates, dry_run=True) + assert rc == 0 + assert not (tmp_path / "docs" / "formal" / "mini.pdf").exists() + + +@typst_required +def test_plain_formal_doc_compiles(tmp_path): + """A formal doc with no cross-directory assets still compiles + (regression guard — this path worked before the #56 fix too).""" + _make_project(tmp_path, with_diagram=False) + templates = tmp_path / "templates" + templates.mkdir() + rc = compile_formal_docs.compile_formal_docs(tmp_path, templates) + assert rc == 0 + assert (tmp_path / "docs" / "formal" / "mini.pdf").is_file() + + +@typst_required +def test_embedded_diagram_resolves(tmp_path): + """Regression for adafmt#56: a formal doc embedding a sibling-dir + asset via image("../diagrams/foo.svg") must compile. Before the + docs-mirror fix this failed with 'cannot read file outside of + project root'.""" + _make_project(tmp_path, with_diagram=True) + templates = tmp_path / "templates" + templates.mkdir() + rc = compile_formal_docs.compile_formal_docs(tmp_path, templates) + assert rc == 0 + assert (tmp_path / "docs" / "formal" / "mini.pdf").is_file() + + +@typst_required +def test_pre_existing_pdf_in_docs_does_not_break_mirror(tmp_path): + """A stale generated PDF anywhere under docs/ must not break the + temp-mirror copy — generated PDFs are build outputs, not inputs.""" + _make_project(tmp_path, with_diagram=True) + (tmp_path / "docs" / "formal" / "stale.pdf").write_bytes(b"%PDF-1.4\n") + templates = tmp_path / "templates" + templates.mkdir() + rc = compile_formal_docs.compile_formal_docs(tmp_path, templates) + assert rc == 0 + assert (tmp_path / "docs" / "formal" / "mini.pdf").is_file() From 55f0ddbc9f9d8018473e9e5ab165c5e8e2f773ab Mon Sep 17 00:00:00 2001 From: Michael Gardner Date: Fri, 15 May 2026 00:11:10 -0700 Subject: [PATCH 2/2] test(compile_formal_docs): add typst-free check of --root + mirror mechanics GPT PR #14 review suggested a non-typst test so the docs-mirror mechanics stay covered on runners without typst installed. Monkeypatches subprocess.run and asserts the compile command is rooted at the docs mirror, the .typ source is mirrored under /formal/, and the sibling diagram asset is mirrored under /diagrams/ at invocation time. Refs: adafmt#56 --- tests/test_compile_formal_docs.py | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_compile_formal_docs.py b/tests/test_compile_formal_docs.py index 71b2e03..3dbc6da 100644 --- a/tests/test_compile_formal_docs.py +++ b/tests/test_compile_formal_docs.py @@ -121,3 +121,46 @@ def test_pre_existing_pdf_in_docs_does_not_break_mirror(tmp_path): rc = compile_formal_docs.compile_formal_docs(tmp_path, templates) assert rc == 0 assert (tmp_path / "docs" / "formal" / "mini.pdf").is_file() + + +# ---------------------------------------------------------------------- +# Compile-command mechanics — no typst required (monkeypatched subprocess) +# ---------------------------------------------------------------------- + +def test_compile_command_roots_at_docs_mirror(tmp_path, monkeypatch): + """typst-free coverage of the fix: monkeypatch subprocess.run and + assert the compile command is `typst compile --root /docs`, + that the .typ source is mirrored under /formal/, and that the + sibling diagram asset is mirrored under /diagrams/ at the time + the compiler is invoked. Preserves coverage of the docs-mirror + mechanics on runners without typst installed.""" + _make_project(tmp_path, with_diagram=True) + templates = tmp_path / "templates" + templates.mkdir() + + captured: dict = {} + + def fake_run(cmd, capture_output=False, text=False): + captured["cmd"] = list(cmd) + root_idx = cmd.index("--root") + root = Path(cmd[root_idx + 1]) + captured["root"] = root + # State checked while the temp build dir still exists: + captured["diagram_mirrored"] = (root / "diagrams" / "dot.svg").is_file() + captured["typ_parent"] = Path(cmd[-2]).parent.name + + class _CP: + returncode = 0 + stdout = "" + stderr = "" + + return _CP() + + monkeypatch.setattr(compile_formal_docs.subprocess, "run", fake_run) + rc = compile_formal_docs.compile_formal_docs(tmp_path, templates) + + assert rc == 0 + assert captured["cmd"][:3] == ["typst", "compile", "--root"] + assert captured["root"].name == "docs" + assert captured["diagram_mirrored"] is True + assert captured["typ_parent"] == "formal"