From 35c7b3c9a51bdbaa5432aeade062d2e2ea71fa42 Mon Sep 17 00:00:00 2001 From: Abhishek B R Date: Thu, 1 Oct 2026 00:34:35 +0530 Subject: [PATCH] fix(tagging): split frontmatter with the same fence rule the index uses Topic rewrites found the frontmatter block with a literal "---\n" / "\n---" search, while the index parses pages with python-frontmatter, which accepts any run of three or more dashes with trailing whitespace. When the two disagreed, tag/untag saw no frontmatter and prepended a new block, leaving the old one in the body, and topic rename/delete read no topics from the page and skipped it: - an empty block (---/---) was never recognised, because the closer search started past it - a fence with trailing spaces was skipped, so tagging left the page with two frontmatter blocks and the index lost its title Split line by line on the shared fence rule instead, and take the line ending from the opening fence. --- .../services/wiki/frontmatter_rewrite.py | 39 ++++++++--------- tests/test_tagging.py | 42 +++++++++++++++++++ 2 files changed, 62 insertions(+), 19 deletions(-) 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,