diff --git a/src/codealmanac/services/wiki/frontmatter_rewrite.py b/src/codealmanac/services/wiki/frontmatter_rewrite.py index 9ad2f84e..744989f3 100644 --- a/src/codealmanac/services/wiki/frontmatter_rewrite.py +++ b/src/codealmanac/services/wiki/frontmatter_rewrite.py @@ -1,3 +1,4 @@ +import re from collections.abc import Callable from io import StringIO from pathlib import Path @@ -20,7 +21,7 @@ class PageTopicsRewrite(CodeAlmanacModel): def rewrite_page_topics(path: Path, topics: tuple[str, ...]) -> None: raw = path.read_bytes().decode("utf-8") split = split_frontmatter(raw) - line_ending = "\r\n" if "\r\n" in split.frontmatter else "\n" + line_ending = split.line_ending yaml = YAML(typ="rt") yaml.preserve_quotes = True if split.frontmatter.strip(): @@ -82,31 +83,31 @@ def read_page_topics(path: Path) -> tuple[str, ...]: class FrontmatterSplit: - def __init__(self, frontmatter: str, body: str): + def __init__(self, frontmatter: str, body: str, line_ending: str = "\n"): self.frontmatter = frontmatter self.body = body + self.line_ending = line_ending -def split_frontmatter(raw: str) -> FrontmatterSplit: - if raw.startswith("---\r\n"): - return split_with_delimiter(raw, "\r\n") - if raw.startswith("---\n"): - return split_with_delimiter(raw, "\n") - return FrontmatterSplit(frontmatter="", body=raw) +# Same fence rule python-frontmatter uses when the index parses a page, so a +# rewrite always sees the block the index read: three or more dashes, then +# optional trailing whitespace. +FRONTMATTER_FENCE = re.compile(r"-{3,}\s*") -def split_with_delimiter(raw: str, line_ending: str) -> FrontmatterSplit: - opener = f"---{line_ending}" - closer = f"{line_ending}---" - end = raw.find(closer, len(opener)) - if end == -1: +def split_frontmatter(raw: str) -> FrontmatterSplit: + lines = re.split(r"(?<=\n)", raw) + if not FRONTMATTER_FENCE.fullmatch(lines[0]): return FrontmatterSplit(frontmatter="", body=raw) - frontmatter = raw[len(opener) : end] - body_start = end + len(closer) - if raw.startswith(line_ending, body_start): - body_start += len(line_ending) - body = raw[body_start:] - return FrontmatterSplit(frontmatter=frontmatter, body=body) + line_ending = "\r\n" if lines[0].endswith("\r\n") else "\n" + for index in range(1, len(lines)): + if FRONTMATTER_FENCE.fullmatch(lines[index]): + return FrontmatterSplit( + frontmatter="".join(lines[1:index]), + body="".join(lines[index + 1 :]), + line_ending=line_ending, + ) + return FrontmatterSplit(frontmatter="", body=raw) def apply_topics(data: CommentedMap, topics: tuple[str, ...]) -> None: diff --git a/tests/test_tagging.py b/tests/test_tagging.py index b4f121f9..416014db 100644 --- a/tests/test_tagging.py +++ b/tests/test_tagging.py @@ -94,6 +94,48 @@ def test_tag_handles_frontmatter_closing_fence_at_eof( assert "concepts" in raw +def test_tag_replaces_empty_frontmatter_block( + tmp_path: Path, + isolated_home: Path, +): + repo = make_repo(tmp_path) + page = repo / "almanac/note.md" + page.write_text("---\n---\n# Note\n\nBody.\n", encoding="utf-8") + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + + app.tagging.tag(TagPageRequest(cwd=repo, slug="note", topics=("concepts",))) + + raw = page.read_bytes().decode("utf-8") + assert raw == "---\ntopics:\n- concepts\n---\n# Note\n\nBody.\n" + + +def test_tag_reads_fences_with_trailing_whitespace_like_the_index( + tmp_path: Path, + isolated_home: Path, +): + repo = make_repo(tmp_path) + page = repo / "almanac/auth-flow.md" + page.write_text( + "--- \ntitle: Auth Flow\ntopics: [auth]\n--- \n# Auth Flow\n", + encoding="utf-8", + ) + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + + result = app.tagging.tag( + TagPageRequest(cwd=repo, slug="auth-flow", topics=("sessions",)) + ) + + raw = page.read_bytes().decode("utf-8") + assert result.topics_before == ("auth",) + assert raw.count("---") == 2 + assert "title: Auth Flow" in raw + assert raw.endswith("---\n# Auth Flow\n") + + def test_tag_preserves_crlf_frontmatter_and_body( tmp_path: Path, isolated_home: Path,