diff --git a/.gitignore b/.gitignore index 432e139..f35c90f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ linkcheck/ resources/_gen/ __pycache__/ .hermes/ +.tools/ diff --git a/Makefile b/Makefile index e992c9f..9bdb4ad 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,37 @@ PAGEFIND := npx -y pagefind LYCHEE := lychee OUT := public -.PHONY: all build index serve run link-check link-fix external-check serve-lan clean +# --- Pinned toolchain (repo-local .tools/; gitignored) --- +# `make bootstrap` installs these exact versions into .tools/bin. The PATH +# prepend below applies to every recipe, so make targets always use the pinned +# tools; your system PATH is never modified. `make tool-versions` shows both. +HUGO_VERSION := 0.167.0 +NODE_VERSION := 22.11.0 +LYCHEE_VERSION := 0.24.2 +TOOLS_BIN := $(CURDIR)/.tools/bin +export PATH := $(TOOLS_BIN):$(PATH) + +.PHONY: all build index serve run link-check link-fix external-check external-fix external-fix-apply serve-lan clean bootstrap tool-versions preflight all: index -# Build Hugo content -> public/ -build: +# ONE-TIME per machine: install the pinned hugo/node/lychee into .tools/bin. +# Idempotent (present-at-pin tools are skipped); touches nothing outside .tools/. +bootstrap: + scripts/bootstrap.sh + +# Diagnostic: pinned (.tools/bin) vs PATH-first (system) tool versions. +tool-versions: + scripts/tool_versions.sh + +# Strict tool check; runs automatically before all/serve/serve-lan and +# fails with a `make bootstrap` hint instead of a bare "error 127". +preflight: + scripts/tool_versions.sh --strict + +# Build Hugo content -> public/ (preflight first so a missing tool fails fast +# and in order even under `make -j`). +build: preflight $(HUGO) --gc # (Re)build the Pagefind search index from public/ -> public/pagefind/ @@ -39,14 +64,32 @@ link-fix: $(PAGEFIND) --site $(OUT) # DEAD LINKS -- external (network): scan all http/https links in content. Report-only. -# Always exits 0; the report is the point. +# Always exits 0; the report is the point. Also saves the full lychee JSON to +# linkcheck/external.json so external-fix can act without re-pinging the web. external-check: python3 scripts/check_external_links.py +# Preview which EXTERNAL links are truly dead (domain/DNS no longer exists) and +# would be commented out. Reports only; changes nothing. Run external-check first. +external-fix: + python3 scripts/fix_dead_external_links.py --report linkcheck/external.json + +# Comment out the truly-dead EXTERNAL links (DNS failed / host unreachable), then +# rebuild content + index so they leave the live page AND the search index. +# 404s, SSL errors, 5xx, redirects, refused/reset connections are NOT auto-commented +# (domain is still alive / ambiguous) -- they go to linkcheck/manual_review_external.txt. +external-fix-apply: + python3 scripts/fix_dead_external_links.py --report linkcheck/external.json --apply + $(HUGO) --gc + $(PAGEFIND) --site $(OUT) + # Serve the BUILT site on all interfaces so another LAN machine can open it: # http://:8090/ (IP printed at startup; also `hostname -I | head -1`) -serve-lan: +serve-lan: preflight python3 scripts/serve_lan.py +# NOTE: clean does NOT remove .tools/ -- the pinned toolchain is expensive to +# reinstall and independent of build output. Remove it manually if ever needed. + clean: rm -rf $(OUT) linkcheck diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..8b5d792 --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Install the pinned toolchain into the repo-local .tools/bin (gitignored): +# hugo extended 0.167.0, node 22.11.0 (provides npx/npm), lychee 0.24.2 +# Idempotent: a tool already present at the pinned version is skipped, so +# re-running with everything installed is an instant no-op. Touches nothing +# outside .tools/ and never modifies your system PATH. +# Requires: curl, tar, sha256sum, and network on first run. +set -eu +ROOT=$(cd "$(dirname "$0")/.." && pwd) +BIN="$ROOT/.tools/bin" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +HUGO_VERSION=0.167.0 +HUGO_SHA256=0163f5c3deddac1f494a1629ddc40c65d18de9d5794facd98f7f96ac2c7d8957 +NODE_VERSION=22.11.0 +LYCHEE_VERSION=0.24.2 + +case "$(uname -s)/$(uname -m)" in + Linux/x86_64) HARCH=amd64; NARCH=x64; RARCH=x86_64 ;; + Linux/aarch64) HARCH=arm64; NARCH=arm64; RARCH=aarch64 ;; + *) echo "bootstrap: unsupported platform $(uname -s) $(uname -m) (Linux x86_64/aarch64 only)" >&2; exit 1 ;; +esac + +command -v curl >/dev/null 2>&1 || { echo "bootstrap: curl is required (downloads the pinned tarballs)" >&2; exit 1; } + +mkdir -p "$BIN" + +# --- hugo extended --- +# Release builds report "v-<40-hex-commit>+extended"; strip the commit so +# the pinned comparison is exact. +hugo_norm() { "$1" version 2>/dev/null | awk '{print $2}' | sed 's/-[0-9a-f]\{40,\}//'; } +if [ -x "$BIN/hugo" ] && [ "$(hugo_norm "$BIN/hugo")" = "v$HUGO_VERSION+extended" ]; then + echo "hugo v$HUGO_VERSION+extended: present, skipping" +else + echo "hugo v$HUGO_VERSION+extended: downloading..." + curl -fsSL -o "$TMP/h.tgz" \ + "https://github.com/gohugoio/hugo/releases/download/v$HUGO_VERSION/hugo_extended_${HUGO_VERSION}_linux-${HARCH}.tar.gz" + echo "$HUGO_SHA256 $TMP/h.tgz" | sha256sum -c - + tar xzf "$TMP/h.tgz" -C "$TMP" + install -m 0755 "$TMP/hugo" "$BIN/hugo" +fi + +# --- node (+ npm/npx) --- +if [ -x "$BIN/node" ] && [ "$("$BIN/node" --version 2>/dev/null)" = "v$NODE_VERSION" ]; then + echo "node v$NODE_VERSION: present, skipping" +else + echo "node v$NODE_VERSION: downloading..." + curl -fsSL -o "$TMP/n.tar.xz" \ + "https://nodejs.org/dist/v$NODE_VERSION/node-v${NODE_VERSION}-linux-${NARCH}.tar.xz" + rm -rf "$ROOT"/.tools/node-v*-linux-* + tar xJf "$TMP/n.tar.xz" -C "$ROOT/.tools" + for t in node npm npx; do + ln -sfn "../node-v${NODE_VERSION}-linux-${NARCH}/bin/$t" "$BIN/$t" + done +fi + +# --- lychee --- +if [ -x "$BIN/lychee" ] && "$BIN/lychee" --version 2>/dev/null | grep -q "$LYCHEE_VERSION"; then + echo "lychee $LYCHEE_VERSION: present, skipping" +else + echo "lychee $LYCHEE_VERSION: downloading..." + curl -fsSL -o "$TMP/l.tgz" \ + "https://github.com/lycheeverse/lychee/releases/download/lychee-v$LYCHEE_VERSION/lychee-${RARCH}-unknown-linux-musl.tar.gz" + tar xzf "$TMP/l.tgz" -C "$TMP" + install -m 0755 "$TMP/lychee-${RARCH}-unknown-linux-musl/lychee" "$BIN/lychee" +fi + +echo "OK: pinned toolchain ready in $BIN (verify with: make tool-versions)" diff --git a/scripts/tool_versions.sh b/scripts/tool_versions.sh new file mode 100755 index 0000000..43cedeb --- /dev/null +++ b/scripts/tool_versions.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# Print a table of tool versions: pinned (.tools/bin -- what make targets use, +# because the Makefile prepends it to recipe PATH) vs PATH-first (what your own +# shell resolves outside make). +# +# scripts/tool_versions.sh informational; always exits 0 +# scripts/tool_versions.sh --strict exit 1 if a pinned tool is missing or +# has the wrong version (used by `make +# preflight`, which runs before +# all/serve/serve-lan) +set -u +ROOT=$(cd "$(dirname "$0")/.." && pwd) +BIN="$ROOT/.tools/bin" +MODE=${1:-} + +HUGO_PIN="v0.167.0+extended" +NODE_PIN="v22.11.0" +LYCHEE_PIN="0.24.2" + +# PATH with .tools/bin stripped, so "PATH-first" shows what the user's shell +# would resolve, even though make exported .tools/bin into our PATH. +SYSPATH=$(printf '%s' "$PATH" | tr ':' '\n' | grep -vx "$BIN" | tr '\n' ':' | sed 's/:$//') + +pinned_ver() { + case "$1" in + # hugo release builds report "v-<40-hex-commit>+extended"; strip the commit + hugo) [ -x "$BIN/hugo" ] && "$BIN/hugo" version 2>/dev/null | awk '{print $2}' | sed 's/-[0-9a-f]\{40,\}//' ;; + node) [ -x "$BIN/node" ] && "$BIN/node" --version 2>/dev/null ;; + npx) [ -x "$BIN/npx" ] && "$BIN/npx" --version 2>/dev/null ;; + lychee) [ -x "$BIN/lychee" ] && "$BIN/lychee" --version 2>/dev/null | awk '{print $2}' ;; + esac +} + +first_path() { PATH="$SYSPATH" command -v "$1" 2>/dev/null; } + +first_ver() { # first_ver + [ -n "${2:-}" ] || return 0 + case "$1" in + hugo) "$2" version 2>/dev/null | awk '{print $2}' | sed 's/-[0-9a-f]\{40,\}//' ;; + node) "$2" --version 2>/dev/null ;; + npx) "$2" --version 2>/dev/null ;; + lychee) "$2" --version 2>/dev/null | awk '{print $2}' ;; + python3) "$2" --version 2>/dev/null | awk '{print $2}' ;; + esac +} + +if [ "$MODE" = "--strict" ]; then + fail=0 + [ "$(pinned_ver hugo)" = "$HUGO_PIN" ] || { echo "preflight: pinned hugo $HUGO_PIN missing/wrong in $BIN" >&2; fail=1; } + [ "$(pinned_ver node)" = "$NODE_PIN" ] || { echo "preflight: pinned node $NODE_PIN missing/wrong in $BIN" >&2; fail=1; } + [ -n "$(pinned_ver npx)" ] || { echo "preflight: pinned npx missing in $BIN" >&2; fail=1; } + [ "$(pinned_ver lychee)" = "$LYCHEE_PIN" ] || { echo "preflight: pinned lychee $LYCHEE_PIN missing/wrong in $BIN" >&2; fail=1; } + command -v python3 >/dev/null 2>&1 || { echo "preflight: python3 not found on PATH (needed by serve-lan)" >&2; fail=1; } + if [ "$fail" = 1 ]; then + echo "preflight: fix with: make bootstrap" >&2 + exit 1 + fi + echo "preflight: OK (pinned toolchain present in $BIN)" + exit 0 +fi + +row() { # row + printf '%-8s %-27s %-27s %s\n' "$1" "$2" "$3" "${4:--}" +} + +echo "tool pinned (.tools/bin) PATH-first (your shell)" +for t in hugo node npx lychee; do + pv=$(pinned_ver "$t") + fp=$(first_path "$t") + fv=$(first_ver "$t" "$fp") + [ -n "$pv" ] || pv="(missing)" + [ -n "$fv" ] || fv="(not installed)" + row "$t" "$pv" "$fv" "$fp" +done +fp=$(first_path python3); fv=$(first_ver python3 "$fp") +[ -n "$fv" ] || fv="(not installed)" +row "python3" "(not pinned)" "$fv" "$fp" +echo +echo "make targets always use the pinned column. 'make bootstrap' installs it."