diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 2cb5036a..41dbc777 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -3,6 +3,10 @@ on: push: branches: - main + # Pull requests build the site without deploying it. The build fails when a published + # route has no page and no redirect, or when a new route is missing from + # website/published-routes.txt (see website/README.md). + pull_request: jobs: build-site: @@ -39,6 +43,7 @@ jobs: deploy-site: needs: build-site + if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: pages: write id-token: write diff --git a/.gitignore b/.gitignore index 9fb02e15..48745969 100644 --- a/.gitignore +++ b/.gitignore @@ -181,8 +181,8 @@ website/node_modules/ # Notebook -> Markdown conversion output, generated by scripts/docs/convert_notebooks.py docs/notebooks/ -# Training pages for the site, generated by scripts/docs/build_training_pages.py -website/static/training/ +# Training section of the site, generated by scripts/docs/build_training_site.py +website/generated/ # schema cache written by cookbook check_metadata.py website/static/cookbook-files/**/wb-schemas/ diff --git a/scripts/docs/build_training_pages.py b/scripts/docs/build_training_pages.py index 6c31b1a8..1e6f2f53 100644 --- a/scripts/docs/build_training_pages.py +++ b/scripts/docs/build_training_pages.py @@ -1,14 +1,12 @@ #!/usr/bin/env python3 -"""Build the standalone HTML pages of the training series for the docs site. +"""Document collection data and pages for the training series. -Reads the worked-example documents of session 01 and writes a page that shows each -document with its metadata, its role in the session, and the chunks that notebook 02 -makes of it, each labelled with the citation the application writes when it uses the -chunk. The site build runs this script before Docusaurus, and the output lands under -website/static so that it is served as is. +Reads the worked-example documents of a session with their roles (assets/documents/roles.json) +and splits each document into the chunks that notebook 02 makes of it, each labelled with the +citation the application writes when it uses the chunk. build_training_site.py calls +viewer_data() to build the Document collection page of the Training section. - python3 scripts/docs/build_training_pages.py # website/static/training/session-01/documents.html - python3 scripts/docs/build_training_pages.py --artifact OUT # body-only page with embedded logos, for a Claude artifact + python3 scripts/docs/build_training_pages.py --artifact OUT # standalone page with embedded logos, for a Claude artifact No dependencies beyond the standard library: the documents use headings, paragraphs and bullet lists only, and the chunking mirrors workshop.split_sections (one chunk per @@ -26,34 +24,14 @@ REPO = Path(__file__).resolve().parents[2] SESSION = REPO / "training" / "session-01-building-ai-applications" DOCS = SESSION / "assets" / "documents" -SITE_OUT = REPO / "website" / "static" / "training" / "session-01" / "documents.html" LOGO = REPO / "website" / "static" / "img" / "program-logo.png" LOGO_DARK = REPO / "website" / "static" / "img" / "program-logo-dark.png" -ROLES = [ - ("evidence", "Relevant evidence", - "The four country briefs. Their figures are rounded from the indicator data, and a correct answer to the main question cites them.", - ["kenya-digital", "rwanda-digital", "kenya-economy", "rwanda-economy"]), - ("definition", "Definition", - "A note on the definition of the internet-users indicator and on the comparability of its values across years.", - ["internet-indicator"]), - ("outdated", "Outdated", - "A 2016 summary with figures for 2015 and expectations that later data did not confirm. It outranks the current briefs for a question about the latest figures, and its date in the metadata removes it.", - ["regional-2016"]), - ("distractor", "Distractors", - "Two notes on related topics without the requested indicators. The coffee note is the retrieval failure case; the mobile-money note is a plausible wrong passage.", - ["coffee", "mobile-money"]), -] -NOTE = { - "kenya-digital": "Relevant evidence for internet use and mobile subscriptions in Kenya.", - "rwanda-digital": "Relevant evidence for internet use and mobile subscriptions in Rwanda.", - "kenya-economy": "Relevant evidence for GDP per capita and growth in Kenya.", - "rwanda-economy": "Relevant evidence for GDP per capita and growth in Rwanda.", - "internet-indicator": "Definition of the indicator; the source for the question on its metadata.", - "regional-2016": "Outdated figures; shows why dates in metadata matter.", - "coffee": "Irrelevant to the question; shows a retrieval failure.", - "mobile-money": "Related topic without the requested indicators; a plausible distractor.", -} +# The role of each document in the session, and the text of the Document collection page. +ROLES_FILE = DOCS / "roles.json" +_roles = json.loads(ROLES_FILE.read_text(encoding="utf-8")) +ROLES = [(g["key"], g["label"], g["description"], [d["id"] for d in g["documents"]]) for g in _roles["groups"]] +NOTE = {d["id"]: d["note"] for g in _roles["groups"] for d in g["documents"]} META_KEYS = ["id", "type", "country", "date", "source", "note"] @@ -263,23 +241,37 @@ def build(docs: list[dict], logo_light: str, logo_dark: str, site_links: bool) - """ +def viewer_data(docs_dir: Path) -> dict: + """A session's document collection as data, for the Document collection page of the Training section. + + `docs_dir` holds the documents (*.md) and roles.json, which groups them by their role in the session.""" + roles = json.loads((docs_dir / "roles.json").read_text(encoding="utf-8")) + by = {d["metadata"]["id"]: d for d in (read_document(p) for p in sorted(docs_dir.glob("*.md")))} + groups = [] + for g in roles["groups"]: + docs = [] + for entry in g["documents"]: + doc = by[entry["id"]] + m = doc["metadata"] + docs.append({ + "id": entry["id"], "title": m.get("title", entry["id"]), "year": str(m.get("date", ""))[:4], + "note": entry.get("note", ""), + "metadata": [[k, m[k]] for k in META_KEYS if k in m], + "chunks": [{"citation": c["citation"], "characters": len(c["text"]), "html": render_markdown(c["text"])} + for c in split_sections(doc)], + }) + groups.append({"key": g["key"], "label": g["label"], "description": g.get("description", ""), "documents": docs}) + return {"groups": groups} + + def main() -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) - ap.add_argument("--artifact", type=Path, help="write a body-only page with embedded logos to this path instead of the site page") + ap.add_argument("--artifact", type=Path, required=True, help="write a standalone page with embedded logos to this path") args = ap.parse_args() docs = [read_document(p) for p in sorted(DOCS.glob("*.md"))] - if args.artifact: - page = build(docs, data_uri(LOGO), data_uri(LOGO_DARK), site_links=True) - args.artifact.write_text(page, encoding="utf-8") - print(f"wrote {args.artifact} ({len(page):,} bytes)") - return 0 - page = build(docs, "/ai4data/img/program-logo.png", "/ai4data/img/program-logo-dark.png", site_links=True) - full = ('\n\n
\n\n\n' - '\n' - '\n' + page.split("\n", 1)[0] + "\n\n\n" + page.split("\n", 1)[1] + "\n\n") - SITE_OUT.parent.mkdir(parents=True, exist_ok=True) - SITE_OUT.write_text(full, encoding="utf-8") - print(f"wrote {SITE_OUT.relative_to(REPO)} ({len(full):,} bytes)") + page = build(docs, data_uri(LOGO), data_uri(LOGO_DARK), site_links=True) + args.artifact.write_text(page, encoding="utf-8") + print(f"wrote {args.artifact} ({len(page):,} bytes)") return 0 diff --git a/scripts/docs/build_training_site.py b/scripts/docs/build_training_site.py new file mode 100644 index 00000000..2f9168f0 --- /dev/null +++ b/scripts/docs/build_training_site.py @@ -0,0 +1,551 @@ +#!/usr/bin/env python3 +"""Generate the Training section of the docs site from the training/ folder. + +The training folder stays the source: its READMEs, participant resources, and +notebooks are what GitHub shows and what reviewers edit. This script writes a +Markdown copy of them into website/generated/training/ (gitignored), which the +`training` docs instance of the site reads. The site build runs it before +Docusaurus, through `npm run convert-notebooks`. + + python3 scripts/docs/build_training_site.py # warnings are printed + python3 scripts/docs/build_training_site.py --strict # warnings fail the run + +The conventions a session folder follows are described in training/AUTHORING.md, and +check_session() reports the ones the build depends on. + +What the script does to the source: + +- The series README becomes the section's index page, and each session README + becomes the session's overview page. Sections listed in OMIT_SECTIONS are + left off the site. +- Each notebook becomes a page with its committed outputs, an "Open in Colab" + button, and a link to the notebook on GitHub. Colab section links + (`#scrollTo=section-N`) become heading anchors, and Colab links between the + session's notebooks become links between the pages. +- Relative links to files that are on the site are rewritten to the generated + pages. Relative links to anything else (data files, scripts, folders) point + to the file on GitHub. Absolute links to the published site become site- + internal links. +- Notebook HTML outputs keep their markup. Inline scripts are removed; the + site's pcnDetail client module provides the behaviour they had in Jupyter. + +No dependencies beyond the standard library. +""" +from __future__ import annotations + +import base64 +import hashlib +import html +import json +import re +import shutil +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +SRC = REPO / "training" +OUT = REPO / "website" / "generated" / "training" +GITHUB = "https://github.com/worldbank/ai4data" +BLOB = f"{GITHUB}/blob/main" +TREE = f"{GITHUB}/tree/main" +EDIT = f"{GITHUB}/edit/main" +COLAB = "https://colab.research.google.com/github/worldbank/ai4data/blob/main" +SITE = "https://worldbank.github.io/ai4data" + +# Sections of a README that concern the team preparing a session and stay on GitHub. +OMIT_SECTIONS = { + "training/README.md": ["Layout of a session folder", "Review", "Adding a session"], + "session": ["Roles", "Definition of ready"], +} + +# Fixed pages of a session: (source, output, sidebar label, sidebar position). Positions +# are within the session: the overview is 1, the preface 1, the slides 1.5, the notebooks 2, +# the participant resources 3, the document collection 3.5, and the worked-example data 4. +SESSION_PAGES = [ + ("preface.md", "preface.md", "Preface", 1), + ("assets/README.md", "worked-example-data.md", "Worked-example data", 4), +] + +WARNINGS: list[str] = [] + + +def warn(path: Path, message: str) -> None: + WARNINGS.append(f"{path.relative_to(REPO).as_posix()}: {message}") + + +def resource_pages(folder: Path) -> list[tuple[Path, str, str | None, int]]: + """Every Markdown file in resources/, as (source, output, sidebar label, position). + + The README is the category's first page, under the label "Participant guide". The other + files follow in the order in which the README first links to them, and files that it + does not link to follow in alphabetical order. A file's name is its page's URL.""" + res = folder / "resources" + if not res.is_dir(): + return [] + for other in sorted(res.iterdir()): + if other.suffix != ".md": + warn(other, "only Markdown files in resources/ become pages; this file stays on GitHub") + files = sorted(p for p in res.glob("*.md") if p.name != "README.md") + readme = res / "README.md" + linked = [] + if readme.exists(): + for target in re.findall(r"\]\(([^)#\s]+\.md)(?:#[^)]*)?\)", readme.read_text(encoding="utf-8")): + path = (res / target).resolve() + if path.parent == res.resolve() and path.exists() and path not in linked: + linked.append(path) + else: + warn(res, "resources/ has no README.md, so the Participant resources category has no first page") + order = linked + [p.resolve() for p in files if p.resolve() not in linked] + pages = [(readme, "resources/index.md", "Participant guide", 1)] if readme.exists() else [] + pages += [(p, f"resources/{p.stem}.md", None, i) for i, p in enumerate(order, start=2)] + return pages + + +def slugify(text: str) -> str: + text = re.sub(r"[^\w\s-]", "", text.lower(), flags=re.UNICODE) + return re.sub(r"[\s_]+", "-", text).strip("-") + + +def yaml_str(text: str) -> str: + return json.dumps(text, ensure_ascii=False) + + +def front_matter(**fields) -> str: + lines = ["---"] + for key, value in fields.items(): + if value is None: + continue + lines.append(f"{key}: {value if isinstance(value, (int, float, bool)) else yaml_str(str(value))}") + lines.append("format: md") + lines.append("---\n") + return "\n".join(lines) + "\n" + + +def split_title(markdown: str) -> tuple[str, str]: + match = re.search(r"^#\s+(.+?)\s*$", markdown, re.MULTILINE) + if not match: + return "Untitled", markdown + body = markdown[: match.start()] + markdown[match.end():] + return match.group(1), body.lstrip("\n") + + +def omit_sections(markdown: str, headings: list[str]) -> str: + """Remove each `## heading` section up to the next heading of level 2 or higher.""" + for heading in headings: + pattern = rf"^##\s+{re.escape(heading)}\s*\n.*?(?=^#{{1,2}}\s|\Z)" + markdown = re.sub(pattern, "", markdown, flags=re.MULTILINE | re.DOTALL) + return markdown.rstrip() + "\n" + + +class Site: + """Maps source files to generated pages and rewrites links between them.""" + + def __init__(self) -> None: + self.pages: dict[Path, Path] = {} # source file -> generated file + self.colab: dict[str, Path] = {} # Colab URL of a notebook -> generated file + + def add(self, src: Path, out: Path) -> None: + self.pages[src.resolve()] = out + if src.suffix == ".ipynb": + self.colab[f"{COLAB}/{src.relative_to(REPO).as_posix()}"] = out + + def rewrite_links(self, markdown: str, src: Path, out: Path, *, colab_to_page: bool) -> str: + def target(url: str) -> str: + url = url.strip() + if url.startswith(SITE + "/"): + path = "/" + url[len(SITE) + 1:] + # Static .html files are not routes; pathname:// keeps the link as is. + return f"pathname://{path}" if re.search(r"\.html(#|$)", path) else path + if url.startswith(SITE): + return "/" + if colab_to_page: + base, _, frag = url.partition("#") + if base in self.colab: + frag = frag.removeprefix("scrollTo=") + return self.relative(out, self.colab[base]) + (f"#{frag}" if frag else "") + if url.startswith("#scrollTo="): + return "#" + url[len("#scrollTo="):] + if re.match(r"^[a-z][a-z0-9+.-]*:", url) or url.startswith("#") or url.startswith("/"): + return url + path, _, frag = url.partition("#") + resolved = (src.parent / path).resolve() + frag = f"#{frag}" if frag else "" + if resolved in self.pages: + return self.relative(out, self.pages[resolved]) + frag + rel = resolved.relative_to(REPO).as_posix() + return f"{TREE if resolved.is_dir() else BLOB}/{rel}{frag}" + + def link(match: re.Match) -> str: + return f"{match.group(1)}({target(match.group(2))}{match.group(3) or ''})" + + parts = re.split(r"(^```.*?^```)", markdown, flags=re.MULTILINE | re.DOTALL) + for i in range(0, len(parts), 2): + parts[i] = re.sub(r"(\]\s?)\(([^()\s]+)(\s+\"[^\"]*\")?\)", link, parts[i]) + parts[i] = re.sub(r'href="([^"]+)"', lambda m: f'href="{target(m.group(1))}"', parts[i]) + return "".join(parts) + + @staticmethod + def relative(frm: Path, to: Path) -> str: + rel = Path(*([".."] * (len(frm.parent.relative_to(OUT).parts)))) / to.relative_to(OUT) + return rel.as_posix() if rel.as_posix().startswith(".") else "./" + rel.as_posix() + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def markdown_page(site: Site, src: Path, out: Path, *, position: int, label: str | None = None, + omit: list[str] | None = None, slug: str | None = None, note: str | None = None) -> None: + title, body = split_title(src.read_text(encoding="utf-8")) + if omit: + body = omit_sections(body, omit) + if note: + body = body.rstrip() + "\n\n## Source files\n\n" + note + "\n" + body = site.rewrite_links(body, src, out, colab_to_page=False) + rel = src.relative_to(REPO).as_posix() + write(out, front_matter(title=title, sidebar_label=label, sidebar_position=position, slug=slug, + custom_edit_url=f"{EDIT}/{rel}") + body) + + +def slides_page(src: Path, out: Path, *, position: float) -> None: + """A page that shows the session's Canva deck in Canva's embedded viewer.""" + deck = json.loads(src.read_text(encoding="utf-8")) + view = deck["view_url"] + embed = view + ("&" if "?" in view else "?") + "embed" + rel = src.relative_to(REPO).as_posix() + body = ( + "This page shows the slides of the session in the Canva viewer. " + "The Open in Canva button opens the deck in a separate tab.\n\n" + f'\n\n" + f'\n' + ) + write(out, front_matter(title=deck["title"], sidebar_label="Slides", sidebar_position=position, + custom_edit_url=f"{BLOB}/{rel}") + body) + + +def documents_page(site: Site, docs_dir: Path, out: Path, *, position: float) -> None: + """The Document collection page: a session's worked-example documents in the DocumentViewer component. + + `docs_dir/roles.json` groups the documents by role and holds the page's text: `intro`, a + list of paragraphs, and `sources`, a paragraph on where the figures come from.""" + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import build_training_pages + roles_file = docs_dir / "roles.json" + roles = json.loads(roles_file.read_text(encoding="utf-8")) + write(out.with_suffix(".json"), json.dumps(build_training_pages.viewer_data(docs_dir), indent=1, ensure_ascii=False)) + rel = docs_dir.relative_to(REPO).as_posix() + text = "\n\n".join(roles.get("intro", [])) + sources = f"The source files are in [`{rel}`]({TREE}/{rel}) on GitHub." + if roles.get("sources"): + sources += " " + roles["sources"] + write(out, "\n".join([ + "---", + "title: Document collection", + "sidebar_label: Document collection", + f"sidebar_position: {position}", + f"custom_edit_url: {BLOB}/{roles_file.relative_to(REPO).as_posix()}", + "---", + "", + "import DocumentViewer from '@site/src/components/training/DocumentViewer';", + f"import data from './{out.with_suffix('.json').name}';", + "", + site.rewrite_links(text, roles_file, out, colab_to_page=False), + "", + "