From 63e71ac9ddbd80e7cda9041f09f1810bd2c5a060 Mon Sep 17 00:00:00 2001 From: rootiest Date: Sat, 25 Jul 2026 21:39:48 -0400 Subject: [PATCH] feat(docs): generate fish-config.md from the manual tree Adds docs/build-manual.py (--concat) and a round-trip test in verify-manual.py that reproduces the pre-migration fish-config.md exactly from docs/manual/. Fixes found by the round-trip check, applied in split-manual.py and re-run to regenerate docs/manual/: - NAME/SYNOPSIS/TABLE OF CONTENTS had no sidebar.order, so they sorted after every numbered section instead of interleaving with DESCRIPTION in original document order. All manual pages now get sidebar.order from a single position counter matching source order. - split_h1/split_h2 used .strip() on section bodies, which also ate leading indentation on the first body line, corrupting the 4-space indented code blocks that open SYNOPSIS and TABLE OF CONTENTS. - The source's leading pandoc metadata block (title/section/header/ date/author) was discarded entirely by the splitter. It's now parsed off via manualtools.parse and stashed on index.md under a "pandoc" key; build-manual.py re-emits it verbatim ahead of the first heading. --- docs/build-manual.py | 70 ++++++++++++++++++++++ docs/manual/00-name.md | 2 + docs/manual/00-synopsis.md | 4 +- docs/manual/00-table-of-contents.md | 4 +- docs/manual/01-configuration-variables.md | 2 +- docs/manual/02-path-setup.md | 2 +- docs/manual/03-key-bindings.md | 2 +- docs/manual/04-abbreviations.md | 2 +- docs/manual/05-functions/index.md | 2 +- docs/manual/06-dependency-catalog.md | 2 +- docs/manual/07-customization.md | 2 +- docs/manual/08-fisher-plugins.md | 2 +- docs/manual/09-installation.md | 2 +- docs/manual/10-personalization.md | 2 +- docs/manual/11-viewing-this-manual.md | 2 +- docs/manual/index.md | 8 ++- docs/split-manual.py | 49 ++++++++++++--- docs/verify-manual.py | 73 +++++++++++++++++++++++ 18 files changed, 211 insertions(+), 21 deletions(-) create mode 100644 docs/build-manual.py diff --git a/docs/build-manual.py b/docs/build-manual.py new file mode 100644 index 0000000..3e95e86 --- /dev/null +++ b/docs/build-manual.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Generate publishable artifacts from the docs/manual SSOT. + + --concat one ordered markdown document for pandoc / config-help + --site Starlight content tree + sidebar.json +""" + +import argparse +import sys +from pathlib import Path + +import yaml + +import manualtools as mt + +DOCS = Path(__file__).parent +MANUAL = DOCS / "manual" + + +def build_concat(root: Path) -> str: + """Concatenate the manual into one ordered markdown document. + + Each file contributes `# {manTitle or title}` at a level matching its + depth, and its body headings are demoted by the same amount. + + The root `index.md` (the LANDING page) may carry a `pandoc` key in its + frontmatter — the original document's pandoc metadata block + (title/section/header/date/author). If present, it is re-emitted + verbatim as the leading `---`-fenced block, ahead of every heading. + """ + chunks: list[str] = [] + index_fm, _ = mt.parse(root / "index.md") + pandoc_meta = index_fm.get("pandoc") + if pandoc_meta: + header = yaml.safe_dump(pandoc_meta, sort_keys=False, allow_unicode=True).rstrip() + chunks.append(f"---\n{header}\n---") + for path, depth in mt.walk(root): + fm, body = mt.parse(path) + if not fm.get("man", True): + continue + heading = fm.get("manTitle") or fm.get("title", path.stem) + chunks.append("#" * (depth + 1) + " " + heading) + if body: + chunks.append(mt.shift_headings(body, depth)) + return "\n\n".join(chunks) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--concat", action="store_true", help="emit the pandoc document") + ap.add_argument("-o", "--output", type=Path, help="write to PATH instead of stdout") + args = ap.parse_args() + + if not args.concat: + ap.error("nothing to do: pass --concat") + + text = build_concat(MANUAL) + if args.output: + args.output.write_text(text) + print(f"wrote {args.output}") + else: + sys.stdout.write(text) + return 0 + + +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).parent)) + raise SystemExit(main()) diff --git a/docs/manual/00-name.md b/docs/manual/00-name.md index 11f3751..6f1f6eb 100644 --- a/docs/manual/00-name.md +++ b/docs/manual/00-name.md @@ -3,6 +3,8 @@ title: Name manTitle: NAME man: true site: false +sidebar: + order: 1 --- fish-config - personal fish shell configuration for Fish 4.x with modern CLI tool integration diff --git a/docs/manual/00-synopsis.md b/docs/manual/00-synopsis.md index 5d2fa97..681b193 100644 --- a/docs/manual/00-synopsis.md +++ b/docs/manual/00-synopsis.md @@ -3,9 +3,11 @@ title: Synopsis manTitle: SYNOPSIS man: true site: false +sidebar: + order: 2 --- -help config [SECTION] + help config [SECTION] Open this manual in the best available pager. Optionally jump to a section by keyword: diff --git a/docs/manual/00-table-of-contents.md b/docs/manual/00-table-of-contents.md index 52d2205..74d43ad 100644 --- a/docs/manual/00-table-of-contents.md +++ b/docs/manual/00-table-of-contents.md @@ -3,9 +3,11 @@ title: Table Of Contents manTitle: TABLE OF CONTENTS man: true site: false +sidebar: + order: 4 --- -1. Configuration Variables + 1. Configuration Variables 2. PATH Setup 3. Key Bindings 4. Abbreviations diff --git a/docs/manual/01-configuration-variables.md b/docs/manual/01-configuration-variables.md index 65aad02..7f4fde7 100644 --- a/docs/manual/01-configuration-variables.md +++ b/docs/manual/01-configuration-variables.md @@ -2,7 +2,7 @@ title: Configuration Variables manTitle: 1. CONFIGURATION VARIABLES sidebar: - order: 1 + order: 5 helpKeywords: - variables - config diff --git a/docs/manual/02-path-setup.md b/docs/manual/02-path-setup.md index 78f8680..2c36092 100644 --- a/docs/manual/02-path-setup.md +++ b/docs/manual/02-path-setup.md @@ -2,7 +2,7 @@ title: Path Setup manTitle: 2. PATH SETUP sidebar: - order: 2 + order: 6 helpKeywords: - path --- diff --git a/docs/manual/03-key-bindings.md b/docs/manual/03-key-bindings.md index 68c4f5f..76a8931 100644 --- a/docs/manual/03-key-bindings.md +++ b/docs/manual/03-key-bindings.md @@ -2,7 +2,7 @@ title: Key Bindings manTitle: 3. KEY BINDINGS sidebar: - order: 3 + order: 7 helpKeywords: - keybindings - bindings diff --git a/docs/manual/04-abbreviations.md b/docs/manual/04-abbreviations.md index f647a14..d0e986e 100644 --- a/docs/manual/04-abbreviations.md +++ b/docs/manual/04-abbreviations.md @@ -2,7 +2,7 @@ title: Abbreviations manTitle: 4. ABBREVIATIONS sidebar: - order: 4 + order: 8 helpKeywords: - abbreviations - abbr diff --git a/docs/manual/05-functions/index.md b/docs/manual/05-functions/index.md index de97101..2755f32 100644 --- a/docs/manual/05-functions/index.md +++ b/docs/manual/05-functions/index.md @@ -2,7 +2,7 @@ title: Functions Reference manTitle: 5. FUNCTIONS REFERENCE sidebar: - order: 5 + order: 9 helpKeywords: - functions --- diff --git a/docs/manual/06-dependency-catalog.md b/docs/manual/06-dependency-catalog.md index 760c5ce..2e6ff3c 100644 --- a/docs/manual/06-dependency-catalog.md +++ b/docs/manual/06-dependency-catalog.md @@ -2,7 +2,7 @@ title: Dependency Catalog manTitle: 6. DEPENDENCY CATALOG sidebar: - order: 6 + order: 10 helpKeywords: - catalog - deps-catalog diff --git a/docs/manual/07-customization.md b/docs/manual/07-customization.md index c802fc7..ceece81 100644 --- a/docs/manual/07-customization.md +++ b/docs/manual/07-customization.md @@ -2,7 +2,7 @@ title: Customization manTitle: 7. CUSTOMIZATION sidebar: - order: 7 + order: 11 helpKeywords: - customization - customize diff --git a/docs/manual/08-fisher-plugins.md b/docs/manual/08-fisher-plugins.md index 9d3edd6..685303d 100644 --- a/docs/manual/08-fisher-plugins.md +++ b/docs/manual/08-fisher-plugins.md @@ -2,7 +2,7 @@ title: Fisher Plugins manTitle: 8. FISHER PLUGINS sidebar: - order: 8 + order: 12 helpKeywords: - plugins - fisher diff --git a/docs/manual/09-installation.md b/docs/manual/09-installation.md index 554f2ab..db34247 100644 --- a/docs/manual/09-installation.md +++ b/docs/manual/09-installation.md @@ -2,7 +2,7 @@ title: Installation manTitle: 9. INSTALLATION sidebar: - order: 9 + order: 13 helpKeywords: - installation - install diff --git a/docs/manual/10-personalization.md b/docs/manual/10-personalization.md index 71b093a..81cd4b7 100644 --- a/docs/manual/10-personalization.md +++ b/docs/manual/10-personalization.md @@ -2,7 +2,7 @@ title: Personalization manTitle: 10. PERSONALIZATION sidebar: - order: 10 + order: 14 helpKeywords: - personalization - personalize diff --git a/docs/manual/11-viewing-this-manual.md b/docs/manual/11-viewing-this-manual.md index 41340d3..4e1079a 100644 --- a/docs/manual/11-viewing-this-manual.md +++ b/docs/manual/11-viewing-this-manual.md @@ -2,7 +2,7 @@ title: Viewing This Manual manTitle: 11. VIEWING THIS MANUAL sidebar: - order: 11 + order: 15 helpKeywords: - viewing - manual diff --git a/docs/manual/index.md b/docs/manual/index.md index 8385cf4..2889163 100644 --- a/docs/manual/index.md +++ b/docs/manual/index.md @@ -3,7 +3,13 @@ title: Fish Shell Configuration description: Reference manual for the rootiest fish configuration. manTitle: DESCRIPTION sidebar: - order: 0 + order: 3 +pandoc: + title: FISH-CONFIG + section: 7 + header: Fish Shell Configuration User Manual + date: June 2026 + author: Rootiest helpKeywords: - description - autopair diff --git a/docs/split-manual.py b/docs/split-manual.py index 50fdaa7..aa45f03 100644 --- a/docs/split-manual.py +++ b/docs/split-manual.py @@ -50,15 +50,34 @@ def load_keywords() -> dict[str, list[str]]: return mapping +def _trim_blank_lines(text: str) -> str: + """Drop leading/trailing blank lines without touching interior indentation. + + Plain `.strip()` also eats leading spaces on the first line, which + destroys 4-space-indented code blocks that start immediately after a + heading (e.g. the SYNOPSIS and TABLE OF CONTENTS sections). + """ + lines = text.split("\n") + while lines and lines[0].strip() == "": + lines.pop(0) + while lines and lines[-1].strip() == "": + lines.pop() + return "\n".join(lines) + + def split_h1(text: str) -> list[tuple[str, str]]: parts = re.split(r"^# (.+)$", text, flags=re.MULTILINE) - return [(parts[i].strip(), parts[i + 1].strip()) for i in range(1, len(parts), 2)] + return [ + (parts[i].strip(), _trim_blank_lines(parts[i + 1])) for i in range(1, len(parts), 2) + ] def split_h2(body: str) -> tuple[str, list[tuple[str, str]]]: parts = re.split(r"^## (.+)$", body, flags=re.MULTILINE) - intro = parts[0].strip() - subs = [(parts[i].strip(), parts[i + 1].strip()) for i in range(1, len(parts), 2)] + intro = _trim_blank_lines(parts[0]) + subs = [ + (parts[i].strip(), _trim_blank_lines(parts[i + 1])) for i in range(1, len(parts), 2) + ] return intro, subs @@ -71,10 +90,23 @@ def main() -> int: OUT.mkdir(parents=True) keywords = load_keywords() - sections = split_h1(SRC.read_text()) + # SRC starts with a pandoc metadata block (title/section/header/date/ + # author) consumed by the man-page build. mt.parse() peels it off so it + # isn't silently dropped; it gets stashed on the LANDING (index.md) page + # under "pandoc" and re-emitted verbatim by build-manual.py --concat. + pandoc_meta, src_body = mt.parse(SRC) + sections = split_h1(src_body) order = 0 + # `position` tracks each heading's place in the *original* document, + # including NAME/SYNOPSIS/DESCRIPTION/TABLE OF CONTENTS. It is what + # `sidebar.order` gets set to, so that manualtools.walk() (and thus + # build-manual.py --concat) reproduces the source order exactly. + # `order` (below) is unrelated: it only numbers the *numbered* sections + # (1. CONFIGURATION VARIABLES, 2. PATH SETUP, ...) for filenames. + position = 0 for heading, body in sections: + position += 1 kw = keywords.get(heading, []) if heading in MAN_ONLY: path = OUT / f"00-{slugify(heading)}.md" @@ -83,6 +115,7 @@ def main() -> int: "manTitle": heading, "man": True, "site": False, + "sidebar": {"order": position}, } path.write_text(mt.serialize(fm, body)) continue @@ -92,8 +125,10 @@ def main() -> int: "title": "Fish Shell Configuration", "description": "Reference manual for the rootiest fish configuration.", "manTitle": heading, - "sidebar": {"order": 0}, + "sidebar": {"order": position}, } + if pandoc_meta: + fm["pandoc"] = pandoc_meta if kw: fm["helpKeywords"] = kw (OUT / "index.md").write_text(mt.serialize(fm, body)) @@ -108,7 +143,7 @@ def main() -> int: fm = { "title": display_title(heading), "manTitle": heading, - "sidebar": {"order": order}, + "sidebar": {"order": position}, } if kw: fm["helpKeywords"] = kw @@ -133,7 +168,7 @@ def main() -> int: fm = { "title": display_title(heading), "manTitle": heading, - "sidebar": {"order": order}, + "sidebar": {"order": position}, } if kw: fm["helpKeywords"] = kw diff --git a/docs/verify-manual.py b/docs/verify-manual.py index ec4f3bb..ba95ac3 100644 --- a/docs/verify-manual.py +++ b/docs/verify-manual.py @@ -3,12 +3,27 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Verification checks for the docs/manual SSOT pipeline.""" +import importlib.util import sys import tempfile from pathlib import Path import manualtools as mt +# docs/build-manual.py follows this repo's hyphenated CLI-script naming +# convention (matching split-manual.py, verify-manual.py), which means it +# cannot satisfy a plain `import build_manual` on its own — Python's import +# statement never treats a hyphen as an underscore. Load it explicitly under +# the name the tests expect and register it in sys.modules; every later +# `import build_manual` (including the one inside test_concat_roundtrips_ +# original below) then finds the cached module instead of touching the path +# finder. +_build_manual_path = Path(__file__).parent / "build-manual.py" +_spec = importlib.util.spec_from_file_location("build_manual", _build_manual_path) +_build_manual = importlib.util.module_from_spec(_spec) +sys.modules["build_manual"] = _build_manual +_spec.loader.exec_module(_build_manual) + def test_parse_roundtrip(): fm = {"title": "Git", "sidebar": {"order": 4}, "helpKeywords": ["git", "gi"]} @@ -104,6 +119,64 @@ def test_function_entries_promoted_to_h2(): assert "\n## " in f"\n{body}", f"{path.name} has no H2 function entries" +def _normalise(text: str) -> str: + """Collapse whitespace so only content differences survive.""" + lines = [ln.rstrip() for ln in text.strip().split("\n")] + return "\n".join(ln for ln in lines if ln != "") + + +def test_concat_roundtrips_original(): + """The concat of manual/ must reproduce the original fish-config.md. + + Prefers docs/fish-config.md.orig (a snapshot of the pre-migration file) + when present. Once that snapshot is deleted post-migration, + docs/fish-config.md IS the concat output regenerated in Step 5, so + falling back to it turns this into an idempotency regression check + instead of going red for a missing file. + """ + import build_manual + + docs = Path(__file__).parent + original = docs / "fish-config.md.orig" + label = "original" + if not original.exists(): + original = docs / "fish-config.md" + label = "fish-config.md" + got = _normalise(build_manual.build_concat(docs / "manual")) + want = _normalise(original.read_text()) + if got != want: + import difflib + + diff = list( + difflib.unified_diff( + want.split("\n"), got.split("\n"), label, "concat", lineterm="", n=1 + ) + )[:40] + raise AssertionError("concat differs from original:\n" + "\n".join(diff)) + + +def test_every_index_keyword_resolves(): + """Every keyword in fish-config.index must match a heading in the concat.""" + import build_manual + + docs = Path(__file__).parent + index = docs / "fish-config.index" + if not index.exists(): + print(" SKIP test_every_index_keyword_resolves (no index file)") + return + concat = build_manual.build_concat(docs / "manual") + headings = {ln.strip() for ln in concat.split("\n") if ln.startswith("#")} + missing = [] + for line in index.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + keyword, heading = line.split("=", 1) + if heading.strip() not in headings: + missing.append(f"{keyword.strip()} -> {heading.strip()}") + assert not missing, "unresolvable index keywords:\n " + "\n ".join(missing) + + TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]