beb89e406a
index.md is both the man-page LANDING section and a Starlight content page. Astro errors on any frontmatter key outside the fixed man/site/manTitle/helpKeywords schema, so folding the source fish-config.md pandoc header (title/section/header/date/author) onto index.md under a fifth "pandoc" key would break the docs-site build the moment it's scaffolded. Relocate that block to docs/manual/_pandoc.yml: a plain, fence-free YAML file that Astro content collections ignore (leading underscore). split-manual.py now extracts it as raw text (no yaml.safe_load/ safe_dump round-trip) so build-manual.py's --concat re-emits it byte-for-byte instead of merely "verbatim" in comment only. Also guard build_concat against a manual/ tree with no _pandoc.yml/index.md, removing the unconditional index.md parse that previously raised FileNotFoundError on such a tree. Regenerated docs/manual/ via split-manual.py; concat output remains byte-identical to the pre-migration docs/fish-config.md.
208 lines
7.2 KiB
Python
208 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright (C) 2026 Rootiest
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""One-shot migration: docs/fish-config.md -> docs/manual/**.
|
|
|
|
Run once, verify with verify-manual.py, then delete this script.
|
|
"""
|
|
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import manualtools as mt
|
|
|
|
DOCS = Path(__file__).parent
|
|
SRC = DOCS / "fish-config.md"
|
|
OUT = DOCS / "manual"
|
|
INDEX = DOCS / "fish-config.index"
|
|
|
|
MAN_ONLY = {"NAME", "SYNOPSIS", "TABLE OF CONTENTS"}
|
|
LANDING = "DESCRIPTION"
|
|
FUNCTIONS_TITLE_RE = re.compile(r"^\d+\.\s+FUNCTIONS REFERENCE$", re.I)
|
|
NUM_PREFIX_RE = re.compile(r"^\d+(\.\d+)*\.?\s+")
|
|
|
|
|
|
def slugify(title: str) -> str:
|
|
s = NUM_PREFIX_RE.sub("", title).lower()
|
|
s = re.sub(r"[^\w\s-]", "", s)
|
|
return re.sub(r"[\s_]+", "-", s).strip("-")
|
|
|
|
|
|
def display_title(heading: str) -> str:
|
|
"""Strip leading numbering and normalise SHOUTING to Title Case."""
|
|
t = NUM_PREFIX_RE.sub("", heading).strip()
|
|
return t.title() if t.isupper() else t
|
|
|
|
|
|
def load_keywords() -> dict[str, list[str]]:
|
|
"""Reverse fish-config.index into {heading text: [keywords]}."""
|
|
mapping: dict[str, list[str]] = {}
|
|
if not INDEX.exists():
|
|
return mapping
|
|
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)
|
|
mapping.setdefault(heading.strip().lstrip("# ").strip(), []).append(keyword.strip())
|
|
return mapping
|
|
|
|
|
|
def extract_raw_frontmatter(path: Path) -> str | None:
|
|
"""Return the raw text between a file's opening `---` fences, or None.
|
|
|
|
Unlike `mt.parse`, this does not round-trip the block through
|
|
`yaml.safe_load`/`yaml.safe_dump` — it hands back the exact original
|
|
bytes (minus the fence lines themselves) so a later verbatim re-emit
|
|
doesn't have to worry about quoting-style or scalar-coercion drift.
|
|
"""
|
|
text = path.read_text()
|
|
if not text.startswith("---\n"):
|
|
return None
|
|
end = text.find("\n---\n", 4)
|
|
if end == -1:
|
|
return None
|
|
return text[4:end]
|
|
|
|
|
|
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(), _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 = _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
|
|
|
|
|
|
def main() -> int:
|
|
if not SRC.exists():
|
|
print(f"error: {SRC} not found", file=sys.stderr)
|
|
return 1
|
|
if OUT.exists():
|
|
shutil.rmtree(OUT)
|
|
OUT.mkdir(parents=True)
|
|
|
|
keywords = load_keywords()
|
|
# SRC starts with a pandoc metadata block (title/section/header/date/
|
|
# author) consumed by the man-page build. It has no purpose as an Astro
|
|
# page's frontmatter (index.md is also a website content page, and
|
|
# Starlight errors on any custom frontmatter key outside the fixed
|
|
# man/site/manTitle/helpKeywords schema), so it is extracted as raw text
|
|
# and written to docs/manual/_pandoc.yml instead of being folded into
|
|
# index.md's frontmatter. The leading underscore keeps Astro content
|
|
# collections from treating it as a page. build-manual.py --concat
|
|
# reads it back and re-emits it byte-for-byte.
|
|
pandoc_raw = extract_raw_frontmatter(SRC)
|
|
_, 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"
|
|
fm = {
|
|
"title": display_title(heading),
|
|
"manTitle": heading,
|
|
"man": True,
|
|
"site": False,
|
|
"sidebar": {"order": position},
|
|
}
|
|
path.write_text(mt.serialize(fm, body))
|
|
continue
|
|
|
|
if heading == LANDING:
|
|
fm = {
|
|
"title": "Fish Shell Configuration",
|
|
"description": "Reference manual for the rootiest fish configuration.",
|
|
"manTitle": heading,
|
|
"sidebar": {"order": position},
|
|
}
|
|
if kw:
|
|
fm["helpKeywords"] = kw
|
|
(OUT / "index.md").write_text(mt.serialize(fm, body))
|
|
if pandoc_raw is not None:
|
|
(OUT / "_pandoc.yml").write_text(pandoc_raw.rstrip("\n") + "\n")
|
|
continue
|
|
|
|
order += 1
|
|
if FUNCTIONS_TITLE_RE.match(heading):
|
|
# Section 5 explodes into a directory of category files.
|
|
d = OUT / f"{order:02d}-functions"
|
|
d.mkdir()
|
|
intro, subs = split_h2(body)
|
|
fm = {
|
|
"title": display_title(heading),
|
|
"manTitle": heading,
|
|
"sidebar": {"order": position},
|
|
}
|
|
if kw:
|
|
fm["helpKeywords"] = kw
|
|
(d / "index.md").write_text(mt.serialize(fm, intro))
|
|
|
|
for i, (sub_heading, sub_body) in enumerate(subs, start=1):
|
|
sub_fm = {
|
|
"title": display_title(sub_heading),
|
|
"manTitle": sub_heading,
|
|
"sidebar": {"order": i},
|
|
}
|
|
sub_kw = keywords.get(sub_heading, [])
|
|
if sub_kw:
|
|
sub_fm["helpKeywords"] = sub_kw
|
|
# H3 function entries -> H2 so --site can split on them.
|
|
promoted = mt.shift_headings(sub_body, -1)
|
|
(d / f"{i:02d}-{slugify(sub_heading)}.md").write_text(
|
|
mt.serialize(sub_fm, promoted)
|
|
)
|
|
continue
|
|
|
|
fm = {
|
|
"title": display_title(heading),
|
|
"manTitle": heading,
|
|
"sidebar": {"order": position},
|
|
}
|
|
if kw:
|
|
fm["helpKeywords"] = kw
|
|
(OUT / f"{order:02d}-{slugify(heading)}.md").write_text(mt.serialize(fm, body))
|
|
|
|
count = sum(1 for _ in OUT.rglob("*.md"))
|
|
print(f"wrote {count} files to {OUT}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
raise SystemExit(main())
|