Files
Waybar/.github/wiki/generate.py
T
AlexandClaude Opus 4.8 4e76d7339f ci(wiki): auto-insert new module pages into the sidebar
generate.py now keeps _Sidebar.md in sync: any Module:-* page from the
mapping that is not yet linked is inserted alphabetically into the
Modules list. Existing entries (custom labels, nested sub-entries,
hand-written non-module links) are left untouched, so adding a module no
longer requires editing the sidebar by hand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:07:53 +02:00

187 lines
6.7 KiB
Python
Executable File

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