Files
github-action/tools/build_wiki.py
T
Tirsvad 8d62ea0d56
Validate workflows / validate (pull_request) Successful in 13s
Generate the English wiki pages from README.md and guides/
Add tools/build_wiki.py, which builds the five English wiki pages into
wiki/, rewrites links so they work inside the wiki and reports pages that
differ from their sources. Offline tests cover the conversion and assert
that the committed pages are current.

Task: MIL-005#3
2026-10-10 08:53:30 +08:00

217 lines
7.6 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Build the English wiki pages in ``wiki/`` from ``README.md`` and ``guides/``.
The wiki source lives in this repository (MIL-005). The English pages are
generated, never edited by hand; ``--check`` reports a page that differs from
what its sources produce.
Usage:
python tools/build_wiki.py # write the pages
python tools/build_wiki.py --check # exit 1 when a page is out of date
"""
import argparse
import posixpath
import re
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
WIKI_DIR = REPO_ROOT / "wiki"
SOURCE_BASE_URL = "https://git.tirsystem.com/TirSystem/github-action/src/branch/main"
GENERATOR = "tools/build_wiki.py"
# Leading emoji and symbols in README headings; removed so anchors stay stable.
_HEADING_SYMBOLS = re.compile(r"^(#{1,6})[ \t]+[←-\U0001FAFF️‍]+[ \t]*")
_FENCE = re.compile(r"^[ \t]*(```|~~~)")
_LINK_TARGET = re.compile(r"\]\(([^)\s]+)\)")
_H2 = re.compile(r"^##[ \t]+(.*?)[ \t]*$")
@dataclass(frozen=True)
class Page:
"""One generated wiki page."""
name: str
source: str
sections: tuple[str, ...] | None = None # H2 titles to keep; None keeps the file
PAGES: tuple[Page, ...] = (
Page("Home", "README.md", ("Overview", "Requirements", "Setup", "Run", "Tests")),
Page("Onboarding", "guides/onboarding.md"),
Page("Credentials", "guides/credentials.md"),
Page("Troubleshooting", "guides/troubleshooting.md"),
Page("Capabilities", "README.md", ("Capabilities",)),
)
# The page a link to a source file should point to. The README is the home page.
_PAGE_FOR_SOURCE = {
"README.md": "Home",
"guides/onboarding.md": "Onboarding",
"guides/credentials.md": "Credentials",
"guides/troubleshooting.md": "Troubleshooting",
}
class WikiBuildError(RuntimeError):
"""A source file or section needed for a page is missing."""
def strip_heading_symbols(line: str) -> str:
"""Remove the leading emoji from a heading line."""
return _HEADING_SYMBOLS.sub(r"\1 ", line)
def rewrite_link(target: str, source: str) -> str:
"""Return the wiki form of a link target found in ``source``.
Links to another generated page become wiki page links. Links to other
repository files become absolute links to the file on ``main``, because the
wiki has no copy of them. External links are unchanged.
"""
if re.match(r"^[a-z][a-z0-9+.-]*:", target, re.IGNORECASE):
return target
path, hash_mark, fragment = target.partition("#")
if not path:
return "#" + fragment.lstrip("-")
resolved = posixpath.normpath(posixpath.join(posixpath.dirname(source), path))
suffix = f"#{fragment}" if hash_mark else ""
page = _PAGE_FOR_SOURCE.get(resolved)
if page is not None:
return page + suffix
return f"{SOURCE_BASE_URL}/{resolved}{suffix}"
def convert_markdown(text: str, source: str) -> str:
"""Strip heading emoji and rewrite links, leaving fenced code untouched."""
out: list[str] = []
in_fence = False
for line in text.splitlines():
if _FENCE.match(line):
in_fence = not in_fence
out.append(line)
continue
if in_fence:
out.append(line)
continue
line = strip_heading_symbols(line)
line = _LINK_TARGET.sub(
lambda match: f"]({rewrite_link(match.group(1), source)})", line
)
out.append(line)
return "\n".join(out)
def split_h2_sections(text: str) -> tuple[list[str], dict[str, list[str]]]:
"""Return the lines before the first H2 and the lines of each H2 section.
A section keeps its heading line and everything up to the next H2, so its
H3 and deeper headings stay with it. Fenced code is not scanned.
"""
preamble: list[str] = []
sections: dict[str, list[str]] = {}
current: list[str] = preamble
in_fence = False
for line in text.splitlines():
if _FENCE.match(line):
in_fence = not in_fence
match = None if in_fence else _H2.match(line)
if match:
title = _HEADING_SYMBOLS.sub("## ", line)[3:].strip()
current = sections.setdefault(title, [])
current.append(line)
return preamble, sections
def render_page(page: Page, repo_root: Path = REPO_ROOT) -> str:
"""Return the full Markdown of one wiki page."""
source_path = repo_root / page.source
try:
text = source_path.read_text(encoding="utf-8")
except FileNotFoundError:
raise WikiBuildError(f"{page.name}: source {page.source} is missing") from None
if page.sections is None:
body_lines = text.splitlines()
else:
preamble, sections = split_h2_sections(text)
body_lines = list(preamble) if page.name == "Home" else []
for title in page.sections:
if title not in sections:
raise WikiBuildError(
f"{page.name}: section '{title}' not found in {page.source}"
)
body_lines += [""] + sections[title]
banner = (
f"<!-- Generated from {page.source} by {GENERATOR}. "
"Edit the source, not this page. -->\n\n"
f"> This page is generated from [`{page.source}`]"
f"({SOURCE_BASE_URL}/{page.source}). Changes made here are overwritten "
"the next time the wiki is published.\n\n"
)
body = convert_markdown("\n".join(body_lines), page.source).strip("\n")
return banner + body + "\n"
def build_all(repo_root: Path = REPO_ROOT) -> dict[str, str]:
"""Return ``{file name: content}`` for every English page."""
return {f"{page.name}.md": render_page(page, repo_root) for page in PAGES}
def write_pages(wiki_dir: Path = WIKI_DIR, repo_root: Path = REPO_ROOT) -> list[str]:
"""Write every page; return the names of the files that changed."""
changed: list[str] = []
wiki_dir.mkdir(parents=True, exist_ok=True)
for name, content in build_all(repo_root).items():
path = wiki_dir / name
current = path.read_bytes() if path.exists() else None
if current != content.encode("utf-8"):
path.write_bytes(content.encode("utf-8"))
changed.append(name)
return changed
def check_pages(wiki_dir: Path = WIKI_DIR, repo_root: Path = REPO_ROOT) -> list[str]:
"""Return a message for every page that is missing or out of date."""
problems: list[str] = []
for name, content in build_all(repo_root).items():
path = wiki_dir / name
if not path.exists():
problems.append(f"{name}: missing")
elif path.read_bytes() != content.encode("utf-8"):
problems.append(f"{name}: differs from its source")
return problems
def main(argv: list[str] | None = None) -> int:
"""Run the generator; return the process exit code."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--check",
action="store_true",
help="report pages that differ from their sources and exit 1",
)
args = parser.parse_args(argv)
try:
if args.check:
problems = check_pages()
for problem in problems:
print(f"ERROR: wiki/{problem}", file=sys.stderr)
if problems:
print(f"Run `python {GENERATOR}` and commit wiki/.", file=sys.stderr)
return 1
print("Wiki pages are up to date.")
return 0
changed = write_pages()
except WikiBuildError as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print(f"Wrote {len(changed)} page(s): {', '.join(changed) or 'none changed'}")
return 0
if __name__ == "__main__":
sys.exit(main())