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:
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Configuration Variables
|
||||
manTitle: 1. CONFIGURATION VARIABLES
|
||||
sidebar:
|
||||
order: 1
|
||||
order: 5
|
||||
helpKeywords:
|
||||
- variables
|
||||
- config
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Path Setup
|
||||
manTitle: 2. PATH SETUP
|
||||
sidebar:
|
||||
order: 2
|
||||
order: 6
|
||||
helpKeywords:
|
||||
- path
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Key Bindings
|
||||
manTitle: 3. KEY BINDINGS
|
||||
sidebar:
|
||||
order: 3
|
||||
order: 7
|
||||
helpKeywords:
|
||||
- keybindings
|
||||
- bindings
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Abbreviations
|
||||
manTitle: 4. ABBREVIATIONS
|
||||
sidebar:
|
||||
order: 4
|
||||
order: 8
|
||||
helpKeywords:
|
||||
- abbreviations
|
||||
- abbr
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Functions Reference
|
||||
manTitle: 5. FUNCTIONS REFERENCE
|
||||
sidebar:
|
||||
order: 5
|
||||
order: 9
|
||||
helpKeywords:
|
||||
- functions
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Dependency Catalog
|
||||
manTitle: 6. DEPENDENCY CATALOG
|
||||
sidebar:
|
||||
order: 6
|
||||
order: 10
|
||||
helpKeywords:
|
||||
- catalog
|
||||
- deps-catalog
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Customization
|
||||
manTitle: 7. CUSTOMIZATION
|
||||
sidebar:
|
||||
order: 7
|
||||
order: 11
|
||||
helpKeywords:
|
||||
- customization
|
||||
- customize
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Fisher Plugins
|
||||
manTitle: 8. FISHER PLUGINS
|
||||
sidebar:
|
||||
order: 8
|
||||
order: 12
|
||||
helpKeywords:
|
||||
- plugins
|
||||
- fisher
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Installation
|
||||
manTitle: 9. INSTALLATION
|
||||
sidebar:
|
||||
order: 9
|
||||
order: 13
|
||||
helpKeywords:
|
||||
- installation
|
||||
- install
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Personalization
|
||||
manTitle: 10. PERSONALIZATION
|
||||
sidebar:
|
||||
order: 10
|
||||
order: 14
|
||||
helpKeywords:
|
||||
- personalization
|
||||
- personalize
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Viewing This Manual
|
||||
manTitle: 11. VIEWING THIS MANUAL
|
||||
sidebar:
|
||||
order: 11
|
||||
order: 15
|
||||
helpKeywords:
|
||||
- viewing
|
||||
- manual
|
||||
|
||||
@@ -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
|
||||
|
||||
+42
-7
@@ -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
|
||||
|
||||
@@ -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_")]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user