Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 122 additions & 10 deletions scripts/check-links.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ set -euo pipefail
host="${LINK_CHECK_HOST:-127.0.0.1}"
port="${LINK_CHECK_PORT:-3000}"
root_url="http://${host}:${port}"
sirv_package="${SIRV_PACKAGE:-sirv-cli@3.0.1}"
linkinator_package="${LINKINATOR_PACKAGE:-linkinator@7.6.1}"
tmpdir="$(mktemp -d)"
server_pid=""

Expand All @@ -18,6 +20,115 @@ cleanup() {

trap cleanup EXIT INT TERM

resolve_npx_bin() {
local package="$1"
local bin_name="$2"
npx --yes --package "${package}" bash -lc "readlink -f \"\$(command -v ${bin_name})\""
}
Comment on lines +23 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the login shell adds stray stdout / mutates PATH in this environment.
bash -lc 'echo "PATH=$PATH"; command -v readlink' 2>&1 | head -n 20
# Check readlink -f availability/behavior
readlink -f "$(command -v bash)" || echo "readlink -f unsupported"

Repository: Project-HAMi/website

Length of output: 351


🏁 Script executed:

#!/bin/bash
sed -n '1,120p' scripts/check-links.sh

Repository: Project-HAMi/website

Length of output: 3654


🏁 Script executed:

#!/bin/bash
rg -n "resolve_npx_bin|sirv_bin|linkinator_bin|npx --yes --package|readlink -f" scripts/check-links.sh

Repository: Project-HAMi/website

Length of output: 488


🏁 Script executed:

#!/bin/bash
rg -n '"check-links"|scripts/check-links.sh|sirv-cli|linkinator' package.json .github scripts -g '!**/node_modules/**'

Repository: Project-HAMi/website

Length of output: 1756


🏁 Script executed:

#!/bin/bash
rg -n '"check-links"|scripts/check-links.sh|bash -lc|readlink -f|realpath' package.json README.md scripts -g '!**/node_modules/**'

Repository: Project-HAMi/website

Length of output: 398


Use a portable path resolver
readlink -f is GNU-only, so resolve_npx_bin() will fail on macOS/BSD. Switch to a cross-platform way to resolve the package binary before relying on this in local npm run check-links.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check-links.sh` around lines 23 - 27, The resolve_npx_bin() helper
currently relies on readlink -f, which is GNU-specific and breaks on macOS/BSD.
Update resolve_npx_bin() to use a cross-platform binary path resolution approach
before returning the package bin path, keeping the existing npx --yes --package
flow and command -v lookup intact. Make the change within resolve_npx_bin() so
local npm run check-links works consistently across environments.


print_linkinator_summary() {
python3 - "${tmpdir}/linkinator.json" "${tmpdir}/linkinator.stderr" <<'PY'
import json
import sys
from pathlib import Path

stdout_path = Path(sys.argv[1])
stderr_path = Path(sys.argv[2])
stdout_text = stdout_path.read_text(errors="replace")
stderr_text = stderr_path.read_text(errors="replace").strip()

if not stdout_text.strip():
print("Linkinator did not emit a JSON payload.")
if stderr_text:
print("linkinator stderr:")
print(stderr_text)
sys.exit(0)

try:
payload = json.loads(stdout_text)
except json.JSONDecodeError as exc:
print(f"Failed to parse linkinator JSON output: {exc}")
print("Raw linkinator stdout:")
print(stdout_text.rstrip())
if stderr_text:
print("linkinator stderr:")
print(stderr_text)
sys.exit(0)

links = payload.get("links") or []
problematic = []

for entry in links:
state = str(entry.get("state") or "").upper()
status = entry.get("status")
http_error = status == 0 or (isinstance(status, int) and (status < 200 or status >= 400))
if state in {"OK", "SKIPPED"} and not http_error:
continue
problematic.append(entry)

if problematic:
print(f"Actionable link entries ({len(problematic)} total, showing up to 50):")
for entry in problematic[:50]:
status = entry.get("status", "<none>")
state = entry.get("state", "<none>")
url = entry.get("url") or "<missing url>"
print(f"- status={status} state={state} url={url}")

parent = entry.get("parent")
parents = parent if isinstance(parent, list) else ([parent] if parent else [])
for item in parents[:5]:
print(f" parent={item}")
if len(parents) > 5:
print(f" parent=... {len(parents) - 5} more")

failure = entry.get("failureDetails")
if failure:
if not isinstance(failure, str):
failure = json.dumps(failure, ensure_ascii=False)
print(f" failureDetails={failure}")
else:
print("Linkinator returned a non-zero exit code but no actionable link entries were present in the JSON payload.")
print("Raw linkinator JSON:")
print(json.dumps(payload, ensure_ascii=False))
print("Check the sirv summary below for concrete missing paths.")

if stderr_text:
print("linkinator stderr:")
print(stderr_text)
PY
}

print_sirv_summary() {
python3 - "${tmpdir}/sirv.log" <<'PY'
import re
import sys
from pathlib import Path

path = Path(sys.argv[1])
pattern = re.compile(r"\[\d{2}:\d{2}:\d{2}\]\s+(\d{3})\s+.*\s+─\s+(.+)$")
offenders = []

for line in path.read_text(errors="replace").splitlines():
match = pattern.search(line)
if not match:
continue
status = int(match.group(1))
if 200 <= status < 400:
continue
offenders.append(line)

if offenders:
print("\n".join(offenders[:100]))
if len(offenders) > 100:
print(f"... truncated {len(offenders) - 100} additional sirv lines")
else:
print("No non-2xx/3xx sirv responses captured.")
PY
}

sirv_bin="$(resolve_npx_bin "${sirv_package}" sirv)"
linkinator_cli="$(resolve_npx_bin "${linkinator_package}" linkinator)"

python3 - "${host}" > "${tmpdir}/skip-args.txt" <<'PY'
import re
import sys
Expand Down Expand Up @@ -46,7 +157,7 @@ for external_host in sorted(hosts):
print(f"^https?://{re.escape(external_host)}(?:/|$)")
PY

npx --yes sirv-cli build -D -H "${host}" -p "${port}" > "${tmpdir}/sirv.log" 2>&1 &
"${sirv_bin}" build -D -H "${host}" -p "${port}" > "${tmpdir}/sirv.log" 2>&1 &
server_pid=$!

for _ in $(seq 1 60); do
Expand All @@ -62,25 +173,26 @@ curl -fsS "${root_url}/" >/dev/null 2>&1 || {
exit 1
}

tmp_linkinator="${tmpdir}/linkinator.log"
mapfile -t skip_args < "${tmpdir}/skip-args.txt"

set +e
# Call linkinator directly so its real exit code and stderr survive failures.
npx --yes linkinator "${root_url}" \
# The human-readable formatter can fail without surfacing the offender URL under Node 20.
# Use the direct CLI + JSON path and print our own summary instead.
node "${linkinator_cli}" "${root_url}" \
--recurse \
--concurrency 10 \
--retry-errors \
--retry-errors-count 2 \
--verbosity error \
"${skip_args[@]}" > "${tmp_linkinator}" 2>&1
--verbosity none \
--format json \
"${skip_args[@]}" > "${tmpdir}/linkinator.json" 2> "${tmpdir}/linkinator.stderr"
linkinator_exit=$?
set -e

if [[ "${linkinator_exit}" -ne 0 ]]; then
echo "Linkinator failed with exit code ${linkinator_exit}. Output:"
cat "${tmp_linkinator}"
echo "End of linkinator output. sirv log:"
cat "${tmpdir}/sirv.log"
echo "Linkinator failed with exit code ${linkinator_exit}. Summary:"
print_linkinator_summary
echo "End of linkinator summary. sirv non-2xx/3xx responses:"
print_sirv_summary
exit "${linkinator_exit}"
fi