#!/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 [--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 = ("") 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/.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" ENTRY_RE = re.compile(r"^ - \[([^\]]+)\]\(\./Module:-") LINK_RE = re.compile(r"\]\(\./(Module:-[^)]+)\)") def sync_sidebar(out_dir, mapping): """Non-destructively add any mapped Module page missing from _Sidebar.md. Only inserts links for pages absent from the sidebar; existing entries (custom labels, nested sub-entries, hand-written non-module links) are never modified. Insertion is alphabetical within the top-level module list. """ path = os.path.join(out_dir, "_Sidebar.md") if not os.path.exists(path): print(" no _Sidebar.md; skipping sidebar sync") return lines = open(path).read().splitlines() linked = set(LINK_RE.findall("\n".join(lines))) missing = sorted((p for p in mapping if p.startswith("Module:-") and p not in linked), key=str.lower) if not missing: print(" sidebar up to date") return def entries(): return [i for i, l in enumerate(lines) if ENTRY_RE.match(l)] if not entries(): print(" could not locate Modules section; skipping sidebar sync") return for page in missing: label = page[len("Module:-"):].replace("-", " ") new_line = f" - [{label}](./{page})" pos = None for i in entries(): if ENTRY_RE.match(lines[i]).group(1).lower() > label.lower(): pos = i break if pos is None: # after the last module entry and its sub-entries j = entries()[-1] + 1 while j < len(lines) and lines[j].startswith(" "): j += 1 pos = j lines.insert(pos, new_line) print(f" sidebar += {label}") open(path, "w").write("\n".join(lines) + "\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)})") sync_sidebar(args.out_dir, mapping) return 0 if __name__ == "__main__": sys.exit(main())