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
|
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,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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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_")]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user