🏗️ 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
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.
177 lines
6.8 KiB
Bash
Executable File
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.)"
|