docs(site): render aligned option blocks as markdown tables
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.
This commit is contained in:
@@ -140,6 +140,58 @@ def _is_shell(para: list[str], entry_name: str | None) -> bool:
|
|||||||
return True
|
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:
|
def _render_para(para: list[str], entry_name: str | None, deeper: bool) -> str:
|
||||||
"""Render one paragraph of a former indented block.
|
"""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):
|
if _is_shell(para, entry_name):
|
||||||
body = "\n".join(para)
|
body = "\n".join(para)
|
||||||
return f"```fish\n{body}\n```"
|
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)
|
return "\n".join(INDENT + line for line in para)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+68
-3
@@ -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 "```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 out.count("```") == 4, f"expected exactly two fences, got:\n{out}"
|
||||||
assert "\nSafe rm wrapper routing to trash:" in out, "description stayed indented"
|
assert "\nSafe rm wrapper routing to trash:" in out, "description stayed indented"
|
||||||
assert (
|
assert "| `(no args)` | List current trash contents |" in out, (
|
||||||
"\n (no args) List current trash contents" in out
|
"option table was not converted to a markdown table"
|
||||||
), "option table lost its indentation"
|
)
|
||||||
assert (
|
assert (
|
||||||
"\nFalls back to /usr/bin/rm when trash is unavailable." in out
|
"\nFalls back to /usr/bin/rm when trash is unavailable." in out
|
||||||
), "trailing prose stayed indented"
|
), "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 <PATH>'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():
|
def test_prettify_leaves_reference_tables_alone():
|
||||||
"""Column-aligned blocks are data, not shell, and must not be fenced."""
|
"""Column-aligned blocks are data, not shell, and must not be fenced."""
|
||||||
import build_manual
|
import build_manual
|
||||||
|
|||||||
Reference in New Issue
Block a user