Files
jellytau/scripts/check-doc-links.sh
T
dtourolle ae26d5356a
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 27m6s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m26s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 3m6s
docs: fix remaining references to the moved build docs
Updates the two referrers outside the docs tree that the move left behind, and
excludes .claude/ from the link checker — the agent worktrees under it are full
checkouts, so it was walking every in-flight branch and reporting their links
as ours.
2026-08-20 19:34:44 +02:00

177 lines
6.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# Documentation link integrity: every relative markdown link must point at a
# file that exists.
#
# Implements DR-208 (see docs/requirements.md).
#
# Why this exists: docs/traceability.md is generated into docs/ while its file
# links were emitted repo-root-relative, so all ~2,800 of them resolved to
# docs/src-tauri/… and 404'd — in the Gitea repo browser and on the published
# mdBook site alike. Nobody clicks 2,800 links, so it went unnoticed for months.
# Several hand-written docs had the same defect at smaller scale: links to files
# that had been deleted, and links written as if the doc lived at the repo root.
# A link that does not resolve is a documentation defect of the same kind as a
# compile error, and a grep is enough to catch the whole class.
#
# What it checks: for every tracked `.md` file, every inline markdown link
# `[text](target)` whose target is a *path* — the target is resolved relative to
# the directory of the file containing it, and must exist on disk.
#
# ⚠️ It validates PATHS, NOT ANCHORS. A green run does not mean the links land
# where the text claims.
#
# 🔴 What it deliberately CANNOT see (do not read a green run as proof):
# - **Anchor fragments.** `foo.md#some-heading` is checked only as `foo.md`.
# Resolving the fragment needs a markdown renderer's heading-slug rules
# (which differ between Gitea, GitHub and mdBook), so a link to a heading
# that was renamed still passes here. That is a deliberate scope cut, not an
# oversight.
# - **External URLs.** http(s):// and mailto: are skipped. Checking them means
# network I/O in a gate, which makes the gate flaky and slow; link rot in an
# external URL is also not something a commit can break.
# - **Reference-style links** (`[text][ref]` with a separate `[ref]: target`
# definition) and bare autolinks. This project writes inline links; add the
# pattern here if that changes.
# - **Links inside fenced code blocks**, which are intentionally skipped —
# a template being *shown* to the reader (e.g. the release-notes template in
# docs/release-checklist.md) is sample text, not a live link, and its targets
# are resolved wherever it is eventually pasted, not from the docs tree.
# - **A link that resolves to the wrong existing file.** Existence is not
# correctness.
#
# Usage: bash scripts/check-doc-links.sh
# Exits non-zero, listing file:line and the unresolved target, on any failure.
set -euo pipefail
cd "$(dirname "$0")/.."
# Generated, vendored or build-output trees. Their markdown is not authored here
# and their link targets are not ours to fix.
EXCLUDES=(
"./node_modules/*"
"./.svelte-kit/*"
"./build/*"
"./dist/*"
"./src-tauri/gen/*"
"./src-tauri/target/*"
"./.git/*"
# Agent/dev scratch worktrees (.claude/worktrees is itself git-ignored). These
# are full checkouts of the repo, so without this the checker walks every
# in-flight branch and reports its links as if they were ours.
"./.claude/*"
)
# Targets that do not exist in the repo *by design* because the publish-docs job
# writes them into docs/ at build time (see .gitea/workflows/publish-docs.yml).
# Keep this list to genuinely generated pages — anything else here is a broken
# link being hidden.
GENERATED_TARGETS=(
"./docs/README.md" # the site's landing page, written by publish-docs
"./docs/api-redirect.md" # the rustdoc redirect stub, likewise
)
is_generated() {
local candidate="$1"
for generated in "${GENERATED_TARGETS[@]}"; do
[[ "$candidate" == "$generated" ]] && return 0
done
return 1
}
echo "🔎 Checking relative markdown links resolve to files on disk…"
# Build the find(1) prune expression from EXCLUDES.
find_args=(. )
for pattern in "${EXCLUDES[@]}"; do
find_args+=(-path "$pattern" -prune -o)
done
find_args+=(-name "*.md" -type f -print)
mapfile -t md_files < <(find "${find_args[@]}" | sort)
echo " ${#md_files[@]} markdown files"
broken=""
checked=0
for md in "${md_files[@]}"; do
dir="$(dirname "$md")"
# One documented exception: docs-site/SUMMARY.md is mdBook's table of
# contents, and the publish-docs job copies it *into* docs/ before rendering
# (book.toml sets src = "../docs"). Its links are therefore written relative
# to docs/, not to the directory the file is stored in. Resolving it from
# docs/ is what actually validates it — and it is the check that catches a
# SUMMARY entry pointing at a page that does not exist, which mdBook itself
# only warns about.
if [[ "$md" == "./docs-site/SUMMARY.md" ]]; then
dir="./docs"
fi
# Strip fenced code blocks (``` and ~~~) before extracting links, so sample
# markdown shown to the reader is not checked as if it were a live link.
# Line numbers are preserved by blanking the lines rather than deleting them.
#
# Then emit "lineno<TAB>target" for each inline link on each surviving line.
while IFS=$'\t' read -r lineno target; do
[[ -z "${target:-}" ]] && continue
# Skip external schemes and pure-anchor links.
case "$target" in
http://*|https://*|mailto:*|ftp://*|"#"*|"") continue ;;
# A protocol-relative or scheme-ish target we do not resolve.
//*) continue ;;
esac
# Drop any anchor fragment and query string — we check the path only.
path="${target%%#*}"
path="${path%%\?*}"
[[ -z "$path" ]] && continue
# Percent-decode: SvelteKit route directories are literally named `[id]`,
# which docs link as `%5Bid%5D`, and spaces appear as `%20`.
if [[ "$path" == *%* ]]; then
path="$(printf '%b' "${path//%/\\x}")"
fi
checked=$((checked + 1))
if is_generated "$dir/$path"; then
continue
fi
if [[ ! -e "$dir/$path" ]]; then
broken+="${md}:${lineno} -> ${target}"$'\n'
fi
done < <(
awk '
/^[[:space:]]*(```|~~~)/ { fence = !fence; print ""; next }
fence { print ""; next }
{ print }
' "$md" |
grep -noE '\]\([^)[:space:]]+' |
sed -E 's/^([0-9]+):\]\(/\1\t/'
)
done
echo " $checked relative links checked"
if [[ -n "$broken" ]]; then
echo ""
echo "❌ Broken documentation links — these targets do not exist on disk:"
echo ""
echo "$broken" | sed 's/^/ /'
echo " Each link is resolved relative to the directory of the file it is in."
echo " The usual causes:"
echo " • the target file was moved or deleted — update or drop the link;"
echo " • the link was written as if the doc lived at the repo root — a doc"
echo " in docs/ needs '../' to reach src/, scripts/ or CHANGELOG.md;"
echo " • a generated doc emits repo-root-relative hrefs — fix the"
echo " generator, not the output (see scripts/extract-traces.ts)."
exit 1
fi
echo "✅ All relative documentation links resolve."
echo " (Reminder: paths only — anchors and external URLs are NOT checked.)"