mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
217 lines
6.3 KiB
Python
217 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Reflow hard-wrapped markdown prose to one line per paragraph.
|
|
|
|
Markdown and editors soft-wrap on their own, so manual mid-paragraph line
|
|
breaks (the ~75-char "wrap") add nothing. This joins wrapped lines within plain
|
|
paragraphs, list items, and (lazily) their continuations, and leaves code
|
|
fences, tables, headings, thematic breaks, HTML blocks, indented code,
|
|
blockquotes, and front matter byte-identical.
|
|
|
|
Safety invariant: the non-whitespace token sequence of a file must be unchanged
|
|
(a reflow only edits whitespace). Any file that would fail the invariant is left
|
|
untouched and reported — the tool never risks losing or scrambling content.
|
|
|
|
Usage:
|
|
python scripts/reflow_md.py # dry run: report what would change
|
|
python scripts/reflow_md.py --apply # rewrite files in place
|
|
python scripts/reflow_md.py --check # exit 1 if any in-scope file is wrapped
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path()
|
|
SKIP_DIRS = {
|
|
".OLD",
|
|
"node_modules",
|
|
".venv",
|
|
".uv-cache",
|
|
".claude",
|
|
".next",
|
|
"dist",
|
|
".git",
|
|
"__pycache__",
|
|
".mypy_cache",
|
|
".ruff_cache",
|
|
".pytest_cache",
|
|
".superpowers",
|
|
}
|
|
# Excluded subtrees / files: archive, generated (regenerated by `make lifecycle`
|
|
# — never hand-edit), gitignored internal notes, untracked working specs.
|
|
EXCLUDE_PREFIXES = ("docs/internal/", "agents/prompts/_generated/")
|
|
EXCLUDE_GLOBS = ("docs/SPEC_",)
|
|
|
|
FENCE = re.compile(r"^(\s*)(`{3,}|~{3,})")
|
|
FENCE_CLOSE = re.compile(r"^\s*(`{3,}|~{3,})\s*$")
|
|
HEADING = re.compile(r"^\s*#{1,6}(\s|$)")
|
|
LIST_ITEM = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+")
|
|
THEMATIC = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$")
|
|
SETEXT = re.compile(r"^\s*(=+|-+)\s*$")
|
|
|
|
|
|
def _is_indented_code(line: str) -> bool:
|
|
return line[:4] == " " and not LIST_ITEM.match(line)
|
|
|
|
|
|
def _is_passthrough(line: str) -> bool:
|
|
"""A line copied verbatim and never joined into prose.
|
|
|
|
Headings, code fences, tables/HTML/blockquotes (`|`, `<`, `>`),
|
|
thematic/setext rules, and indented code each stay on their own line.
|
|
"""
|
|
s = line.strip()
|
|
return bool(
|
|
HEADING.match(line)
|
|
or FENCE.match(line)
|
|
or s.startswith(("|", "<", ">"))
|
|
or THEMATIC.match(line)
|
|
or SETEXT.match(line)
|
|
or _is_indented_code(line)
|
|
)
|
|
|
|
|
|
def _is_block_break(line: str) -> bool:
|
|
"""A blank line or any verbatim block construct (ends a prose/list run)."""
|
|
return line.strip() == "" or _is_passthrough(line)
|
|
|
|
|
|
def _copy_fence(lines: list[str], i: int, out: list[str]) -> int:
|
|
"""Copy a fenced code block verbatim through its closing fence."""
|
|
out.append(lines[i])
|
|
i += 1
|
|
while i < len(lines):
|
|
out.append(lines[i])
|
|
closed = FENCE_CLOSE.match(lines[i])
|
|
i += 1
|
|
if closed:
|
|
break
|
|
return i
|
|
|
|
|
|
def _join_list(lines: list[str], i: int, out: list[str]) -> int:
|
|
"""Reflow a list block: one line per item, joining lazy continuations."""
|
|
while (
|
|
i < len(lines) and not _is_block_break(lines[i]) and LIST_ITEM.match(lines[i])
|
|
):
|
|
item = [lines[i]]
|
|
i += 1
|
|
while (
|
|
i < len(lines)
|
|
and lines[i].strip() != ""
|
|
and not LIST_ITEM.match(lines[i])
|
|
and not _is_block_break(lines[i])
|
|
):
|
|
item.append(lines[i].strip())
|
|
i += 1
|
|
out.append(
|
|
" ".join(p.rstrip() if k == 0 else p.strip() for k, p in enumerate(item))
|
|
)
|
|
return i
|
|
|
|
|
|
def _join_paragraph(lines: list[str], i: int, out: list[str]) -> int:
|
|
"""Join a plain paragraph's lines into one."""
|
|
para: list[str] = []
|
|
while (
|
|
i < len(lines)
|
|
and not _is_block_break(lines[i])
|
|
and not LIST_ITEM.match(lines[i])
|
|
):
|
|
para.append(lines[i].strip())
|
|
i += 1
|
|
out.append(" ".join(para))
|
|
return i
|
|
|
|
|
|
def _skip_front_matter(lines: list[str], out: list[str]) -> int:
|
|
"""Copy a leading `--- ... ---` front-matter block verbatim; return next i."""
|
|
if not (lines and lines[0].strip() == "---"):
|
|
return 0
|
|
out.append(lines[0])
|
|
i = 1
|
|
while i < len(lines) and lines[i].strip() != "---":
|
|
out.append(lines[i])
|
|
i += 1
|
|
if i < len(lines):
|
|
out.append(lines[i])
|
|
i += 1
|
|
return i
|
|
|
|
|
|
def reflow(text: str) -> str:
|
|
lines = text.split("\n")
|
|
out: list[str] = []
|
|
i = _skip_front_matter(lines, out)
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
if line.strip() == "":
|
|
out.append(line)
|
|
i += 1
|
|
elif FENCE.match(line):
|
|
i = _copy_fence(lines, i, out)
|
|
elif _is_passthrough(line):
|
|
out.append(line)
|
|
i += 1
|
|
elif LIST_ITEM.match(line):
|
|
i = _join_list(lines, i, out)
|
|
else:
|
|
i = _join_paragraph(lines, i, out)
|
|
return "\n".join(out)
|
|
|
|
|
|
def in_scope(p: Path) -> bool:
|
|
if any(part in SKIP_DIRS for part in p.parts):
|
|
return False
|
|
rel = p.as_posix()
|
|
if any(rel.startswith(pre) for pre in EXCLUDE_PREFIXES):
|
|
return False
|
|
return not any(g in rel for g in EXCLUDE_GLOBS)
|
|
|
|
|
|
def main() -> None:
|
|
args = sys.argv[1:]
|
|
apply = "--apply" in args
|
|
check = "--check" in args
|
|
|
|
changed: list[Path] = []
|
|
skipped: list[Path] = []
|
|
for p in sorted(ROOT.rglob("*.md")):
|
|
if not in_scope(p):
|
|
continue
|
|
orig = p.read_text()
|
|
new = reflow(orig)
|
|
if new == orig:
|
|
continue
|
|
if orig.split() != new.split(): # token invariant — never touch
|
|
skipped.append(p)
|
|
continue
|
|
changed.append(p)
|
|
if apply:
|
|
p.write_text(new)
|
|
|
|
if check:
|
|
for p in skipped:
|
|
print(f"warning: skipped (token-invariant): {p}")
|
|
if changed:
|
|
print(f"{len(changed)} file(s) have hard-wrapped prose. Fix with:")
|
|
print(" make reflow-docs")
|
|
for p in changed:
|
|
print(f" {p}")
|
|
sys.exit(1)
|
|
print("OK: no hard-wrapped markdown prose in scope.")
|
|
return
|
|
|
|
verb = "reflowed" if apply else "would reflow"
|
|
print(f"{verb}: {len(changed)} skipped (token-invariant): {len(skipped)}")
|
|
for p in changed:
|
|
print(f" {p}")
|
|
for p in skipped:
|
|
print(f" !! skipped {p}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|