Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions docs/Chapter5/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ Take a simple mistake:

<!-- no-ce -->
```cpp
#include <string>

template <typename T>
T add(T a, T b) { return a + b; }

Expand Down
109 changes: 109 additions & 0 deletions tools/check_links.py
Original file line number Diff line number Diff line change
@@ -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'<a\b[^>]*?href="([^"]+)"', re.IGNORECASE)
ARTICLE_RE = re.compile(r"<article\b.*?</article>", 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())
106 changes: 106 additions & 0 deletions tools/check_quizzes.py
Original file line number Diff line number Diff line change
@@ -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'<div class="quiz">.*?</ul>', re.DOTALL)
OPTION_RE = re.compile(r'<li class="quiz-option"([^>]*)>')
ARTICLE_RE = re.compile(r"<article\b.*?</article>", 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())
109 changes: 109 additions & 0 deletions tools/selftest.py
Original file line number Diff line number Diff line change
@@ -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"<article\b.*?</article>", 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 <article> 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('<li class="quiz-option" role="button"',
'<li class="quiz-option" data-correct="1" role="button"', 1)),
("leaked quiz syntax", "check_quizzes.py", "leaked",
lambda a: a.replace("</article>", ":::</article>", 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())
Loading