From 204c44f389c2e18794526087176c28a6666a22b5 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Thu, 10 Sep 2026 12:55:39 +0300 Subject: [PATCH 1/3] Check the manual's xref and image targets in CI A broken xref in the tap page shipped in 2.0.0 and went unnoticed for two months, because nothing verifies the Antora sources. This adds a small dependency-free checker that walks the pages and nav, errors on xrefs to missing pages and on missing images, and warns on fragments it can't find (the auto-generated section ids are approximated, and a handful of existing fragments already don't match). A Docs workflow runs it on pushes to master and on pull requests that touch the manual. --- .github/workflows/docs.yml | 27 +++++++++++ scripts/check-docs-xrefs.py | 94 +++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100755 scripts/check-docs-xrefs.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..e1dfc39f8 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,27 @@ +name: Docs + +on: + push: + branches: + - master + paths: + - 'doc/**' + - 'scripts/check-docs-xrefs.py' + - '.github/workflows/docs.yml' + pull_request: + paths: + - 'doc/**' + - 'scripts/check-docs-xrefs.py' + - '.github/workflows/docs.yml' + +permissions: + contents: read + +jobs: + xrefs: + name: Check cross-references and images + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - run: python3 scripts/check-docs-xrefs.py diff --git a/scripts/check-docs-xrefs.py b/scripts/check-docs-xrefs.py new file mode 100755 index 000000000..0b744e10c --- /dev/null +++ b/scripts/check-docs-xrefs.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Check that the manual's cross-references and images point at things that exist. + +Walks doc/modules/ROOT (pages and nav.adoc) and verifies: + +- every xref: target page exists under pages/ (an error); +- every xref: fragment resolves to an anchor in the target page, either an + explicit [#id] / [[id]] or a section title's auto-generated id (a warning, + since Antora's id generation has corner cases this script doesn't model); +- every image:/image:: target exists under assets/images (an error). + +Exits non-zero only on errors, so it can gate CI without being pedantic. +""" + +import os +import re +import sys + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "doc", "modules", "ROOT") +PAGES = os.path.join(ROOT, "pages") +IMAGES = os.path.join(ROOT, "assets", "images") + +XREF_RE = re.compile(r"xref:([^\[\s]+)\[") +IMAGE_RE = re.compile(r"image::?([^\[\s]+)\[") +EXPLICIT_ANCHOR_RE = re.compile(r"^\[#([^\],]+)|\[\[([^\]]+)\]\]", re.MULTILINE) +SECTION_RE = re.compile(r"^=+\s+(.+?)\s*$", re.MULTILINE) + + +def section_id(title): + """Approximate Asciidoctor's auto id for TITLE (idprefix `_', idseparator `_').""" + title = re.sub(r"`|\*|_(?=\w)|(?<=\w)_", "", title) # strip inline formatting marks + title = re.sub(r"kbd:\[([^\]]*)\]", r"\1", title) + slug = re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_") + return "_" + slug + + +def anchors_in(path): + with open(path, encoding="utf-8") as f: + text = f.read() + ids = set() + for m in EXPLICIT_ANCHOR_RE.finditer(text): + ids.add(m.group(1) or m.group(2)) + for m in SECTION_RE.finditer(text): + ids.add(section_id(m.group(1))) + return ids + + +def adoc_files(): + for dirpath, _, names in os.walk(PAGES): + for name in sorted(names): + if name.endswith(".adoc"): + yield os.path.join(dirpath, name) + yield os.path.join(ROOT, "nav.adoc") + + +def main(): + errors, warnings = [], [] + anchor_cache = {} + for path in adoc_files(): + rel = os.path.relpath(path, ROOT) + with open(path, encoding="utf-8") as f: + lines = f.read().splitlines() + for lineno, line in enumerate(lines, 1): + for target in XREF_RE.findall(line): + page, _, anchor = target.partition("#") + if page: + if not page.endswith(".adoc"): + continue # a resource or module-qualified xref; not modelled + target_path = os.path.normpath(os.path.join(PAGES, page)) + else: + target_path = path + if not os.path.isfile(target_path): + errors.append(f"{rel}:{lineno}: xref to missing page {page}") + continue + if anchor: + if target_path not in anchor_cache: + anchor_cache[target_path] = anchors_in(target_path) + if anchor not in anchor_cache[target_path]: + warnings.append(f"{rel}:{lineno}: xref to unknown anchor #{anchor} in {page or rel}") + for target in IMAGE_RE.findall(line): + if "://" in target: + continue + if not os.path.isfile(os.path.join(IMAGES, target)): + errors.append(f"{rel}:{lineno}: image {target} not found under assets/images") + for w in warnings: + print(f"warning: {w}") + for e in errors: + print(f"error: {e}") + print(f"{len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 3704576b48a5ab61271e6e516da3023eccba7b0f Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Thu, 10 Sep 2026 12:55:39 +0300 Subject: [PATCH 2/3] Mirror the CI steps in the Makefile Add docs-check, test-ts and test-under-ts so the checks CI runs can be reproduced locally without reading the workflow, list test in .PHONY, and byte-compile before running the unit tests in the default target. --- Makefile | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index e73fc1168..732616234 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean compile lint refcard test-all test-coverage test-integration test-unit +.PHONY: clean compile docs-check lint refcard test test-all test-coverage test-integration test-ts test-under-ts test-unit .DEFAULT_GOAL := test # Per our CircleCI, linting/compiling assumes Emacs 28. @@ -17,7 +17,7 @@ lint: clean # Checks for byte-compilation warnings. compile: clean - eldev -dtT compile --warnings-as-errors + eldev -dtT compile --warnings-as-errors test-all: clean eldev -dtT -p test --test-type all @@ -28,12 +28,24 @@ test-integration: clean test-unit: clean eldev -dtT -p test +# The specs under test/clojure-ts-mode/ (plus the main suite), as CI runs them on Emacs 30. +test-ts: clean + eldev -dtT -p test --test-type clojure-ts-mode + +# The main suite with every clojure-mode buffer switched to clojure-ts-mode (advisory in CI). +test-under-ts: clean + eldev -dtT -p test --under-clojure-ts-mode + # Line coverage of the unit tests (needs the sources as-is, hence no -p). test-coverage: clean eldev -dtT test -u on,text +# Verify that the manual's xref: and image: targets exist. +docs-check: + python3 scripts/check-docs-xrefs.py + # Rebuild the printable quick-reference card into the manual's attachments. refcard: tectonic -o doc/modules/ROOT/attachments refcard/cider-refcard.tex -test: lint test-unit compile +test: lint compile test-unit From 93dae15accf73473cc6f5ef0fae5dbc8c16d545b Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Thu, 10 Sep 2026 16:06:52 +0300 Subject: [PATCH 3/3] Generate section ids the way the site does The playbook sets idprefix to nothing and idseparator to a dash, so the checker approximated auto ids with the wrong shape and warned about eight fragments that resolve fine. With the site's settings it warns about the two that are actually broken. --- scripts/check-docs-xrefs.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/check-docs-xrefs.py b/scripts/check-docs-xrefs.py index 0b744e10c..d33c96464 100755 --- a/scripts/check-docs-xrefs.py +++ b/scripts/check-docs-xrefs.py @@ -27,11 +27,14 @@ def section_id(title): - """Approximate Asciidoctor's auto id for TITLE (idprefix `_', idseparator `_').""" + """Approximate the auto id for TITLE the way the site builds it. + + The playbook sets idprefix to the empty string and idseparator to `-', + so a section titled "Up and Running" gets the id `up-and-running'. + """ title = re.sub(r"`|\*|_(?=\w)|(?<=\w)_", "", title) # strip inline formatting marks title = re.sub(r"kbd:\[([^\]]*)\]", r"\1", title) - slug = re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_") - return "_" + slug + return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") def anchors_in(path):