From bad5b1b575150b7d3ccfda01f2d5c0c54a8f55e8 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Wed, 14 Oct 2026 17:35:00 -0400 Subject: [PATCH 1/3] Link a folder under the blueprint by the spelling Git committed #301 recorded the committed spelling of each captured file whose name macOS Git stores composed, and source-note links looked their whole path up in that map. A link to a folder, such as `../sources/`, never matched, so when a folder above it was decomposed on disk the link named the on-disk spelling and 404'd. The map now holds each file's leading folder paths too, and a link takes the committed spelling of its longest mapped leading path. That also covers a folder render does not capture, such as one holding only a `.gitkeep`. The test runs on every OS with the composed ls-tree output the existing decomposed-name test uses, now a shared helper. --- autoform_cli/render.py | 21 ++++++++++++---- tests/test_render.py | 54 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/autoform_cli/render.py b/autoform_cli/render.py index b8578fb3..a19aeb5e 100644 --- a/autoform_cli/render.py +++ b/autoform_cli/render.py @@ -1132,7 +1132,13 @@ def _sources_base(blueprint: Path, repo_root: Path, linker: SourceLinker) -> "_S # The vault is outside the repository being linked, so no blob URL # describes it. Better no link than one that 404s. return None - spellings = {unicodedata.normalize("NFC", path): committed for path, committed in linker.spellings.items()} + spellings: dict[str, str] = {} + for path, committed in linker.spellings.items(): + # A link may name a folder as well as a note, so each leading path maps too. + parts, committed_parts = path.split("/"), committed.split("/") + for depth in range(1, len(parts) + 1): + prefix = unicodedata.normalize("NFC", "/".join(parts[:depth])) + spellings.setdefault(prefix, "/".join(committed_parts[:depth])) return _SourceBase(linker.repository_url, linker.ref, relative, spellings) @@ -1151,12 +1157,19 @@ class _SourceBase: ref: str relative: str spellings: Mapping[str, str] = field(default_factory=dict) - """Committed paths keyed by NFC form, since a link spells a note however its author typed it.""" + """Committed spellings of captured paths and their leading folders, keyed by NFC form, as links spell them.""" def href(self, tail: tuple[str, ...]) -> str: verb = "blob" if tail else "tree" - path = "/".join((self.relative, *tail)) - path = self.spellings.get(unicodedata.normalize("NFC", path), path) + parts = "/".join((self.relative, *tail)).split("/") + # The longest leading path with a committed spelling covers folders render did not capture, + # such as one holding only a `.gitkeep`. + for depth in range(len(parts), 0, -1): + committed = self.spellings.get(unicodedata.normalize("NFC", "/".join(parts[:depth]))) + if committed is not None: + parts[:depth] = committed.split("/") + break + path = "/".join(parts) return f"{self.repository_url}/{verb}/{self.ref}/{quote(path, safe='/')}" diff --git a/tests/test_render.py b/tests/test_render.py index 21f1ee2e..242def84 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -2432,6 +2432,19 @@ def test_markdown_source_permalink_quotes_the_original_repository_path(tmp_path: assert ".autoform-publication" not in page and "autoform-blueprint-" not in page +def _print_composed_names(monkeypatch: pytest.MonkeyPatch) -> None: + """Make `git ls-tree` print e + U+0301 composed, as macOS Git does; elsewhere Git keeps names as written.""" + run = subprocess.run + + def ls_tree(command, *args, **kwargs): + completed = run(command, *args, **kwargs) + if command[:2] == ["git", "ls-tree"]: + completed.stdout = completed.stdout.replace("e\u0301".encode(), "\u00e9".encode()) + return completed + + monkeypatch.setattr(lean_module.subprocess, "run", ls_tree) + + def test_decomposed_file_names_link_to_the_spelling_git_committed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -2449,16 +2462,7 @@ def test_decomposed_file_names_link_to_the_spelling_git_committed( subprocess.run(["git", "config", "core.precomposeunicode", "true"], cwd=project, check=True) commit = _git_commit(project, "fixture") output = tmp_path / "out" - run = subprocess.run - - def precomposed_ls_tree(command, *args, **kwargs): - # macOS Git prints the composed spelling it committed; elsewhere Git keeps names as written. - completed = run(command, *args, **kwargs) - if command[:2] == ["git", "ls-tree"]: - completed.stdout = completed.stdout.replace("e\u0301".encode(), "\u00e9".encode()) - return completed - - monkeypatch.setattr(lean_module.subprocess, "run", precomposed_ls_tree) + _print_composed_names(monkeypatch) report = render_site(project / "blueprint", output, lean_root=project, repository_url=_REPOSITORY, ref=commit) @@ -2470,6 +2474,36 @@ def precomposed_ls_tree(command, *args, **kwargs): assert report.warnings == [] +def test_a_link_to_a_folder_names_the_spelling_git_committed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + project = _project(tmp_path) + blueprint = project / "Poincare\u0301" / "blueprint" # a folder name decomposed on disk, as Finder writes it + blueprint.parent.mkdir() + (project / "blueprint").rename(blueprint) + (blueprint / "sources").mkdir() + (blueprint / "sources/paper.md").write_text("# Paper\n", encoding="utf-8") + (blueprint / "sources/drafts").mkdir() + (blueprint / "sources/drafts/.gitkeep").write_text("", encoding="utf-8") # committed, but never captured + _edit( + blueprint / "roadmap/README.md", + "# Roadmap", + "# Roadmap\n\nSee [the notes](../sources/), [the paper](../sources/paper.md) and [drafts](../sources/drafts/).", + ) + subprocess.run(["git", "init", "-q"], cwd=project, check=True) + subprocess.run(["git", "config", "core.precomposeunicode", "true"], cwd=project, check=True) + commit = _git_commit(project, "fixture") + _print_composed_names(monkeypatch) + + report = render_site(blueprint, tmp_path / "out", lean_root=project, repository_url=_REPOSITORY, ref=commit) + + chapter = (tmp_path / "out/roadmap/README.md").read_text(encoding="utf-8") + assert f"/tree/{commit}/Poincar%C3%A9/blueprint/sources" in chapter + assert f"/blob/{commit}/Poincar%C3%A9/blueprint/sources/paper.md" in chapter + assert f"/blob/{commit}/Poincar%C3%A9/blueprint/roadmap/top.md" in chapter + assert f"/{commit}/Poincar%C3%A9/blueprint/sources/drafts" in chapter + assert "Poincare%CC%81" not in chapter + assert report.warnings == [] + + def test_a_source_note_link_names_the_committed_spelling_however_it_was_typed() -> None: from autoform_cli.render import _SourceBase From 69a10b69861629db64d70d3e219051f38e2db715 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Wed, 14 Oct 2026 17:35:00 -0400 Subject: [PATCH 2/3] Say which links render --ref needs which inputs for Since #300, code links need only the captured Lean sources in the commit, and links into the blueprint need the blueprint too. The help still said links needed every rendered input. --- autoform_cli/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autoform_cli/__main__.py b/autoform_cli/__main__.py index 269f6137..2121e343 100644 --- a/autoform_cli/__main__.py +++ b/autoform_cli/__main__.py @@ -431,8 +431,8 @@ def main(argv: Sequence[str] | None = None) -> int: render.add_argument("--repository-url", help="project URL, e.g. https://github.com/owner/repo") render.add_argument( "--ref", - help="commit or branch to link; links appear only if its local commit " - "holds the exact rendered inputs", + help="commit or branch to link; code links appear only if its local commit holds " + "the captured Lean sources, and links into the blueprint only if it also holds the blueprint", ) render.add_argument( "--require-declarations", From 00e30ee7ae2bb340b963f9eed7a3591ab18e2a5a Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Sat, 17 Oct 2026 14:08:16 -0400 Subject: [PATCH 3/3] Say exactly what a folder link's lookup covers The comment in _SourceBase.href said the lookup covers folders render did not capture. Render does capture such a folder, just no file inside it, and only the leading folders it shares with a captured file take their committed spelling; its own decomposed name keeps the link's spelling. The spellings docstring again says why its keys are NFC. --- autoform_cli/render.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/autoform_cli/render.py b/autoform_cli/render.py index a19aeb5e..31ad36d0 100644 --- a/autoform_cli/render.py +++ b/autoform_cli/render.py @@ -1157,13 +1157,13 @@ class _SourceBase: ref: str relative: str spellings: Mapping[str, str] = field(default_factory=dict) - """Committed spellings of captured paths and their leading folders, keyed by NFC form, as links spell them.""" + """Committed captured paths and their leading folders, keyed by NFC form, since a link spells a path as typed.""" def href(self, tail: tuple[str, ...]) -> str: verb = "blob" if tail else "tree" parts = "/".join((self.relative, *tail)).split("/") - # The longest leading path with a committed spelling covers folders render did not capture, - # such as one holding only a `.gitkeep`. + # A folder with no captured file below it, such as one holding only a `.gitkeep`, has no entry of its own, + # so only the leading folders it shares with a captured file take their committed spelling. for depth in range(len(parts), 0, -1): committed = self.spellings.get(unicodedata.normalize("NFC", "/".join(parts[:depth]))) if committed is not None: