From efa86e8eda14b429e69fa075eb7871cdbfde4ed4 Mon Sep 17 00:00:00 2001 From: rootiest Date: Sat, 25 Jul 2026 21:06:22 -0400 Subject: [PATCH] feat(docs): add manualtools library for SSOT frontmatter and ordering --- docs/manualtools.py | 81 +++++++++++++++++++++++++++++++++++++ docs/verify-manual.py | 93 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 docs/manualtools.py create mode 100644 docs/verify-manual.py diff --git a/docs/manualtools.py b/docs/manualtools.py new file mode 100644 index 0000000..463932d --- /dev/null +++ b/docs/manualtools.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Shared helpers for the docs/manual SSOT pipeline. + +Frontmatter parsing, deterministic tree ordering, and heading level shifts. +Used by build-manual.py, split-manual.py, and verify-manual.py. +""" + +import re +from pathlib import Path + +import yaml + +FENCE_RE = re.compile(r"^\s*(```|~~~)") +HEADING_RE = re.compile(r"^(#{1,6})(\s)") + + +def parse(path: Path) -> tuple[dict, str]: + """Split a markdown file into (frontmatter dict, body text). + + Files without a leading `---` block yield an empty dict and the whole + text as body. Body is returned with trailing whitespace stripped. + """ + text = path.read_text() + if not text.startswith("---\n"): + return {}, text.rstrip() + end = text.find("\n---\n", 4) + if end == -1: + return {}, text.rstrip() + fm = yaml.safe_load(text[4:end]) or {} + return fm, text[end + 5 :].lstrip('\n').rstrip() + + +def serialize(fm: dict, body: str) -> str: + """Render a frontmatter dict and body back into markdown text.""" + block = yaml.safe_dump(fm, sort_keys=False, allow_unicode=True).rstrip() + return f"---\n{block}\n---\n\n{body.rstrip()}\n" + + +def shift_headings(body: str, by: int) -> str: + """Add `by` levels to every ATX heading, ignoring fenced code blocks.""" + if by == 0: + return body + out, in_fence = [], False + for line in body.split("\n"): + if FENCE_RE.match(line): + in_fence = not in_fence + if not in_fence: + line = HEADING_RE.sub(lambda m: "#" * (len(m.group(1)) + by) + m.group(2), line) + out.append(line) + return "\n".join(out) + + +def _sort_key(entry: Path) -> tuple: + """Order by sidebar.order when present, else by filename. Stable.""" + target = entry / "index.md" if entry.is_dir() else entry + order = None + if target.exists(): + fm, _ = parse(target) + order = (fm.get("sidebar") or {}).get("order") + return (order is None, order if order is not None else 0, entry.name) + + +def walk(root: Path, depth: int = 0) -> list[tuple[Path, int]]: + """Return ordered (path, depth) pairs for every markdown file under root. + + A directory sorts at the position of its index.md and its children are + emitted immediately afterwards at depth+1. + """ + entries = [e for e in root.iterdir() if e.is_dir() or e.suffix == ".md"] + result: list[tuple[Path, int]] = [] + for entry in sorted(entries, key=_sort_key): + if entry.is_dir(): + index = entry / "index.md" + if index.exists(): + result.append((index, depth)) + result.extend(walk(entry, depth + 1)) + elif entry.name != "index.md" or depth == 0: + result.append((entry, depth)) + return result diff --git a/docs/verify-manual.py b/docs/verify-manual.py new file mode 100644 index 0000000..23f04c7 --- /dev/null +++ b/docs/verify-manual.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Verification checks for the docs/manual SSOT pipeline.""" + +import sys +import tempfile +from pathlib import Path + +import manualtools as mt + + +def test_parse_roundtrip(): + fm = {"title": "Git", "sidebar": {"order": 4}, "helpKeywords": ["git", "gi"]} + body = "## gitig\n\nManages ignore files." + text = mt.serialize(fm, body) + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "t.md" + p.write_text(text) + got_fm, got_body = mt.parse(p) + assert got_fm == fm, f"frontmatter mismatch: {got_fm!r}" + assert got_body == body, f"body mismatch: {got_body!r}" + + +def test_parse_no_frontmatter(): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "t.md" + p.write_text("# Plain\n\ntext\n") + fm, body = mt.parse(p) + assert fm == {}, f"expected empty frontmatter, got {fm!r}" + assert body == "# Plain\n\ntext", f"body mismatch: {body!r}" + + +def test_shift_headings(): + body = "## a\n\ntext\n\n### b" + assert mt.shift_headings(body, 1) == "### a\n\ntext\n\n#### b" + + +def test_shift_headings_skips_code_fences(): + body = "## a\n\n```\n# not a heading\n```\n\n## b" + got = mt.shift_headings(body, 1) + assert "# not a heading" in got, "code fence content was modified" + assert got.startswith("### a"), f"heading not shifted: {got[:10]!r}" + + +def test_walk_orders_by_sidebar_order_then_filename(): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "b.md").write_text(mt.serialize({"title": "B", "sidebar": {"order": 1}}, "")) + (root / "a.md").write_text(mt.serialize({"title": "A", "sidebar": {"order": 2}}, "")) + (root / "c.md").write_text(mt.serialize({"title": "C"}, "")) + got = [p.name for p, _ in mt.walk(root)] + assert got == ["b.md", "a.md", "c.md"], f"wrong order: {got}" + + +def test_walk_nests_directory_after_its_index(): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "01-first.md").write_text(mt.serialize({"title": "First"}, "")) + sub = root / "02-group" + sub.mkdir() + (sub / "index.md").write_text(mt.serialize({"title": "Group"}, "")) + (sub / "01-child.md").write_text(mt.serialize({"title": "Child"}, "")) + (root / "03-last.md").write_text(mt.serialize({"title": "Last"}, "")) + got = [(p.name, depth) for p, depth in mt.walk(root)] + expected = [ + ("01-first.md", 0), + ("index.md", 0), + ("01-child.md", 1), + ("03-last.md", 0), + ] + assert got == expected, f"wrong nesting: {got}" + + +TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + + +def main() -> int: + failed = 0 + for t in TESTS: + try: + t() + print(f" PASS {t.__name__}") + except AssertionError as e: + print(f" FAIL {t.__name__}: {e}", file=sys.stderr) + failed += 1 + print(f"\n{len(TESTS) - failed}/{len(TESTS)} passed") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).parent)) + raise SystemExit(main())