diff --git a/autoform_cli/__main__.py b/autoform_cli/__main__.py index 269f6137d..2121e3439 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", diff --git a/autoform_cli/render.py b/autoform_cli/render.py index b8578fb36..31ad36d0b 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 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" - path = "/".join((self.relative, *tail)) - path = self.spellings.get(unicodedata.normalize("NFC", path), path) + parts = "/".join((self.relative, *tail)).split("/") + # 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: + 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 21f1ee2ec..242def844 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