@@ -5,6 +5,8 @@ set -euo pipefail
55host=" ${LINK_CHECK_HOST:- 127.0.0.1} "
66port=" ${LINK_CHECK_PORT:- 3000} "
77root_url=" http://${host} :${port} "
8+ sirv_package=" ${SIRV_PACKAGE:- sirv-cli@ 3.0.1} "
9+ linkinator_package=" ${LINKINATOR_PACKAGE:- linkinator@ 7.6.1} "
810tmpdir=" $( mktemp -d) "
911server_pid=" "
1012
@@ -18,6 +20,115 @@ cleanup() {
1820
1921trap cleanup EXIT INT TERM
2022
23+ resolve_npx_bin () {
24+ local package=" $1 "
25+ local bin_name=" $2 "
26+ npx --yes --package " ${package} " bash -lc " readlink -f \"\$ (command -v ${bin_name} )\" "
27+ }
28+
29+ print_linkinator_summary () {
30+ python3 - " ${tmpdir} /linkinator.json" " ${tmpdir} /linkinator.stderr" << 'PY '
31+ import json
32+ import sys
33+ from pathlib import Path
34+
35+ stdout_path = Path(sys.argv[1])
36+ stderr_path = Path(sys.argv[2])
37+ stdout_text = stdout_path.read_text(errors="replace")
38+ stderr_text = stderr_path.read_text(errors="replace").strip()
39+
40+ if not stdout_text.strip():
41+ print("Linkinator did not emit a JSON payload.")
42+ if stderr_text:
43+ print("linkinator stderr:")
44+ print(stderr_text)
45+ sys.exit(0)
46+
47+ try:
48+ payload = json.loads(stdout_text)
49+ except json.JSONDecodeError as exc:
50+ print(f"Failed to parse linkinator JSON output: {exc}")
51+ print("Raw linkinator stdout:")
52+ print(stdout_text.rstrip())
53+ if stderr_text:
54+ print("linkinator stderr:")
55+ print(stderr_text)
56+ sys.exit(0)
57+
58+ links = payload.get("links") or []
59+ problematic = []
60+
61+ for entry in links:
62+ state = str(entry.get("state") or "").upper()
63+ status = entry.get("status")
64+ http_error = status == 0 or (isinstance(status, int) and (status < 200 or status >= 400))
65+ if state in {"OK", "SKIPPED"} and not http_error:
66+ continue
67+ problematic.append(entry)
68+
69+ if problematic:
70+ print(f"Actionable link entries ({len(problematic)} total, showing up to 50):")
71+ for entry in problematic[:50]:
72+ status = entry.get("status", "<none>")
73+ state = entry.get("state", "<none>")
74+ url = entry.get("url") or "<missing url>"
75+ print(f"- status={status} state={state} url={url}")
76+
77+ parent = entry.get("parent")
78+ parents = parent if isinstance(parent, list) else ([parent] if parent else [])
79+ for item in parents[:5]:
80+ print(f" parent={item}")
81+ if len(parents) > 5:
82+ print(f" parent=... {len(parents) - 5} more")
83+
84+ failure = entry.get("failureDetails")
85+ if failure:
86+ if not isinstance(failure, str):
87+ failure = json.dumps(failure, ensure_ascii=False)
88+ print(f" failureDetails={failure}")
89+ else:
90+ print("Linkinator returned a non-zero exit code but no actionable link entries were present in the JSON payload.")
91+ print("Raw linkinator JSON:")
92+ print(json.dumps(payload, ensure_ascii=False))
93+ print("Check the sirv summary below for concrete missing paths.")
94+
95+ if stderr_text:
96+ print("linkinator stderr:")
97+ print(stderr_text)
98+ PY
99+ }
100+
101+ print_sirv_summary () {
102+ python3 - " ${tmpdir} /sirv.log" << 'PY '
103+ import re
104+ import sys
105+ from pathlib import Path
106+
107+ path = Path(sys.argv[1])
108+ pattern = re.compile(r"\[\d{2}:\d{2}:\d{2}\]\s+(\d{3})\s+.*\s+─\s+(.+)$")
109+ offenders = []
110+
111+ for line in path.read_text(errors="replace").splitlines():
112+ match = pattern.search(line)
113+ if not match:
114+ continue
115+ status = int(match.group(1))
116+ if 200 <= status < 400:
117+ continue
118+ offenders.append(line)
119+
120+ if offenders:
121+ print("\n".join(offenders[:100]))
122+ if len(offenders) > 100:
123+ print(f"... truncated {len(offenders) - 100} additional sirv lines")
124+ else:
125+ print("No non-2xx/3xx sirv responses captured.")
126+ PY
127+ }
128+
129+ sirv_bin=" $( resolve_npx_bin " ${sirv_package} " sirv) "
130+ linkinator_cli=" $( resolve_npx_bin " ${linkinator_package} " linkinator) "
131+
21132python3 - " ${host} " > " ${tmpdir} /skip-args.txt" << 'PY '
22133import re
23134import sys
@@ -46,7 +157,7 @@ for external_host in sorted(hosts):
46157 print(f"^https?://{re.escape(external_host)}(?:/|$)")
47158PY
48159
49- npx --yes sirv-cli build -D -H " ${host} " -p " ${port} " > " ${tmpdir} /sirv.log" 2>&1 &
160+ " ${sirv_bin} " build -D -H " ${host} " -p " ${port} " > " ${tmpdir} /sirv.log" 2>&1 &
50161server_pid=$!
51162
52163for _ in $( seq 1 60) ; do
@@ -62,25 +173,26 @@ curl -fsS "${root_url}/" >/dev/null 2>&1 || {
62173 exit 1
63174}
64175
65- tmp_linkinator=" ${tmpdir} /linkinator.log"
66176mapfile -t skip_args < " ${tmpdir} /skip-args.txt"
67177
68178set +e
69- # Call linkinator directly so its real exit code and stderr survive failures.
70- npx --yes linkinator " ${root_url} " \
179+ # The human-readable formatter can fail without surfacing the offender URL under Node 20.
180+ # Use the direct CLI + JSON path and print our own summary instead.
181+ node " ${linkinator_cli} " " ${root_url} " \
71182 --recurse \
72183 --concurrency 10 \
73184 --retry-errors \
74185 --retry-errors-count 2 \
75- --verbosity error \
76- " ${skip_args[@]} " > " ${tmp_linkinator} " 2>&1
186+ --verbosity none \
187+ --format json \
188+ " ${skip_args[@]} " > " ${tmpdir} /linkinator.json" 2> " ${tmpdir} /linkinator.stderr"
77189linkinator_exit=$?
78190set -e
79191
80192if [[ " ${linkinator_exit} " -ne 0 ]]; then
81- echo " Linkinator failed with exit code ${linkinator_exit} . Output :"
82- cat " ${tmp_linkinator} "
83- echo " End of linkinator output . sirv log :"
84- cat " ${tmpdir} /sirv.log "
193+ echo " Linkinator failed with exit code ${linkinator_exit} . Summary :"
194+ print_linkinator_summary
195+ echo " End of linkinator summary . sirv non-2xx/3xx responses :"
196+ print_sirv_summary
85197 exit " ${linkinator_exit} "
86198fi
0 commit comments