ci: auto-sync GitHub wiki module pages from man pages
Add tooling that makes the scdoc man pages the single source of truth and regenerates the wiki module pages from them: - .github/wiki/mapping.json: man-page -> wiki-page mapping (aggregation-aware) - .github/wiki/generate.py: scdoc -> pandoc -> gfm, strips man-only sections, concatenates aggregated pages, appends optional extras/<Page>.md - .github/wiki/extras/: hand-kept appendices (screenshots) with no man equivalent - .github/workflows/wiki.yml: on push to man/** (or the tooling), regenerate and push the wiki; other wiki pages are left untouched Covers all 65 man pages -> 45 wiki pages (5 new: GPS, Inhibitor, Mango, Menu, WWAN). Non-module and hand-written wiki pages are never modified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate GitHub wiki pages from the scdoc man pages.
|
||||
|
||||
The man pages under man/ are the single source of truth for module
|
||||
documentation. This script converts them to GitHub-flavoured Markdown
|
||||
(scdoc -> roff -> pandoc -> gfm) and writes one wiki page per entry in
|
||||
mapping.json, concatenating several man pages into one page where the
|
||||
wiki keeps an aggregated page (e.g. Module:-Hyprland).
|
||||
|
||||
Only the pages listed in mapping.json are (re)written; every other wiki
|
||||
page (Home, Installation, user showcases, ...) is left untouched.
|
||||
|
||||
Usage:
|
||||
generate.py --man-dir man --out-dir <wiki-checkout> [--check]
|
||||
|
||||
Requires: scdoc, pandoc.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Man-page sections that are meaningless on the wiki and must be dropped.
|
||||
DROP_SECTIONS = {"NAME", "FILES", "AUTHOR", "AUTHORS"}
|
||||
|
||||
HEADING_RE = re.compile(r"^(#+)\s+(.*)$")
|
||||
|
||||
|
||||
def sh(cmd, stdin=None):
|
||||
return subprocess.run(
|
||||
cmd, input=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
).stdout
|
||||
|
||||
|
||||
def man_to_gfm(scd_path):
|
||||
"""scdoc -> roff -> pandoc gfm, as raw markdown text."""
|
||||
roff = sh(["scdoc"], stdin=open(scd_path, "rb").read())
|
||||
gfm = sh(["pandoc", "-f", "man", "-t", "gfm", "--wrap=none"], stdin=roff)
|
||||
return gfm.decode("utf-8")
|
||||
|
||||
|
||||
def strip_sections(md):
|
||||
"""Remove top-level man-only sections (NAME/FILES/AUTHOR)."""
|
||||
out, drop = [], False
|
||||
for line in md.splitlines():
|
||||
m = HEADING_RE.match(line)
|
||||
if m and len(m.group(1)) == 1:
|
||||
drop = m.group(2).strip().upper() in DROP_SECTIONS
|
||||
if not drop:
|
||||
out.append(line)
|
||||
return "\n".join(out).strip("\n")
|
||||
|
||||
|
||||
def demote(md, levels=1):
|
||||
"""Add `levels` extra '#' to every heading (for aggregated pages)."""
|
||||
out = []
|
||||
for line in md.splitlines():
|
||||
m = HEADING_RE.match(line)
|
||||
out.append("#" * levels + line if m else line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def pretty(basename):
|
||||
"""waybar-sway-mode -> sway/mode (submodule heading for aggregated pages)."""
|
||||
return basename.removeprefix("waybar-").replace("-", "/", 1)
|
||||
|
||||
|
||||
def build_page(man_dir, sources, page, extras_dir):
|
||||
parts = []
|
||||
banner = ("<!-- AUTOGENERATED from " + ", ".join(f"man/{s}.5.scd" for s in sources)
|
||||
+ " by .github/wiki/generate.py — DO NOT EDIT HERE."
|
||||
" Edit the man page(s); the wiki syncs automatically. -->")
|
||||
parts.append(banner)
|
||||
aggregated = len(sources) > 1
|
||||
for src in sources:
|
||||
scd = os.path.join(man_dir, src + ".5.scd")
|
||||
body = strip_sections(man_to_gfm(scd))
|
||||
if aggregated:
|
||||
parts.append(f"\n# {pretty(src)}\n\n" + demote(body))
|
||||
else:
|
||||
parts.append("\n" + body)
|
||||
# Optional hand-maintained appendix (screenshots, showcase snippets) that
|
||||
# has no man-page equivalent: .github/wiki/extras/<Page>.md, appended verbatim.
|
||||
extra = os.path.join(extras_dir, page + ".md")
|
||||
if os.path.exists(extra):
|
||||
parts.append("\n" + open(extra).read().strip("\n"))
|
||||
return "\n".join(parts).rstrip() + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--man-dir", default="man")
|
||||
ap.add_argument("--out-dir")
|
||||
ap.add_argument("--mapping", default=os.path.join(os.path.dirname(__file__), "mapping.json"))
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="Only validate the mapping vs man/, write nothing.")
|
||||
args = ap.parse_args()
|
||||
|
||||
mapping = {k: v for k, v in json.load(open(args.mapping)).items()
|
||||
if not k.startswith("_")}
|
||||
|
||||
# Validate: every man page mapped exactly once; every source exists.
|
||||
mapped, errors = {}, []
|
||||
for page, sources in mapping.items():
|
||||
for s in sources:
|
||||
if not os.path.exists(os.path.join(args.man_dir, s + ".5.scd")):
|
||||
errors.append(f"{page}: man/{s}.5.scd does not exist")
|
||||
if s in mapped:
|
||||
errors.append(f"{s} mapped to both {mapped[s]} and {page}")
|
||||
mapped[s] = page
|
||||
on_disk = {f[:-6] for f in os.listdir(args.man_dir) if f.endswith(".5.scd")}
|
||||
for s in sorted(on_disk - set(mapped)):
|
||||
errors.append(f"man/{s}.5.scd is not referenced in mapping.json")
|
||||
if errors:
|
||||
print("Mapping errors:\n " + "\n ".join(errors), file=sys.stderr)
|
||||
return 1
|
||||
print(f"Mapping OK: {len(on_disk)} man pages -> {len(mapping)} wiki pages")
|
||||
if args.check:
|
||||
return 0
|
||||
if not args.out_dir:
|
||||
print("--out-dir is required unless --check", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
extras_dir = os.path.join(os.path.dirname(args.mapping), "extras")
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
for page, sources in mapping.items():
|
||||
out = os.path.join(args.out_dir, page + ".md")
|
||||
open(out, "w").write(build_page(args.man_dir, sources, page, extras_dir))
|
||||
print(f" wrote {page}.md ({', '.join(sources)})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user