From eaf98b3069042fb0c4c67f981d73ae1f000c073c Mon Sep 17 00:00:00 2001 From: Lars Ivar Hatledal Date: Sun, 26 Jul 2026 09:13:00 +0200 Subject: [PATCH] add CI checks for links, anchors and quizzes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clean `mkdocs build` says nothing about whether a link still points at a heading that exists, or whether a quiz question has an answer. Both fail silently: the reader lands at the top of a page, or clicks four options and is told all of them are wrong. Four of the eight defects found in the recent review were of exactly this kind. tools/check_links.py walks the *built* site rather than the markdown, for two reasons. Heading anchors come from the slugifier, so checking sources means reimplementing it and hoping the copy stays honest. And under /nb/ mkdocs-static-i18n has already chosen between the translation and the English fallback — which is what makes an English anchor like #range-based-for resolve to a Norwegian page whose slug is #range-basert-for. Currently 11516 links, all resolving. tools/check_quizzes.py asserts every question has exactly one correct option, at least three options, and that no authoring syntax leaked through a block the hook failed to parse. tools/selftest.py plants a known defect in a copy of the site and asserts the checkers catch it. This is not ceremony — writing it found two real holes. check_links.py ignores the theme navigation, so a test mutating a nav link proves nothing; and check_quizzes.py looked for leaked syntax only on pages where a quiz had parsed, skipping the check in exactly the case where the hook had failed. The second was a genuine bug, fixed here. Also fixes Chapter5/templates.md, which used std::string without including . The page quotes the template-deduction error, but a reader pasting the code got "std::string is not a member of std" first. Verified: with the include, the deduction error is the first one. Co-Authored-By: Claude Opus 5 --- .github/workflows/check.yml | 41 ++++++++++++++ docs/Chapter5/templates.md | 2 + tools/check_links.py | 109 ++++++++++++++++++++++++++++++++++++ tools/check_quizzes.py | 106 +++++++++++++++++++++++++++++++++++ tools/selftest.py | 109 ++++++++++++++++++++++++++++++++++++ 5 files changed, 367 insertions(+) create mode 100644 .github/workflows/check.yml create mode 100644 tools/check_links.py create mode 100644 tools/check_quizzes.py create mode 100644 tools/selftest.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..0f6a04d --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,41 @@ +name: Check + +# Runs on every push and pull request. `deploy.yml` publishes the site; this +# job is about whether the site is *correct* — chiefly the failures that a +# clean `mkdocs build` does not notice, such as a link to a heading that no +# longer exists or a quiz question with no correct answer. + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt + + # --strict turns MkDocs' own warnings (a nav entry pointing at a missing + # file, say) into build failures. + - name: Build site + run: mkdocs build --strict + + # Confirms the checkers below still detect a planted defect, so they + # cannot quietly decay into always passing. + - name: Self-test the checkers + run: python tools/selftest.py site + + - name: Check links and anchors + run: python tools/check_links.py site + + - name: Check quiz questions + run: python tools/check_quizzes.py site diff --git a/docs/Chapter5/templates.md b/docs/Chapter5/templates.md index d1051ca..11d2555 100644 --- a/docs/Chapter5/templates.md +++ b/docs/Chapter5/templates.md @@ -141,6 +141,8 @@ Take a simple mistake: ```cpp +#include + template T add(T a, T b) { return a + b; } diff --git a/tools/check_links.py b/tools/check_links.py new file mode 100644 index 0000000..cd966c0 --- /dev/null +++ b/tools/check_links.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Check that every internal link in the built site resolves — anchors included. + +Run it against the output of `mkdocs build`: + + python tools/check_links.py site + +Why the built HTML rather than the markdown sources? Two reasons, both of which +have bitten this book already: + +* Heading anchors are generated by the slugifier, so checking markdown means + reimplementing that slugifier and hoping the copy stays faithful. Reading the + emitted `id="..."` attributes needs no such guess. +* Under `/nb/`, mkdocs-static-i18n has already decided whether each page is the + Norwegian translation or the English fallback. A link written + `control_statements.md#range-based-for` resolves to the *Norwegian* page, + whose heading slug is `#range-basert-for` — so the anchor silently breaks and + the reader lands at the top of the page. Nothing in the build warns about it. + Checking the built tree catches exactly this. + +Exits non-zero if anything is unresolved, so CI fails on a broken link. +""" + +import argparse +import os +import re +import sys +from urllib.parse import unquote, urlparse + +ID_RE = re.compile(r'\bid="([^"]+)"') +HREF_RE = re.compile(r']*?href="([^"]+)"', re.IGNORECASE) +ARTICLE_RE = re.compile(r"", re.DOTALL) + +# Schemes that are somebody else's problem. +EXTERNAL = ("http:", "https:", "mailto:", "tel:", "javascript:", "data:", "//") + + +def collect(site_dir): + """Map each built page to its element ids, and gather every link it makes.""" + pages, links = {}, [] + for root, _dirs, files in os.walk(site_dir): + for name in files: + if not name.endswith(".html"): + continue + full = os.path.join(root, name) + rel = os.path.relpath(full, site_dir).replace(os.sep, "/") + html = open(full, encoding="utf-8").read() + + # Ids come from the whole page; links only from the article body, so + # the theme's own chrome (nav, tabs, search) is not under test. + pages[rel] = set(ID_RE.findall(html)) + match = ARTICLE_RE.search(html) + for href in HREF_RE.findall(match.group(0) if match else html): + links.append((rel, href)) + return pages, links + + +def resolve(from_page, href): + """Return (target page, fragment) for a link found on `from_page`.""" + parsed = urlparse(href) + path, frag = parsed.path, unquote(parsed.fragment) + if not path: + return from_page, frag + base = os.path.dirname(from_page) + target = os.path.normpath(os.path.join(base, path)).replace(os.sep, "/") + if not target.endswith(".html"): + target = target.rstrip("/") + "/index.html" + return target, frag + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("site", nargs="?", default="site", help="built site directory") + args = ap.parse_args() + + if not os.path.isdir(args.site): + sys.exit(f"error: '{args.site}' is not a directory — run `mkdocs build` first") + + pages, links = collect(args.site) + missing_page, missing_anchor = [], [] + + for from_page, href in links: + if href.startswith(EXTERNAL) or href == "#": + continue + target, frag = resolve(from_page, href) + if target not in pages: + missing_page.append((from_page, href)) + elif frag and frag not in pages[target]: + missing_anchor.append((from_page, href)) + + print(f"pages scanned : {len(pages)}") + print(f"links checked : {len(links)}") + + for label, rows in (("BROKEN LINK", missing_page), ("BROKEN ANCHOR", missing_anchor)): + for page, href in rows: + print(f" {label}: {page} -> {href}") + + total = len(missing_page) + len(missing_anchor) + if total: + print(f"\nFAILED: {len(missing_page)} broken link(s), " + f"{len(missing_anchor)} broken anchor(s)") + return 1 + print("OK: every internal link and anchor resolves") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/check_quizzes.py b/tools/check_quizzes.py new file mode 100644 index 0000000..f8e9a86 --- /dev/null +++ b/tools/check_quizzes.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Check the multiple-choice questions in the built site. + +Run it against the output of `mkdocs build`: + + python tools/check_quizzes.py site + +A quiz is authored as a ````quiz block (see hooks/quiz.py) and rendered to +HTML. Three things can go wrong silently, and all three survive a clean build: + +* **No correct option**, or more than one. `quiz.js` marks whichever options + carry `data-correct`, so a missing `=` in the source produces a question that + is simply unanswerable — it goes red whatever the reader picks. +* **Too few options.** A question with one or two is not worth asking. +* **A malformed block.** If the fence or the `:::` separator is wrong, the hook + leaves the block alone and the raw authoring syntax renders as literal text. + +Exits non-zero on any of them, so CI fails rather than shipping a broken quiz. +""" + +import argparse +import os +import re +import sys + +QUIZ_RE = re.compile(r'
.*?', re.DOTALL) +OPTION_RE = re.compile(r'
  • ]*)>') +ARTICLE_RE = re.compile(r"", re.DOTALL) + +MIN_OPTIONS = 3 + +# Authoring syntax that must never reach the page. If any of these show up, the +# hook did not process a block it should have. +LEAKED = [ + (":::", "the quiz explanation separator"), + ("````", "a four-backtick quiz fence"), + ('markdown="', "an md_in_html attribute"), +] + + +def check_page(rel, html): + problems = [] + match = ARTICLE_RE.search(html) + article = match.group(0) if match else html + + quizzes = QUIZ_RE.findall(article) + for index, block in enumerate(quizzes, start=1): + options = OPTION_RE.findall(block) + correct = sum(1 for attrs in options if 'data-correct="1"' in attrs) + if correct != 1: + problems.append( + f"{rel} quiz {index}: {correct} correct options, expected exactly 1" + ) + if len(options) < MIN_OPTIONS: + problems.append( + f"{rel} quiz {index}: only {len(options)} options, expected at least {MIN_OPTIONS}" + ) + + # Deliberately NOT guarded by `if quizzes:`. When the hook fails to parse a + # block, no `.quiz` div is produced at all — so guarding on a successful + # parse would skip the check in exactly the case it exists to catch. None of + # these markers appear anywhere in the built site legitimately. + for needle, what in LEAKED: + if needle in article: + problems.append(f"{rel}: {what} leaked into the rendered page ({needle!r})") + + return len(quizzes), problems + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("site", nargs="?", default="site", help="built site directory") + args = ap.parse_args() + + if not os.path.isdir(args.site): + sys.exit(f"error: '{args.site}' is not a directory — run `mkdocs build` first") + + total, pages_with_quizzes, problems = 0, 0, [] + for root, _dirs, files in os.walk(args.site): + for name in sorted(files): + if not name.endswith(".html"): + continue + full = os.path.join(root, name) + rel = os.path.relpath(full, args.site).replace(os.sep, "/") + count, found = check_page(rel, open(full, encoding="utf-8").read()) + total += count + pages_with_quizzes += 1 if count else 0 + problems.extend(found) + + print(f"quizzes found : {total} across {pages_with_quizzes} page(s)") + for p in problems: + print(f" PROBLEM: {p}") + + if problems: + print(f"\nFAILED: {len(problems)} problem(s)") + return 1 + if total == 0: + print("OK: no quizzes to check") + return 0 + print("OK: every question has exactly one correct option") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/selftest.py b/tools/selftest.py new file mode 100644 index 0000000..17d3703 --- /dev/null +++ b/tools/selftest.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Prove that the checkers in this folder actually catch things. + + python tools/selftest.py site + +A checker that only ever passes is worse than no checker: it buys confidence +without earning it. This plants a known defect in a *copy* of the built site, +runs the relevant checker, and asserts it fails — then checks that an untouched +copy still passes. + +Both of these were caught by writing this file: `check_links.py` ignores the +theme's navigation (so a test that mutates a nav link proves nothing), and +`check_quizzes.py` originally looked for leaked authoring syntax only on pages +where a quiz had parsed successfully — skipping the check in precisely the case +where the hook had failed. + +Exits non-zero if any planted defect goes undetected. +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile + +TOOLS = os.path.dirname(os.path.abspath(__file__)) +ARTICLE_RE = re.compile(r"", re.DOTALL) +PAGE = "Chapter1/exercises/index.html" + + +def run_checker(script, site): + r = subprocess.run( + [sys.executable, os.path.join(TOOLS, script), site], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + return r.returncode, r.stdout + r.stderr + + +def patch_article(site, rel, fn): + """Rewrite the article body of one page. The nav is not under test.""" + path = os.path.join(site, rel.replace("/", os.sep)) + html = open(path, encoding="utf-8").read() + m = ARTICLE_RE.search(html) + if not m: + raise RuntimeError(f"no
    in {rel}") + open(path, "w", encoding="utf-8").write(html[: m.start()] + fn(m.group(0)) + html[m.end():]) + + +CASES = [ + ("broken link", "check_links.py", "BROKEN LINK", + lambda a: a.replace('href="../variables/"', 'href="../nope/"', 1)), + ("broken anchor", "check_links.py", "BROKEN ANCHOR", + lambda a: a.replace('href="../functions/#parameters-are-copies"', + 'href="../functions/#no-such-heading"', 1)), + ("no correct option", "check_quizzes.py", "0 correct options", + lambda a: a.replace(' data-correct="1"', "", 1)), + ("two correct options", "check_quizzes.py", "2 correct options", + lambda a: a.replace('
  • ", ":::
  • ", 1)), +] + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("site", nargs="?", default="site", help="built site directory") + args = ap.parse_args() + + if not os.path.isdir(args.site): + sys.exit(f"error: '{args.site}' is not a directory — run `mkdocs build` first") + + rows, ok = [], True + + for name, script, expect, mutate in CASES: + tmp = tempfile.mkdtemp(prefix="selftest-") + try: + site = os.path.join(tmp, "site") + shutil.copytree(args.site, site) + patch_article(site, PAGE, mutate) + code, out = run_checker(script, site) + caught = code != 0 and expect.lower() in out.lower() + finally: + shutil.rmtree(tmp, ignore_errors=True) + ok &= caught + rows.append((name, script, caught)) + + # And the control: an unmodified site must pass both. + clean = all(run_checker(s, args.site)[0] == 0 + for s in ("check_links.py", "check_quizzes.py")) + ok &= clean + rows.append(("clean site passes", "both", clean)) + + width = max(len(r[0]) for r in rows) + for name, script, good in rows: + print(f" {'ok ' if good else 'FAIL'} {name:{width}} ({script})") + + if not ok: + print("\nFAILED: a planted defect was not detected") + return 1 + print(f"\nOK: {len(rows)} self-tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())