From 67fb29687c94e98c7dee8a2855a2a6792b4ffed7 Mon Sep 17 00:00:00 2001 From: rootiest Date: Sun, 26 Jul 2026 03:38:07 -0400 Subject: [PATCH] docs(site): render aligned option blocks as markdown tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-column option and subcommand blocks in the manual were falling through to the indented-code fallback, rendering as an unhighlighted grey slab on the site. `_as_table` detects a contiguous, column-aligned indented run and emits a headerless GFM table instead; anything it cannot prove is tabular still takes the old path. 15 blocks convert, 20 are correctly left alone. The concat and man-page outputs are untouched — `prettify` is site-only, and the byte-exact round-trip test stays green. --- docs/build-manual.py | 55 +++++++++++++++++++++++++++++++++ docs/verify-manual.py | 71 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/docs/build-manual.py b/docs/build-manual.py index 959f0b4..69c2252 100644 --- a/docs/build-manual.py +++ b/docs/build-manual.py @@ -140,6 +140,58 @@ def _is_shell(para: list[str], entry_name: str | None) -> bool: return True +CELL_SPLIT = re.compile(r"\s{2,}") + + +def _cell(text: str, code: bool) -> str: + """Render one table cell. `|` must be escaped even inside a code span.""" + text = text.strip().replace("|", r"\|") + return f"`{text}`" if code and text else text + + +def _as_table(para: list[str]) -> str | None: + """Render an aligned two-column block as a markdown table, else None. + + Option and subcommand tables are the one thing in this manual that is + genuinely tabular, and the indented-code fallback renders them as a grey + slab. Everything else stays in that fallback: returning None is always + safe, so every check here is free to be conservative. + + The rows must form one contiguous indented run, optionally introduced by + a label line (`Options:`) and closed by a sentence. Lines indented deeper + than the run are wrapped descriptions and fold into the row above. + """ + starts = [i for i, ln in enumerate(para) if ln.startswith(" ")] + if len(starts) < 2 or starts != list(range(starts[0], starts[-1] + 1)): + return None + head = para[: starts[0]] + body = para[starts[0] : starts[-1] + 1] + tail = para[starts[-1] + 1 :] + if head and not head[-1].rstrip().endswith(":"): + return None # a head that isn't a label means mixed content + + indent = min(len(ln) - len(ln.lstrip()) for ln in body) + rows: list[list[str]] = [] + for line in body: + if len(line) - len(line.lstrip()) > indent and rows: + rows[-1][1] += " " + line.strip() + continue + parts = CELL_SPLIT.split(line.strip(), 1) + if len(parts) != 2 or not parts[1].strip(): + return None # not column-aligned; a numbered list, or prose + rows.append([parts[0], parts[1].strip()]) + if len(rows) < 2: + return None + if any("<" in value or "{" in value for _, value in rows): + return None # live markdown in the prose column + + out = [line.strip() for line in head] + out += ["| | |", "|---|---|"] + out += [f"| {_cell(k, True)} | {_cell(v, False)} |" for k, v in rows] + out += [line.strip() for line in tail] + return "\n".join(out) + + def _render_para(para: list[str], entry_name: str | None, deeper: bool) -> str: """Render one paragraph of a former indented block. @@ -152,6 +204,9 @@ def _render_para(para: list[str], entry_name: str | None, deeper: bool) -> str: if _is_shell(para, entry_name): body = "\n".join(para) return f"```fish\n{body}\n```" + table = _as_table(para) + if table is not None: + return table return "\n".join(INDENT + line for line in para) diff --git a/docs/verify-manual.py b/docs/verify-manual.py index f904d4d..6c7279c 100644 --- a/docs/verify-manual.py +++ b/docs/verify-manual.py @@ -237,14 +237,79 @@ def test_prettify_splits_an_entry_block(): assert "```fish\nrm file.txt" in out, "examples were not fenced as fish" assert out.count("```") == 4, f"expected exactly two fences, got:\n{out}" assert "\nSafe rm wrapper routing to trash:" in out, "description stayed indented" - assert ( - "\n (no args) List current trash contents" in out - ), "option table lost its indentation" + assert "| `(no args)` | List current trash contents |" in out, ( + "option table was not converted to a markdown table" + ) assert ( "\nFalls back to /usr/bin/rm when trash is unavailable." in out ), "trailing prose stayed indented" +def test_as_table_converts_option_blocks(): + """A labelled, column-aligned block becomes a table; wrapped rows fold in.""" + import build_manual + + out = build_manual._as_table( + [ + "Options:", + " -a/--aggressive Also removes node_modules, logs,", + " and IDE dirs", + " -d/--dry-run Print what would be removed", + "Pass neither to run interactively.", + ] + ) + assert out is not None, "a plain option table was rejected" + assert out.splitlines()[0] == "Options:", "the label line was dropped" + assert out.splitlines()[-1] == "Pass neither to run interactively.", ( + "the trailing sentence was dropped" + ) + assert ( + "| `-a/--aggressive` | Also removes node_modules, logs, and IDE dirs |" in out + ), "a wrapped description did not fold into the row above" + + +def test_as_table_escapes_pipes(): + """`|` splits table cells even inside a code span, so it must be escaped.""" + import build_manual + + out = build_manual._as_table( + [" -r/-R Recurse into it", " -e|-E Empty it"] + ) + assert out is not None and r"`-e\|-E`" in out, f"pipe was not escaped:\n{out}" + + +def test_as_table_rejects_non_tables(): + """Returning None is always safe, so every ambiguous shape must return it.""" + import build_manual + + cases = { + "single row": [" -f/--force Force-delete unmerged branches too"], + "numbered list": [ + " 1. git+cargo source build (fish shell itself)", + " 2. cargo (Rust tools — gets latest crate version)", + ], + "misaligned rows": [ + " -e/--empty Empty the trash", + " -S/--secure Permanently delete (single space, not a column)", + ], + "synopsis continuation": [ + " auto-pull add [PATH]", + " auto-pull status", + ], + "unlabelled head": [ + "Routes to the best tool by context.", + " --disk force duf", + " --dir force dust", + ], + "live markdown in prose column": [ + " add Register 's git root", + " remove Unregister by basename", + ], + } + for label, para in cases.items(): + assert build_manual._as_table(para) is None, f"{label} was wrongly tabled" + + def test_prettify_leaves_reference_tables_alone(): """Column-aligned blocks are data, not shell, and must not be fenced.""" import build_manual