fix(docs): render function entries per-section for the Starlight site #149
+154
-4
@@ -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 = {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user