Files
2026-09-07 12:37:57 -05:00

98 lines
3.8 KiB
Bash
Executable File

#!/bin/bash
# site-qa.sh - QA for the Grav Website repo and (with -l BASE) the live site.
#
# ./site-qa.sh repo checks: structure, frontmatter, link targets
# ./site-qa.sh -l BASE live checks: every known route returns 200, all
# internal links/assets resolve, pages have content,
# canonical URLs are HTTPS
#
# Exit non-zero on any failure. Used by Gitea Actions, the local pre-push
# hook, and the post-deploy verification in the site's sync job.
set -uo pipefail
cd "$(dirname "$0")/.."
FAIL=0
fail() { echo "FAIL: $*"; FAIL=$((FAIL+1)); }
# pages-relative dir -> URL route: lowercase, strip NN. numeric prefixes
route_of() {
local p=${1#/}
p=$(echo "$p" | tr 'A-Z' 'a-z' | sed -E 's@(^|/)[0-9]+[.]@\1@g')
printf '%s' "${p:-/}"
}
# collect route -> file map from the pages tree
declare -A ROUTES PFILE
while IFS= read -r f; do
grep -q '^title:' "$f" || fail "no title frontmatter: $f"
d=${f#pages/} # e.g. 02.shop/01.Welcome/page.md
d=${d%/*} # 02.shop/01.Welcome
d=${d%/page} # (page.md pages keep their folder route)
r="/$(route_of "$d")"
# Grav's homepage alias: the configured home folder is served at /
[ "$r" = "/home" ] && r="/"
ROUTES["$r"]=$f
PFILE["$r"]=$f
done < <(find pages -name '*.md')
ROUTES["/"]=${PFILE["home"]:-}
repo_checks() {
local f r link t
for r in "${!ROUTES[@]}"; do
f=${ROUTES[$r]}
[ -n "$f" ] || continue
for link in $(grep -oE '\]\([^)#]+' "$f" | sed -E 's/^\]\(//'); do
case "$link" in http*|mailto:*) continue ;; esac
t="$r/$link"
t=$(awk -v p="$t" 'BEGIN{n=split(p,seg,"/");out="";for(i=1;i<=n;i++){s=tolower(seg[i]);sub(/^[0-9]+\./,"",s);if(s==""||s==".")continue;if(s==".."){sub(/\/[^\/]*$/,"",out)}else{out=out"/"s}};print (out==""?"/":out)}')
[ -n "${ROUTES[$t]:-}" ] || fail "broken link '$link' in ${PFILE[$r]} (no route '$t')"
done
done
echo "repo: ${#ROUTES[@]} routes checked"
}
live_checks() {
local BASE=${1%/}
local ROUTELIST=("/") SITEMAP=/tmp/qa-sitemap.txt CHECKED=/tmp/qa-checked.txt
local r
for r in "${!ROUTES[@]}"; do [ "$r" = "/" ] || ROUTELIST+=("/$r"); done
: > $SITEMAP; : > $CHECKED
for r in $(printf '%s\n' "${ROUTELIST[@]}" | sort -u); do
code=$(curl -s -o /dev/null -w '%{http_code}' -m 20 --retry 1 "$BASE$r")
if [ "$code" = "200" ]; then echo "$BASE$r" >> $SITEMAP; else fail "route $r -> $code"; fi
sleep 0.2
done
can=$(curl -s -m 15 "$BASE/" | grep -oE '<link rel="canonical" href="[^"]*"' | head -1)
case "$can" in
*https://*) : ;;
"") fail "no canonical link on home" ;;
*) fail "canonical not https: $can" ;;
esac
local url body upath link code
for url in $(cat $SITEMAP); do
body=$(curl -s -m 20 "$url"); sleep 0.2
[ ${#body} -lt 500 ] && fail "page too small ($url, ${#body} bytes)"
upath=${url#$BASE}; upath=${upath:-/}
for link in $(echo "$body" | grep -oE 'href="[^"]*"' | sed 's/href="//; s/"$//' | sort -u); do
case "$link" in
http*|mailto:*|\#*) [[ "$link" == "$BASE"* ]] || continue ;;
/*) link="$BASE$link" ;;
*) link="$BASE$(realpath -m "${upath%/}/..${upath%/}/$link" 2>/dev/null | sed 's|//\+|/|g')" ;;
esac
grep -qxF "$link" $CHECKED && continue
echo "$link" >> $CHECKED
case "$link" in
*.css|*.js|*.png|*.jpg|*.jpeg|*.svg|*.ico|*.woff*|*.map|*.webp|*.xml) continue ;;
esac
code=$(curl -s -o /dev/null -w '%{http_code}' -m 20 "$link"); sleep 0.1
[ "$code" != "200" ] && fail "link $link -> $code"
done
done
echo "checked ${#ROUTES[@]} routes, $(wc -l < $CHECKED) links/assets"
}
case "${1:-}" in
-l) live_checks "$2" ;;
*) repo_checks ;;
esac
[ $FAIL -eq 0 ] && echo "QA: PASS" || { echo "QA: $FAIL failure(s)"; exit 1; }