From 2c1aa52871045a956bfb9843aad466d9b4e68f0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 22:47:06 +0000 Subject: [PATCH] fix(docs): render function entries per-section for the Starlight site Function entry pages previously reused the man-page pipeline's single indented block plus its paragraph-guessing heuristics (_is_prose's per-line word count, _as_table's 2+ row minimum), so whether a Description, Arguments, or Exit Status section landed as flowing prose, a table, or an unhighlighted code block depended on incidental shape -- a short wrapped line, a single argument, a narrow column -- rather than which section it was. `cat`, `copy`, and `ltr` each ended up formatted differently for no functional reason. Add render_entry_site, a site-only renderer that builds each entry straight from the parsed function header instead of re-deriving structure from indented text: every present section (Synopsis, Description, Arguments, Exit Status, Returns, Notes, Example) gets its own `###` heading, Arguments/Exit Status become a table via the new _kv_rows column parser (which also fixes narrow single-space columns and name-only rows with wrapped continuations), and everything else is unwrapped into normal paragraphs. The man-page/pandoc path (render_entry, build_concat) is untouched. --- docs/build-manual.py | 158 ++++++++++++++++++++++++++++++++++++++++-- docs/verify-manual.py | 149 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+), 4 deletions(-) diff --git a/docs/build-manual.py b/docs/build-manual.py index c9a0224..e80ebca 100644 --- a/docs/build-manual.py +++ b/docs/build-manual.py @@ -749,12 +749,161 @@ def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str return block -def build_entries(functions: dict[str, dict], link=None) -> dict[str, list[tuple[str, str]]]: +def _kv_cell(text: str, code: bool) -> str: + """Render one Arguments/Exit-Status table cell, code-protecting live MDX chars.""" + needs_protection = ("<" in text or "{" in text) and "`" not in text + return _cell(text, code or needs_protection) + + +def _kv_rows(lines: list[str]) -> list[list[str]] | None: + """Parse a structurally two-column section (ARGUMENTS, EXIT STATUS) into rows. + + Source rows are authored `termdescription`, padded to a shared + column so terms line up. A term too wide for that column instead sits + alone on its own line, with the description wrapped onto the next, + deeper-indented line(s) (see `mkrep`'s `--new-remote []`). A plain + `\\s{2,}` split recovers most rows, but a term that exactly fills the + column can leave only one space before its description -- so the + shared column (the widest successful split) is reused to split those. + + Returns None when the lines don't fit this shape at all -- a blank line + inside the section, or a name-only line with no continuation to supply + its description -- which means it's prose, not a table (e.g. "Exit + status of eza, lsd, or ls, whichever ran"). + """ + if not lines or any(not line.strip() for line in lines): + return None + cols: list[int] = [] + parsed: list[tuple] = [] + for line in lines: + if len(line) - len(line.lstrip()) > 0: + parsed.append(("cont", line.strip())) + continue + m = CELL_SPLIT.search(line) + if m: + cols.append(m.end()) + parsed.append(("row", line[: m.start()].strip(), line[m.end() :].strip())) + else: + parsed.append(("maybe", line)) + if not cols: + return None + col = max(cols) + rows: list[list[str]] = [] + for item in parsed: + if item[0] == "cont": + if not rows: + return None + rows[-1][1] = (rows[-1][1] + " " + item[1]).strip() + elif item[0] == "row": + rows.append([item[1], item[2]]) + else: + line = item[1] + if col - 1 < len(line) and line[col - 1] == " " and line[col:].strip(): + rows.append([line[:col].strip(), line[col:].strip()]) + else: + rows.append([line.strip(), ""]) + if any(not desc for _, desc in rows): + return None + return rows + + +def _unwrap_prose(lines: list[str]) -> str: + """Rejoin hard-wrapped comment lines into flowing paragraphs. + + Function-header prose is authored wrapped to a fixed column; a blank + `#` line (rare, but legal) separates paragraphs. + """ + paragraphs: list[str] = [] + para: list[str] = [] + for line in lines + [""]: + if line.strip(): + para.append(line.strip()) + elif para: + paragraphs.append(" ".join(para)) + para = [] + return "\n\n".join(paragraphs) + + +def _section_body(lines: list[str]) -> str: + """Render one section's raw comment lines for the site. + + A structurally two-column section (Arguments, Exit Status) becomes a + table; everything else (a single sentence, a "See X --help" pointer) + is prose, unwrapped back into flowing paragraphs. + """ + rows = _kv_rows(lines) + if rows is None: + return _unwrap_prose(lines) + out = ["| | |", "|---|---|"] + out += [f"| {_kv_cell(k, True)} | {_kv_cell(v, False)} |" for k, v in rows] + return "\n".join(out) + + +SITE_SECTIONS = ( + ("ARGUMENTS", "Arguments"), + ("EXIT STATUS", "Exit Status"), + ("RETURNS", "Returns"), + ("NOTES", "Notes"), +) + + +def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) -> str: + """Render one parsed function header as a manual entry body for the site. + + Unlike `render_entry` (the single indented man-page block pandoc wants, + which the site then has to reverse-engineer paragraph shape out of), + this renders each section straight from the parsed header: a heading + per section, Arguments/Exit Status as a table, everything else as + prose. Every entry page reads the same way regardless of how many rows + or lines any one section happens to carry, and each section is its own + jump-to-able heading. + """ + parts: list[str] = [] + + syn = fn.get("SYNOPSIS", []) + if syn: + parts.append('### Synopsis\n\n```fish title="Usage"\n' + "\n".join(syn) + "\n```") + + desc = fn.get("DESCRIPTION", []) + if desc: + parts.append("### Description\n\n" + _unwrap_prose(desc)) + + for label, title in SITE_SECTIONS: + body = fn.get(label) + if not body: + continue + parts.append(f"### {title}\n\n" + _section_body(body)) + + example = fn.get("EXAMPLE") + if example: + parts.append('### Example\n\n```fish\n' + "\n".join(example) + "\n```") + + def names(raw: list[str]) -> list[str]: + return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n] + + refs = [] + for label, values in ( + ("Dependencies", names(fn.get("DEPENDENCIES", []))), + ("Used by", sorted(used_by)), + ): + if values: + rendered = ", ".join(link(v) if link else f"`{v}`" for v in values) + refs.append(f"**{label}:** {rendered}") + if refs: + parts.append("\n\n".join(refs)) + + return "\n\n".join(parts) + + +def build_entries( + functions: dict[str, dict], link=None, site: bool = False +) -> dict[str, list[tuple[str, str]]]: """Group rendered entries by category stem, ordered by function name. The `Used by` reverse index is computed here in one pass rather than authored: a bidirectional link maintained by hand drifts the moment one - side is edited. + side is edited. `site` selects `render_entry_site` (headings + tables) + over `render_entry` (the man-page indented block `build_concat` needs). """ used_by: dict[str, list[str]] = {} for name, fn in functions.items(): @@ -762,10 +911,11 @@ def build_entries(functions: dict[str, dict], link=None) -> dict[str, list[tuple if dep in functions: used_by.setdefault(dep, []).append(name) + render = render_entry_site if site else render_entry out: dict[str, list[tuple[str, str]]] = {} for name in sorted(functions): fn = functions[name] - body = render_entry(fn, used_by.get(name, []), link) + body = render(fn, used_by.get(name, []), link) out.setdefault(fn["CATEGORY"][0], []).append((name, body)) return out @@ -866,7 +1016,7 @@ def build_site(root: Path, out: Path) -> list[dict]: out.mkdir(parents=True) functions = mt.parse_functions(FUNCTIONS) - entries = build_entries(functions, link=lambda n: _entry_link(n, functions)) + entries = build_entries(functions, link=lambda n: _entry_link(n, functions), site=True) sidebar: list[dict] = [{"label": "Home", "link": "/"}] standard_groups: dict = {} diff --git a/docs/verify-manual.py b/docs/verify-manual.py index bbb140b..e3749b3 100644 --- a/docs/verify-manual.py +++ b/docs/verify-manual.py @@ -1042,6 +1042,155 @@ def test_returns_renders_after_exit_status(): assert exit_pos < returns_pos, f"Returns: rendered before Exit Status::\n{out}" +def test_render_entry_site_headings_every_present_section(): + """`render_entry_site` gives each present header its own `###` heading, in order.""" + import build_manual + + fn = { + "SYNOPSIS": ["thing [args...]"], + "DESCRIPTION": ["Does a thing."], + "ARGUMENTS": ["args... Arguments forwarded to the thing"], + "EXIT STATUS": ["0 Always"], + "RETURNS": ["The thing, printed to stdout"], + "NOTES": ["Some extra context."], + "EXAMPLE": ["thing foo"], + } + out = build_manual.render_entry_site(fn, []) + headings = ["Synopsis", "Description", "Arguments", "Exit Status", "Returns", "Notes", "Example"] + positions = [out.find(f"### {h}") for h in headings] + assert all(p != -1 for p in positions), f"missing a heading:\n{out}" + assert positions == sorted(positions), f"sections out of order:\n{out}" + + +def test_render_entry_site_single_argument_becomes_a_table_not_a_code_block(): + """A one-row Arguments section (e.g. `cat`'s `args...`) is still a table. + + Regression guard: the old man-page-block path required >= 2 rows before + it would recognise a table, so a single argument fell through to an + unhighlighted code-block fallback. + """ + import build_manual + + fn = { + "SYNOPSIS": ["cat [args...]"], + "DESCRIPTION": ["Enhanced cat replacement."], + "ARGUMENTS": ["args... Files or directories to display"], + "EXAMPLE": ["cat README.md"], + } + out = build_manual.render_entry_site(fn, []) + assert "```text" not in out, f"single-row Arguments fell back to a code block:\n{out}" + assert "| `args...` | Files or directories to display |" in out, out + + +def test_render_entry_site_wrapped_description_stays_prose(): + """A hard-wrapped DESCRIPTION whose last line is short still reads as prose. + + Regression guard for the exact `cat.fish` bug report: the man-page path + ran a per-line "each line has >= 3 words" prose heuristic that broke on + a short final line like "installed.", sending the whole description to + an unhighlighted code-block fallback instead of flowing text. + """ + import build_manual + + fn = { + "SYNOPSIS": ["cat [args...]"], + "DESCRIPTION": [ + "Enhanced cat replacement. Wraps bat for files, giving syntax highlighting", + "and line numbers; falls back to raw cat for ANSI-colored log files if bat is not", + "installed.", + ], + "EXAMPLE": ["cat README.md"], + } + out = build_manual.render_entry_site(fn, []) + assert "```text" not in out, f"wrapped description fell back to a code block:\n{out}" + assert ( + "Enhanced cat replacement. Wraps bat for files, giving syntax highlighting and " + "line numbers; falls back to raw cat for ANSI-colored log files if bat is not " + "installed." in out + ), f"description was not unwrapped into one flowing paragraph:\n{out}" + + +def test_render_entry_site_single_sentence_exit_status_stays_prose(): + """A one-sentence EXIT STATUS (e.g. `ltr`'s) renders as prose, not a table or code block.""" + import build_manual + + fn = { + "SYNOPSIS": ["ltr [args...]"], + "DESCRIPTION": ["Reversed time-sorted listing."], + "EXIT STATUS": ["Exit status of eza, lsd, or ls, whichever ran"], + "EXAMPLE": ["ltr ~/projects"], + } + out = build_manual.render_entry_site(fn, []) + assert "```text" not in out, f"prose Exit Status fell back to a code block:\n{out}" + assert "| | |" not in out, f"a single sentence was wrongly tabled:\n{out}" + assert "### Exit Status\n\nExit status of eza, lsd, or ls, whichever ran" in out, out + + +def test_kv_rows_splits_a_narrow_single_space_column(): + """A term that fills the padded column leaves only one space -- still a table. + + `rm.fish` authors `-r, -R, --recursive Forwarded to trash put...` with + only one space before the description because the term exactly fills + the shared column; the shared-column reuse must still split it. + """ + import build_manual + + rows = build_manual._kv_rows( + [ + "(none) List current trash contents", + "-r, -R, --recursive Forwarded to trash put alongside path arguments", + ] + ) + assert rows == [ + ["(none)", "List current trash contents"], + ["-r, -R, --recursive", "Forwarded to trash put alongside path arguments"], + ], rows + + +def test_kv_rows_folds_a_name_only_line_with_wrapped_continuation(): + """A term too wide for the column sits alone; its description wraps onto + the next, deeper-indented line(s) (`mkrep`'s `--new-remote []`).""" + import build_manual + + rows = build_manual._kv_rows( + [ + "--cd, --no-cd Change into (default: --cd)", + "--new-remote []", + " Create + link a remote by running (or", + " $MKREP_REMOTE_CMD) in the new repo directory", + ] + ) + assert rows == [ + ["--cd, --no-cd", "Change into (default: --cd)"], + [ + "--new-remote []", + "Create + link a remote by running (or $MKREP_REMOTE_CMD) in the new repo directory", + ], + ], rows + + +def test_kv_rows_rejects_a_bare_prose_sentence(): + """A single sentence with no column structure and no continuation is prose, not a table.""" + import build_manual + + assert build_manual._kv_rows(["See jobrunner --help for the full argument reference."]) is None + assert build_manual._kv_rows(["Exit status of eza, lsd, or ls, whichever ran"]) is None + + +def test_build_entries_site_matches_build_entries_man_by_function_set(): + """The site path (headings/tables) must document exactly the same + functions as the man-page path (indented blocks) -- only the rendering + differs, never the coverage.""" + import build_manual + + functions = _parsed_functions() + man_entries = build_manual.build_entries(functions) + site_entries = build_manual.build_entries(functions, site=True) + man_names = {name for names in man_entries.values() for name, _ in names} + site_names = {name for names in site_entries.values() for name, _ in names} + assert man_names == site_names, f"coverage mismatch: {man_names ^ site_names}" + + def test_site_avoids_reserved_dir(): """No output directory may collide with a Cloudflare Pages reserved name.