#!/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())