#!/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 "linenotarget" 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.)"