feat(wiki): generate the course textbook into the wiki from modules/ via CI
- tools/build_wiki.py: host-agnostic generator — renders modules/** + capstone into wiki pages with a unit-by-unit _Sidebar, a Home TOC, and a _Footer; rewrites lab/ and repo-root links to absolute repo URLs (labs stay in the repo, linked not copied). Single source of truth stays in modules/. - .gitea/workflows/sync-wiki.yml + .github/workflows/sync-wiki.yml: sync the wiki on every push to main touching modules/**, capstone/**, README, or the generator. Documented prerequisites: an Actions runner + a WIKI_TOKEN secret (+ GitHub wiki must be initialized once). - tools/README.md: how to run the sync manually and via CI. - README: "Read it as a book" pointer to the wiki. The Gitea wiki has already been populated with the initial render. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfzV5QvtPDz8LJS3Pu5VLT
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the course into wiki pages from the single source of truth (modules/).
|
||||
|
||||
Host-agnostic: this writes Markdown into a target wiki working directory. A thin
|
||||
per-host CI wrapper clones the host's `<repo>.wiki.git`, runs this script, then
|
||||
commits and pushes. The same script feeds both the Gitea and GitHub wikis — only
|
||||
the `--host` URL flavor and the CI wrapper differ.
|
||||
|
||||
The wiki is GENERATED BUILD OUTPUT. Never hand-edit it; edit modules/ and let CI
|
||||
re-render. Labs are NOT copied into the wiki — lesson pages link to the runnable
|
||||
files back in the main repo.
|
||||
|
||||
Usage:
|
||||
build_wiki.py --repo-root . --out <wiki-dir> \
|
||||
--web-base https://git.jpaul.io/justin/ai-workflow-course \
|
||||
--branch main --host gitea
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Unit structure (module numbers), from the syllabus. Drives the textbook nav.
|
||||
UNITS = [
|
||||
("Unit 1 — Get out of the chat window", range(1, 8)),
|
||||
("Unit 2 — Make it shareable, reviewable, recoverable", range(8, 13)),
|
||||
("Unit 3 — Automate the checking and shipping", range(13, 20)),
|
||||
("Unit 4 — Extend the AI into your systems", range(20, 24)),
|
||||
("Unit 5 — AI in the Loop", range(24, 28)),
|
||||
]
|
||||
|
||||
|
||||
def src_url(web_base: str, branch: str, host: str, path: str) -> str:
|
||||
"""Browser URL to a file in the main repo (host-specific path scheme)."""
|
||||
path = path.rstrip("/")
|
||||
if host == "github":
|
||||
return f"{web_base}/blob/{branch}/{path}"
|
||||
return f"{web_base}/src/branch/{branch}/{path}" # gitea / forgejo
|
||||
|
||||
|
||||
def first_h1(text: str, fallback: str) -> str:
|
||||
for line in text.splitlines():
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def short_title(h1: str) -> str:
|
||||
"""'Module 1 — The Copy-Paste Problem' -> 'The Copy-Paste Problem'."""
|
||||
m = re.match(r"^Module\s+\d+\s*[—:-]\s*(.+)$", h1)
|
||||
return m.group(1).strip() if m else h1
|
||||
|
||||
|
||||
def rewrite_lab_links(body: str, web_base: str, branch: str, host: str, slug: str) -> str:
|
||||
"""Make ](lab/...) point at the runnable file in the main repo, not the wiki."""
|
||||
def repl(m: re.Match) -> str:
|
||||
rel = m.group(1) # e.g. 'lab/Dockerfile' or 'lab/'
|
||||
return f"]({src_url(web_base, branch, host, f'modules/{slug}/{rel}')})"
|
||||
return re.sub(r"\]\((lab/[^)]*)\)", repl, body)
|
||||
|
||||
|
||||
# Repo root files that may be linked from prose; in the wiki these must point back
|
||||
# at the main repo, not at a (nonexistent) wiki page of the same name.
|
||||
ROOT_FILES = ("AGENTS.md", "README.md", "the-workflow-syllabus.md", "handoff.md", "_TEMPLATE.md")
|
||||
|
||||
|
||||
def rewrite_repo_file_links(body: str, web_base: str, branch: str, host: str) -> str:
|
||||
pattern = r"\]\((" + "|".join(re.escape(f) for f in ROOT_FILES) + r")\)"
|
||||
return re.sub(
|
||||
pattern,
|
||||
lambda m: f"]({src_url(web_base, branch, host, m.group(1))})",
|
||||
body,
|
||||
)
|
||||
|
||||
|
||||
def banner(source_path: str, source_url: str) -> str:
|
||||
return (
|
||||
f"> 📖 _This page is generated from [`{source_path}`]({source_url}). "
|
||||
f"**Edit the source, not the wiki** — edits here are overwritten on the next sync. "
|
||||
f"Run the hands-on labs from the repo, linked inline._\n"
|
||||
)
|
||||
|
||||
|
||||
def discover_modules(repo_root: Path):
|
||||
"""Return [(number:int, slug:str, path:Path)] sorted by number."""
|
||||
mods = []
|
||||
for d in sorted((repo_root / "modules").iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
m = re.match(r"^(\d+)-", d.name)
|
||||
if m and (d / "README.md").exists():
|
||||
mods.append((int(m.group(1)), d.name, d))
|
||||
return sorted(mods, key=lambda t: t[0])
|
||||
|
||||
|
||||
def home_intro(repo_root: Path) -> str:
|
||||
"""Pull title/tagline/thesis from the repo README (up to its first '## ')."""
|
||||
readme = (repo_root / "README.md").read_text(encoding="utf-8")
|
||||
out = []
|
||||
for line in readme.splitlines():
|
||||
if line.startswith("## "):
|
||||
break
|
||||
out.append(line)
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--repo-root", default=".")
|
||||
ap.add_argument("--out", required=True, help="wiki working directory to write into")
|
||||
ap.add_argument("--web-base", required=True, help="main repo web base URL")
|
||||
ap.add_argument("--branch", default="main")
|
||||
ap.add_argument("--host", choices=["gitea", "github"], default="gitea")
|
||||
args = ap.parse_args()
|
||||
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
out = Path(args.out).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mods = discover_modules(repo_root)
|
||||
if not mods:
|
||||
print("no modules found", flush=True)
|
||||
return 1
|
||||
|
||||
titles: dict[int, str] = {}
|
||||
slugs: dict[int, str] = {}
|
||||
|
||||
# --- per-module pages -------------------------------------------------
|
||||
for num, slug, path in mods:
|
||||
body = (path / "README.md").read_text(encoding="utf-8")
|
||||
h1 = first_h1(body, f"Module {num}")
|
||||
titles[num] = h1
|
||||
slugs[num] = slug
|
||||
source_path = f"modules/{slug}/README.md"
|
||||
body = rewrite_lab_links(body, args.web_base, args.branch, args.host, slug)
|
||||
body = rewrite_repo_file_links(body, args.web_base, args.branch, args.host)
|
||||
page = (
|
||||
banner(source_path, src_url(args.web_base, args.branch, args.host, source_path))
|
||||
+ "\n"
|
||||
+ body
|
||||
+ "\n"
|
||||
)
|
||||
(out / f"{slug}.md").write_text(page, encoding="utf-8")
|
||||
|
||||
# --- capstone ---------------------------------------------------------
|
||||
cap = repo_root / "capstone" / "README.md"
|
||||
cap_title = None
|
||||
if cap.exists():
|
||||
body = cap.read_text(encoding="utf-8")
|
||||
cap_title = first_h1(body, "Capstone — The Full Loop")
|
||||
source_path = "capstone/README.md"
|
||||
# capstone labs may reference modules/.. ; only lab/ links are rewritten elsewhere,
|
||||
# capstone has none, so copy through with a banner.
|
||||
body = rewrite_repo_file_links(body, args.web_base, args.branch, args.host)
|
||||
page = (
|
||||
banner(source_path, src_url(args.web_base, args.branch, args.host, source_path))
|
||||
+ "\n"
|
||||
+ body
|
||||
+ "\n"
|
||||
)
|
||||
(out / "capstone.md").write_text(page, encoding="utf-8")
|
||||
|
||||
# --- _Sidebar.md (textbook nav) --------------------------------------
|
||||
sb = ["### [📖 Home](Home)\n"]
|
||||
for unit_title, rng in UNITS:
|
||||
present = [n for n in rng if n in slugs]
|
||||
if not present:
|
||||
continue
|
||||
sb.append(f"**{unit_title}**\n")
|
||||
for n in present:
|
||||
sb.append(f"- [{n} · {short_title(titles[n])}]({slugs[n]})")
|
||||
sb.append("")
|
||||
if cap_title:
|
||||
sb.append("**Finale**\n")
|
||||
sb.append(f"- [{short_title(cap_title)}](capstone)")
|
||||
sb.append("")
|
||||
(out / "_Sidebar.md").write_text("\n".join(sb) + "\n", encoding="utf-8")
|
||||
|
||||
# --- Home.md (landing + TOC) -----------------------------------------
|
||||
intro = rewrite_repo_file_links(home_intro(repo_root), args.web_base, args.branch, args.host)
|
||||
home = [intro, "\n## Contents\n"]
|
||||
for unit_title, rng in UNITS:
|
||||
present = [n for n in rng if n in slugs]
|
||||
if not present:
|
||||
continue
|
||||
home.append(f"### {unit_title}\n")
|
||||
for n in present:
|
||||
home.append(f"- **[{titles[n]}]({slugs[n]})**")
|
||||
home.append("")
|
||||
if cap_title:
|
||||
home.append("### Finale\n")
|
||||
home.append(f"- **[{cap_title}](capstone)**")
|
||||
home.append("")
|
||||
home.append(
|
||||
"\n---\n> 📖 _This wiki is generated from the "
|
||||
f"[course repo]({args.web_base}) — edit `modules/` there, not these pages._"
|
||||
)
|
||||
(out / "Home.md").write_text("\n".join(home) + "\n", encoding="utf-8")
|
||||
|
||||
# --- _Footer.md -------------------------------------------------------
|
||||
(out / "_Footer.md").write_text(
|
||||
f"_Generated from the [ai-workflow-course repo]({args.web_base}) • "
|
||||
"the model is the cheap, swappable part; the workflow is the durable skill._\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
pages = len(mods) + (1 if cap_title else 0) + 3
|
||||
print(f"wrote {pages} wiki files to {out} ({len(mods)} modules"
|
||||
f"{' + capstone' if cap_title else ''} + Home/_Sidebar/_Footer)", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user