Merge pull request 'fix(docs): render function entries per-section for the Starlight site' (#149) from claude/starlight-doc-parsing-fix-voyyvi into main
CI / github-mirror (push) Skipped
CI / test (push) Successful in 2m1s
CI / build-docs (push) Successful in 3m32s

Reviewed-on: #149
This commit was merged in pull request #149.
This commit is contained in:
2026-09-14 22:59:26 +00:00
committed by Gitea
2 changed files with 303 additions and 4 deletions
+154 -4
View File
@@ -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 `term<padding>description`, 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 [<cmd>]`). 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 = {}
+149
View File
@@ -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 [<cmd>]`)."""
import build_manual
rows = build_manual._kv_rows(
[
"--cd, --no-cd Change into <dir> (default: --cd)",
"--new-remote [<cmd>]",
" Create + link a remote by running <cmd> (or",
" $MKREP_REMOTE_CMD) in the new repo directory",
]
)
assert rows == [
["--cd, --no-cd", "Change into <dir> (default: --cd)"],
[
"--new-remote [<cmd>]",
"Create + link a remote by running <cmd> (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.