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
27 changes: 27 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 15 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand 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
97 changes: 97 additions & 0 deletions scripts/check-docs-xrefs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/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 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)
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")


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())
Loading