Generate the English wiki pages from README.md and guides/
Validate workflows / validate (pull_request) Successful in 13s

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
This commit is contained in:
2026-10-10 08:53:30 +08:00
parent bef58e75c7
commit 8d62ea0d56
7 changed files with 746 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
"""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())