Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions makefile/compile_formal_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
166 changes: 166 additions & 0 deletions tests/test_compile_formal_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# 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 ``../<dir>/`` 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 = (
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">'
'<rect width="12" height="12" fill="black"/></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()


# ----------------------------------------------------------------------
# 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 <mirror>/docs`,
that the .typ source is mirrored under <root>/formal/, and that the
sibling diagram asset is mirrored under <root>/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"