feat(docs): generate Section 5 from function comment headers
The man-page-style comment header above each function in functions/*.fish becomes the SSOT for that function's documentation. Writing a new function and documenting it are now the same act. - manualtools.parse_functions() parses every header carrying a # CATEGORY; absence of one is the opt-in, keeping bundled-plugin and prompt internals out of the manual with no exclusion list to maintain. - build-manual.py generates entries for both --concat and --site, with **Dependencies:** rendered as links and a **Used by:** reverse index computed in one pass. Cross-category links are the navigation win. - docs/manual/05-functions/*.md reduced to frontmatter-only stubs. Every intro measured zero words, so the category files were pure entry containers; ordering, titles, and helpKeywords routing are untouched. - _first_sentence() unwraps the leading hard-wrapped paragraph and skips the whole Synopsis block, not just its label line. Site cards no longer truncate mid-clause or show a synopsis as their description. Verification, per the design spec: - test_concat_roundtrips_original scoped to sections 0-4 and 6-11. It guarded a format migration; this is a content migration. - replaced by structural checks: one entry per categorised function, the required sections present, every category resolving to a stub with no stub empty, and every declared dependency resolving to a real function or a type -q-guarded binary. - public functions lacking # CATEGORY warn rather than fail, so a new user-facing function going undocumented stays visible in CI. 24/24 checks pass. 94 entries generated from 94 parsed headers. Also drops a stale claim from open-url's NOTES: config-help --html calls xdg-open directly and has never called open-url.
This commit is contained in:
+151
-13
@@ -18,6 +18,37 @@ import manualtools as mt
|
||||
|
||||
DOCS = Path(__file__).parent
|
||||
MANUAL = DOCS / "manual"
|
||||
FUNCTIONS = DOCS.parent / "functions"
|
||||
SLUG_DIR = "reference"
|
||||
|
||||
|
||||
def _is_function_page(path: Path, root: Path) -> bool:
|
||||
"""True for a Section 5 category stub (not its index)."""
|
||||
rel = path.relative_to(root)
|
||||
return bool(rel.parts) and rel.parts[0].endswith("-functions") and rel.name != "index.md"
|
||||
|
||||
|
||||
def _entry_slug(title: str) -> str:
|
||||
"""The site's page slug for an entry heading."""
|
||||
return re.sub(r"[^\w-]+", "-", title.strip().lower()).strip("-")
|
||||
|
||||
|
||||
def _entry_link(name: str, functions: dict) -> str:
|
||||
"""Link a dependency name to its entry page; plain code span if unknown."""
|
||||
fn = functions.get(name)
|
||||
if not fn:
|
||||
return f"`{name}`"
|
||||
category = re.sub(r"^\d+-", "", fn["CATEGORY"][0])
|
||||
return f"[`{name}`](/{SLUG_DIR}/{category}/{_entry_slug(name)}/)"
|
||||
|
||||
|
||||
def _with_entries(body: str, path: Path, entries: dict) -> str:
|
||||
"""Append this category's generated `## name` entries to its stub body."""
|
||||
generated = entries.get(path.stem, [])
|
||||
if not generated:
|
||||
return body
|
||||
blocks = [f"## {name}\n\n{entry}" for name, entry in generated]
|
||||
return "\n\n".join(([body] if body.strip() else []) + blocks)
|
||||
|
||||
|
||||
def build_concat(root: Path) -> str:
|
||||
@@ -32,6 +63,7 @@ def build_concat(root: Path) -> str:
|
||||
present, its contents are re-emitted byte-for-byte as the leading
|
||||
`---`-fenced block, ahead of every heading.
|
||||
"""
|
||||
entries = build_entries(mt.parse_functions(FUNCTIONS))
|
||||
chunks: list[str] = []
|
||||
pandoc_path = root / "_pandoc.yml"
|
||||
if pandoc_path.exists():
|
||||
@@ -43,6 +75,8 @@ def build_concat(root: Path) -> str:
|
||||
continue
|
||||
heading = fm.get("manTitle") or fm.get("title", path.stem)
|
||||
chunks.append("#" * (depth + 1) + " " + heading)
|
||||
if _is_function_page(path, root):
|
||||
body = _with_entries(body, path, entries)
|
||||
if body:
|
||||
chunks.append(mt.shift_headings(body, depth))
|
||||
return "\n\n".join(chunks) + "\n"
|
||||
@@ -73,19 +107,46 @@ def _jsx_attr_escape(value: str) -> str:
|
||||
def _first_sentence(body: str) -> str:
|
||||
"""Extract a one-line description from the start of an entry body.
|
||||
|
||||
`Synopsis:` lines are skipped: they restate the calling convention,
|
||||
The `Synopsis:` block is skipped whole — label line plus its
|
||||
deeper-indented continuation lines. It restates the calling convention,
|
||||
which the card already shows as its title, so using one as the card
|
||||
description wastes the line.
|
||||
|
||||
Source prose is hard-wrapped, so the leading paragraph is unwrapped
|
||||
before the sentence match — otherwise a card truncates at the first
|
||||
line break, mid-clause.
|
||||
"""
|
||||
for line in body.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith(("#", "```", "|", "-", "*", ">")):
|
||||
para: list[str] = []
|
||||
in_fence = False
|
||||
syn_indent: int | None = None
|
||||
for raw in body.split("\n"):
|
||||
line = raw.strip()
|
||||
indent = len(raw) - len(raw.lstrip())
|
||||
if syn_indent is not None:
|
||||
if line and indent <= syn_indent:
|
||||
syn_indent = None
|
||||
else:
|
||||
continue
|
||||
if line.startswith("```"):
|
||||
in_fence = not in_fence
|
||||
if para:
|
||||
break
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
if not line or line.startswith(("#", "|", "-", "*", ">")):
|
||||
if para:
|
||||
break
|
||||
continue
|
||||
if line.startswith("Synopsis:"):
|
||||
syn_indent = indent
|
||||
continue
|
||||
m = SENTENCE_RE.match(line)
|
||||
return (m.group(1) if m else line)[:160]
|
||||
return ""
|
||||
para.append(line)
|
||||
if not para:
|
||||
return ""
|
||||
text = " ".join(para)
|
||||
m = SENTENCE_RE.match(text)
|
||||
return (m.group(1) if m else text)[:160]
|
||||
|
||||
|
||||
# Commands common enough in this manual that a block whose every line starts
|
||||
@@ -222,8 +283,12 @@ def _prettify_block(block: list[str], entry_name: str | None) -> str:
|
||||
|
||||
out: list[str] = []
|
||||
if lines and lines[0].startswith(SYNOPSIS_PREFIX):
|
||||
synopsis = lines.pop(0)[len(SYNOPSIS_PREFIX) :].strip()
|
||||
out.append(f"```fish\n{synopsis}\n```")
|
||||
synopsis = [lines.pop(0)[len(SYNOPSIS_PREFIX) :].strip()]
|
||||
# A multi-line synopsis is authored aligned under the first line;
|
||||
# keep the whole thing in one fence rather than orphaning the rest.
|
||||
while lines and lines[0].startswith(" "):
|
||||
synopsis.append(lines.pop(0).strip())
|
||||
out.append("```fish\n" + "\n".join(synopsis) + "\n```")
|
||||
|
||||
para: list[str] = []
|
||||
for line in lines + [""]:
|
||||
@@ -268,6 +333,76 @@ def prettify(body: str, entry_name: str | None = None) -> str:
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
ENTRY_HEADS = {"ARGUMENTS": "Arguments:", "RETURNS": "Returns:", "NOTES": "Notes:"}
|
||||
|
||||
|
||||
def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str:
|
||||
"""Render one parsed function header as a manual entry body.
|
||||
|
||||
Emits the same man-page shape Section 5 was authored in — one 4-space
|
||||
indented block opening with `Synopsis:` — so `prettify` keeps handling it
|
||||
for the site and pandoc keeps handling it for the man page, with no
|
||||
special case on either side.
|
||||
|
||||
`link` maps a function name to its markdown link, or is None for the man
|
||||
page, where a URL in the middle of a sentence is noise.
|
||||
"""
|
||||
out: list[str] = []
|
||||
syn = fn.get("SYNOPSIS", [])
|
||||
if syn:
|
||||
pad = " " * len(SYNOPSIS_PREFIX + " ")
|
||||
out.append(f"{SYNOPSIS_PREFIX} {syn[0]}")
|
||||
out += [pad + line for line in syn[1:]]
|
||||
out.append("")
|
||||
for line in fn.get("DESCRIPTION", []):
|
||||
out.append(line)
|
||||
for label, head in ENTRY_HEADS.items():
|
||||
body = fn.get(label)
|
||||
if not body:
|
||||
continue
|
||||
out += ["", head] + [" " + line for line in body]
|
||||
if fn.get("EXAMPLE"):
|
||||
out += [""] + fn["EXAMPLE"]
|
||||
|
||||
block = "\n".join((INDENT + line).rstrip() for line in out)
|
||||
|
||||
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:
|
||||
block += "\n\n" + "\n\n".join(refs)
|
||||
return block
|
||||
|
||||
|
||||
def build_entries(functions: dict[str, dict], link=None) -> 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.
|
||||
"""
|
||||
used_by: dict[str, list[str]] = {}
|
||||
for name, fn in functions.items():
|
||||
for dep in re.split(r"[,\s]+", " ".join(fn.get("DEPENDENCIES", []))):
|
||||
if dep in functions:
|
||||
used_by.setdefault(dep, []).append(name)
|
||||
|
||||
out: dict[str, list[tuple[str, str]]] = {}
|
||||
for name in sorted(functions):
|
||||
fn = functions[name]
|
||||
body = render_entry(fn, used_by.get(name, []), link)
|
||||
out.setdefault(fn["CATEGORY"][0], []).append((name, body))
|
||||
return out
|
||||
|
||||
|
||||
def _page_fm(fm: dict) -> dict:
|
||||
"""Strip pipeline-only keys from frontmatter destined for the site."""
|
||||
return {k: v for k, v in fm.items() if k not in PIPELINE_KEYS}
|
||||
@@ -315,6 +450,9 @@ def build_site(root: Path, out: Path) -> list[dict]:
|
||||
shutil.rmtree(out)
|
||||
out.mkdir(parents=True)
|
||||
|
||||
functions = mt.parse_functions(FUNCTIONS)
|
||||
entries = build_entries(functions, link=lambda n: _entry_link(n, functions))
|
||||
|
||||
sidebar: list[dict] = []
|
||||
functions_group: dict = {}
|
||||
for path, _depth in mt.walk(root):
|
||||
@@ -341,7 +479,7 @@ def build_site(root: Path, out: Path) -> list[dict]:
|
||||
# upload. The pages build fine and never arrive — every entry 404s in
|
||||
# production while working locally. test_site_avoids_reserved_dir
|
||||
# guards this.
|
||||
slug_dir = "reference"
|
||||
slug_dir = SLUG_DIR
|
||||
if rel.name == "index.md":
|
||||
target = out / slug_dir / "index.md"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -360,12 +498,12 @@ def build_site(root: Path, out: Path) -> list[dict]:
|
||||
category = re.sub(r"^\d+-", "", rel.stem)
|
||||
cat_dir = out / slug_dir / category
|
||||
cat_dir.mkdir(parents=True, exist_ok=True)
|
||||
intro, entries = _split_entries(body)
|
||||
intro, page_entries = _split_entries(_with_entries(body, path, entries))
|
||||
|
||||
cards = []
|
||||
links = []
|
||||
for title, entry_body in entries:
|
||||
entry_slug = re.sub(r"[^\w-]+", "-", title.strip().lower()).strip("-")
|
||||
for title, entry_body in page_entries:
|
||||
entry_slug = _entry_slug(title)
|
||||
desc = _first_sentence(entry_body)
|
||||
entry_fm = {"title": title}
|
||||
if desc:
|
||||
|
||||
Reference in New Issue
Block a user