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.
This commit is contained in:
2026-07-25 21:39:48 -04:00
parent 61a82540fb
commit 63e71ac9dd
18 changed files with 211 additions and 21 deletions
+70
View File
@@ -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())
+2
View File
@@ -3,6 +3,8 @@ title: Name
manTitle: NAME manTitle: NAME
man: true man: true
site: false site: false
sidebar:
order: 1
--- ---
fish-config - personal fish shell configuration for Fish 4.x with modern CLI tool integration fish-config - personal fish shell configuration for Fish 4.x with modern CLI tool integration
+3 -1
View File
@@ -3,9 +3,11 @@ title: Synopsis
manTitle: SYNOPSIS manTitle: SYNOPSIS
man: true man: true
site: false site: false
sidebar:
order: 2
--- ---
help config [SECTION] help config [SECTION]
Open this manual in the best available pager. Optionally jump to a section Open this manual in the best available pager. Optionally jump to a section
by keyword: by keyword:
+3 -1
View File
@@ -3,9 +3,11 @@ title: Table Of Contents
manTitle: TABLE OF CONTENTS manTitle: TABLE OF CONTENTS
man: true man: true
site: false site: false
sidebar:
order: 4
--- ---
1. Configuration Variables 1. Configuration Variables
2. PATH Setup 2. PATH Setup
3. Key Bindings 3. Key Bindings
4. Abbreviations 4. Abbreviations
+1 -1
View File
@@ -2,7 +2,7 @@
title: Configuration Variables title: Configuration Variables
manTitle: 1. CONFIGURATION VARIABLES manTitle: 1. CONFIGURATION VARIABLES
sidebar: sidebar:
order: 1 order: 5
helpKeywords: helpKeywords:
- variables - variables
- config - config
+1 -1
View File
@@ -2,7 +2,7 @@
title: Path Setup title: Path Setup
manTitle: 2. PATH SETUP manTitle: 2. PATH SETUP
sidebar: sidebar:
order: 2 order: 6
helpKeywords: helpKeywords:
- path - path
--- ---
+1 -1
View File
@@ -2,7 +2,7 @@
title: Key Bindings title: Key Bindings
manTitle: 3. KEY BINDINGS manTitle: 3. KEY BINDINGS
sidebar: sidebar:
order: 3 order: 7
helpKeywords: helpKeywords:
- keybindings - keybindings
- bindings - bindings
+1 -1
View File
@@ -2,7 +2,7 @@
title: Abbreviations title: Abbreviations
manTitle: 4. ABBREVIATIONS manTitle: 4. ABBREVIATIONS
sidebar: sidebar:
order: 4 order: 8
helpKeywords: helpKeywords:
- abbreviations - abbreviations
- abbr - abbr
+1 -1
View File
@@ -2,7 +2,7 @@
title: Functions Reference title: Functions Reference
manTitle: 5. FUNCTIONS REFERENCE manTitle: 5. FUNCTIONS REFERENCE
sidebar: sidebar:
order: 5 order: 9
helpKeywords: helpKeywords:
- functions - functions
--- ---
+1 -1
View File
@@ -2,7 +2,7 @@
title: Dependency Catalog title: Dependency Catalog
manTitle: 6. DEPENDENCY CATALOG manTitle: 6. DEPENDENCY CATALOG
sidebar: sidebar:
order: 6 order: 10
helpKeywords: helpKeywords:
- catalog - catalog
- deps-catalog - deps-catalog
+1 -1
View File
@@ -2,7 +2,7 @@
title: Customization title: Customization
manTitle: 7. CUSTOMIZATION manTitle: 7. CUSTOMIZATION
sidebar: sidebar:
order: 7 order: 11
helpKeywords: helpKeywords:
- customization - customization
- customize - customize
+1 -1
View File
@@ -2,7 +2,7 @@
title: Fisher Plugins title: Fisher Plugins
manTitle: 8. FISHER PLUGINS manTitle: 8. FISHER PLUGINS
sidebar: sidebar:
order: 8 order: 12
helpKeywords: helpKeywords:
- plugins - plugins
- fisher - fisher
+1 -1
View File
@@ -2,7 +2,7 @@
title: Installation title: Installation
manTitle: 9. INSTALLATION manTitle: 9. INSTALLATION
sidebar: sidebar:
order: 9 order: 13
helpKeywords: helpKeywords:
- installation - installation
- install - install
+1 -1
View File
@@ -2,7 +2,7 @@
title: Personalization title: Personalization
manTitle: 10. PERSONALIZATION manTitle: 10. PERSONALIZATION
sidebar: sidebar:
order: 10 order: 14
helpKeywords: helpKeywords:
- personalization - personalization
- personalize - personalize
+1 -1
View File
@@ -2,7 +2,7 @@
title: Viewing This Manual title: Viewing This Manual
manTitle: 11. VIEWING THIS MANUAL manTitle: 11. VIEWING THIS MANUAL
sidebar: sidebar:
order: 11 order: 15
helpKeywords: helpKeywords:
- viewing - viewing
- manual - manual
+7 -1
View File
@@ -3,7 +3,13 @@ title: Fish Shell Configuration
description: Reference manual for the rootiest fish configuration. description: Reference manual for the rootiest fish configuration.
manTitle: DESCRIPTION manTitle: DESCRIPTION
sidebar: sidebar:
order: 0 order: 3
pandoc:
title: FISH-CONFIG
section: 7
header: Fish Shell Configuration User Manual
date: June 2026
author: Rootiest
helpKeywords: helpKeywords:
- description - description
- autopair - autopair
+42 -7
View File
@@ -50,15 +50,34 @@ def load_keywords() -> dict[str, list[str]]:
return mapping 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]]: def split_h1(text: str) -> list[tuple[str, str]]:
parts = re.split(r"^# (.+)$", text, flags=re.MULTILINE) 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]]]: def split_h2(body: str) -> tuple[str, list[tuple[str, str]]]:
parts = re.split(r"^## (.+)$", body, flags=re.MULTILINE) parts = re.split(r"^## (.+)$", body, flags=re.MULTILINE)
intro = parts[0].strip() intro = _trim_blank_lines(parts[0])
subs = [(parts[i].strip(), parts[i + 1].strip()) for i in range(1, len(parts), 2)] subs = [
(parts[i].strip(), _trim_blank_lines(parts[i + 1])) for i in range(1, len(parts), 2)
]
return intro, subs return intro, subs
@@ -71,10 +90,23 @@ def main() -> int:
OUT.mkdir(parents=True) OUT.mkdir(parents=True)
keywords = load_keywords() 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 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: for heading, body in sections:
position += 1
kw = keywords.get(heading, []) kw = keywords.get(heading, [])
if heading in MAN_ONLY: if heading in MAN_ONLY:
path = OUT / f"00-{slugify(heading)}.md" path = OUT / f"00-{slugify(heading)}.md"
@@ -83,6 +115,7 @@ def main() -> int:
"manTitle": heading, "manTitle": heading,
"man": True, "man": True,
"site": False, "site": False,
"sidebar": {"order": position},
} }
path.write_text(mt.serialize(fm, body)) path.write_text(mt.serialize(fm, body))
continue continue
@@ -92,8 +125,10 @@ def main() -> int:
"title": "Fish Shell Configuration", "title": "Fish Shell Configuration",
"description": "Reference manual for the rootiest fish configuration.", "description": "Reference manual for the rootiest fish configuration.",
"manTitle": heading, "manTitle": heading,
"sidebar": {"order": 0}, "sidebar": {"order": position},
} }
if pandoc_meta:
fm["pandoc"] = pandoc_meta
if kw: if kw:
fm["helpKeywords"] = kw fm["helpKeywords"] = kw
(OUT / "index.md").write_text(mt.serialize(fm, body)) (OUT / "index.md").write_text(mt.serialize(fm, body))
@@ -108,7 +143,7 @@ def main() -> int:
fm = { fm = {
"title": display_title(heading), "title": display_title(heading),
"manTitle": heading, "manTitle": heading,
"sidebar": {"order": order}, "sidebar": {"order": position},
} }
if kw: if kw:
fm["helpKeywords"] = kw fm["helpKeywords"] = kw
@@ -133,7 +168,7 @@ def main() -> int:
fm = { fm = {
"title": display_title(heading), "title": display_title(heading),
"manTitle": heading, "manTitle": heading,
"sidebar": {"order": order}, "sidebar": {"order": position},
} }
if kw: if kw:
fm["helpKeywords"] = kw fm["helpKeywords"] = kw
+73
View File
@@ -3,12 +3,27 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""Verification checks for the docs/manual SSOT pipeline.""" """Verification checks for the docs/manual SSOT pipeline."""
import importlib.util
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
import manualtools as mt 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(): def test_parse_roundtrip():
fm = {"title": "Git", "sidebar": {"order": 4}, "helpKeywords": ["git", "gi"]} 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" 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_")] TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]