feat(docs): add manualtools library for SSOT frontmatter and ordering
This commit is contained in:
@@ -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
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user