Generate the English wiki pages from README.md and guides/
Validate workflows / validate (pull_request) Successful in 13s
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:
@@ -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())
|
||||
Reference in New Issue
Block a user