Skip to content

Commit 140a088

Browse files
committed
Rewrite re-exported type links to original defining package
Haddock resolves re-exports to the local re-export page (e.g. a reference to cardano-ledger-byron's Address via Cardano.Api.Byron becomes href="Cardano-Api-Byron.html#t:Address", not an external link). The Haddock-emitted "Source" link, however, points to the cabal store path for the original defining package. Phase 2b uses that as ground truth to rewrite local re-export hrefs to the upstream doc site. Also extend dead-link handling in Phase 3: before annotating a 404 as unclickable, probe for the page under doc-site subdirectories (api/, protocols/, framework/ — ouroboros-network's layout) and under parent modules (Cardano-Ledger-Api-State-Query-CommitteeMembersState.html -> Cardano-Ledger-Api-State-Query.html#t:CommitteeMembersState). If the parent resolves, rewrite to it with a reconstructed #t: fragment so the browser still scrolls to the definition. On a full cardano-api build this rewrites 205 of 208 re-exported types (up from 0) and rescues 182 of 233 previously-annotated dead links.
1 parent 0cc7cf1 commit 140a088

1 file changed

Lines changed: 266 additions & 14 deletions

File tree

scripts/fix-haddock-links.sh

Lines changed: 266 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,16 @@
1313
# 2. Resolve + rewrite: for each discovered target, probe candidate
1414
# doc-site URLs and rewrite HTML links in place (or mark unmapped
1515
# CHaP packages as unclickable).
16-
# 3. Validate + annotate: HEAD each rewritten URL; replace dead ones
17-
# with annotated plain-text spans so there are zero clickable 404s.
16+
# 2b. Re-export rewrite: extract (type, defining package, module) triples
17+
# from each local page's Haddock-emitted "Source" cabal-store link,
18+
# then rewrite the local re-export hrefs (e.g. a reference to
19+
# cardano-ledger-byron's `Address` via `Cardano.Api.Byron`) to point
20+
# at the upstream doc site instead of the local re-export page.
21+
# 3. Validate + annotate: HEAD each rewritten URL; for dead URLs try to
22+
# rescue by probing doc-site subdirectories (api/, protocols/,
23+
# framework/) and parent modules with #t: fragment reconstruction.
24+
# URLs that can't be rescued become annotated plain-text spans so
25+
# there are zero clickable 404s.
1826
#
1927
# When dead links are found (unmapped CHaP package or module-level 404),
2028
# the script prints an actionable summary — upstream module name, symbols
@@ -31,8 +39,14 @@ set -euo pipefail
3139
WEBSITE_DIR="${1:?Usage: $0 <website-directory>}"
3240
[ -d "$WEBSITE_DIR" ] || { echo "Error: $WEBSITE_DIR is not a directory" >&2; exit 1; }
3341

34-
CHAP_PKGS_FILE="" URLS_FILE="" DEAD_URLS_FILE="" REWRITE_SCRIPT="" DEAD_SCRIPT=""
35-
trap 'rm -f "$CHAP_PKGS_FILE" "$URLS_FILE" "$DEAD_URLS_FILE" "$REWRITE_SCRIPT" "$DEAD_SCRIPT"' EXIT
42+
TMPFILES=()
43+
trap 'rm -f "${TMPFILES[@]}"' EXIT
44+
45+
mktemp_tracked() {
46+
local t; t=$(mktemp)
47+
TMPFILES+=("$t")
48+
printf '%s\n' "$t"
49+
}
3650

3751
# ============================================================================
3852
# Configuration and helpers
@@ -62,6 +76,13 @@ IOG_DOC_BASES=(
6276
"https://input-output-hk.github.io/io-sim"
6377
)
6478

79+
# Some doc sites organise module pages into subdirectories beneath the
80+
# package root (e.g. ouroboros-network publishes Ouroboros-Network-Magic.html
81+
# under ouroboros-network/api/, not ouroboros-network/ directly). When a
82+
# module page URL 404s at the flat path, the rescue logic retries under
83+
# each of these subdirectories.
84+
DOC_SUBDIRS=(api protocols framework)
85+
6586
derive_name_candidates() {
6687
local name="$1"
6788
while true; do
@@ -135,7 +156,7 @@ for dir in "$WEBSITE_DIR"/*/; do
135156
done
136157

137158
# Fetch CHaP index
138-
CHAP_PKGS_FILE=$(mktemp)
159+
CHAP_PKGS_FILE=$(mktemp_tracked)
139160
curl -sL https://chap.intersectmbo.org/01-index.tar.gz \
140161
| tar -tz | grep -oP '^[^/]+' | sort -u > "$CHAP_PKGS_FILE"
141162
declare -A CHAP_SET
@@ -172,9 +193,10 @@ echo " Symlinks created: $symlink_count"
172193

173194
echo "Phase 2: Resolving doc sites and rewriting links..."
174195

175-
REWRITE_SCRIPT=$(mktemp)
196+
REWRITE_SCRIPT=$(mktemp_tracked)
176197
UNMAPPED_CHAP=() # CHaP packages we couldn't resolve to a doc site — gap in config
177198
NON_CHAP=() # non-CHaP packages (bootlibs etc.) we don't bother linking to
199+
declare -A PKG_URL=() # cached doc-site base URL per package (reused in Phase 2b)
178200
skipped=0 rewrote_docsite=0
179201

180202
while IFS= read -r pkg; do
@@ -198,6 +220,7 @@ while IFS= read -r pkg; do
198220

199221
is_chap="no"; [[ -v "CHAP_SET[$lookup]" ]] && is_chap="yes"
200222
url=$(resolve_url "$lookup" "$is_chap")
223+
PKG_URL[$lookup]="$url"
201224

202225
if [[ "$url" == "NONE" ]]; then
203226
UNMAPPED_CHAP+=("$lookup")
@@ -236,14 +259,149 @@ if [[ -s "$REWRITE_SCRIPT" ]]; then
236259
find "$WEBSITE_DIR" -name '*.html' -print0 | xargs -0 -P "$(nproc)" sed -i -f "$REWRITE_SCRIPT"
237260
fi
238261

262+
# ============================================================================
263+
# Phase 2b: Rewrite re-exported type links to the original defining package
264+
# ============================================================================
265+
#
266+
# Haddock resolves re-exports to the LOCAL re-export page (e.g. a reference
267+
# to cardano-ledger-byron's `Address` via `Cardano.Api.Byron` becomes
268+
# href="Cardano-Api-Byron.html#t:Address", not an external link). The
269+
# Haddock-emitted "Source" link, however, points to the cabal store for the
270+
# original defining package (file:///.../.cabal/store/PKG-VERSION/.../src/
271+
# Module.Name.html). We use that as the source of truth to rewrite these
272+
# local re-export hrefs to point at the upstream doc site.
273+
274+
echo "Phase 2b: Rewriting re-exported type links..."
275+
276+
REEXPORTS_FILE=$(mktemp_tracked)
277+
python3 - "$WEBSITE_DIR" <<'PYEOF' > "$REEXPORTS_FILE"
278+
import os, re, sys
279+
website = sys.argv[1]
280+
pattern = re.compile(
281+
r'id="(t|v):([^"]+)" class="def">[^<]*</a>\s*<a href="file:///[^"]*?/([a-zA-Z][\w-]*)-\d+\.[^/]*/share/doc/html/src/([^"#]+)\.html'
282+
)
283+
for d in os.listdir(website):
284+
if not d.endswith('-inplace') or not os.path.isdir(os.path.join(website, d)):
285+
continue
286+
dir_path = os.path.join(website, d)
287+
for f in os.listdir(dir_path):
288+
if not f.endswith('.html'):
289+
continue
290+
html = open(os.path.join(dir_path, f)).read()
291+
for m in pattern.finditer(html):
292+
atype, name, pkg, source_module = m.groups()
293+
module_page = source_module.replace('.', '-') + '.html'
294+
# Tab-separated: local_page, anchor, name, package, module_page
295+
print('\t'.join([f, atype, name, pkg, module_page]))
296+
PYEOF
297+
298+
reexport_total=$(wc -l < "$REEXPORTS_FILE" | tr -d ' ')
299+
echo " Re-exports found: $reexport_total"
300+
301+
reexport_rewritten=0
302+
reexport_unresolvable=0
303+
if [[ $reexport_total -gt 0 ]]; then
304+
# Build candidate URLs: direct, then subdirs, then parent-stripped (with subdirs).
305+
# Format: local_page \t anchor \t name \t candidate_url
306+
REEXPORT_CANDIDATES=$(mktemp_tracked)
307+
while IFS=$'\t' read -r local_page anchor name pkg module_page; do
308+
[ -z "$local_page" ] && continue
309+
310+
# Resolve package base URL via the Phase 2 cache (fall back to resolve_url
311+
# for packages not seen as cross-package targets in the HTML).
312+
if [[ -v "PKG_URL[$pkg]" ]]; then
313+
base="${PKG_URL[$pkg]}"
314+
else
315+
is_chap="no"; [[ -v "CHAP_SET[$pkg]" ]] && is_chap="yes"
316+
base=$(resolve_url "$pkg" "$is_chap")
317+
PKG_URL[$pkg]="$base"
318+
fi
319+
320+
# Skip unresolvable (NONE) and non-CHaP (HACKAGE — we don't link there).
321+
[[ "$base" == "NONE" || "$base" == "HACKAGE" ]] && continue
322+
323+
# Direct URL
324+
echo "${local_page} ${anchor} ${name} ${base}/${pkg}/${module_page}" >> "$REEXPORT_CANDIDATES"
325+
326+
# Subdirectory variants (ouroboros-network layout)
327+
for subdir in "${DOC_SUBDIRS[@]}"; do
328+
echo "${local_page} ${anchor} ${name} ${base}/${pkg}/${subdir}/${module_page}" >> "$REEXPORT_CANDIDATES"
329+
done
330+
331+
# Progressively stripped parent modules (+ subdir variants)
332+
module_base="${module_page%.html}"
333+
while [[ "$module_base" == *-* ]]; do
334+
module_base="${module_base%-*}"
335+
echo "${local_page} ${anchor} ${name} ${base}/${pkg}/${module_base}.html" >> "$REEXPORT_CANDIDATES"
336+
for subdir in "${DOC_SUBDIRS[@]}"; do
337+
echo "${local_page} ${anchor} ${name} ${base}/${pkg}/${subdir}/${module_base}.html" >> "$REEXPORT_CANDIDATES"
338+
done
339+
done
340+
done < "$REEXPORTS_FILE"
341+
342+
# Probe all unique candidate URLs in parallel
343+
REEXPORT_VALID_FILE=$(mktemp_tracked)
344+
if [[ -s "$REEXPORT_CANDIDATES" ]]; then
345+
# shellcheck disable=SC2016
346+
awk -F'\t' '{print $4}' "$REEXPORT_CANDIDATES" | sort -u | \
347+
xargs -P 16 -I{} sh -c \
348+
'code=$(curl -sI -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 "{}"); if [ "$code" = "200" ] || [ "$code" = "301" ] || [ "$code" = "302" ]; then echo "{}"; fi' \
349+
> "$REEXPORT_VALID_FILE" 2>/dev/null
350+
fi
351+
352+
declare -A REEXPORT_VALID_SET=()
353+
while IFS= read -r url; do
354+
[ -z "$url" ] && continue
355+
REEXPORT_VALID_SET["$url"]=1
356+
done < "$REEXPORT_VALID_FILE"
357+
358+
# For each re-export, walk its candidates in order and pick the first
359+
# that validated. First-hit-wins prefers the direct URL over subdir /
360+
# parent fallbacks.
361+
declare -A REEXPORT_RESOLVED=() # "local_page|anchor|name" -> resolved_url
362+
while IFS=$'\t' read -r local_page anchor name candidate; do
363+
[ -z "$local_page" ] && continue
364+
key="${local_page}|${anchor}|${name}"
365+
[[ -v "REEXPORT_RESOLVED[$key]" ]] && continue
366+
if [[ -v "REEXPORT_VALID_SET[$candidate]" ]]; then
367+
REEXPORT_RESOLVED["$key"]="$candidate"
368+
fi
369+
done < "$REEXPORT_CANDIDATES"
370+
371+
# Build sed rewrite rules. We only rewrite the exact
372+
# href="LOCAL_PAGE#ANCHOR:NAME" that we've proven is a re-export of a
373+
# type documented at a reachable upstream URL; the LOCAL_PAGE#ANCHOR:NAME
374+
# for local-only types (e.g. cardano-api's own Address GADT) is left
375+
# untouched.
376+
REEXPORT_SCRIPT=$(mktemp_tracked)
377+
for key in "${!REEXPORT_RESOLVED[@]}"; do
378+
IFS='|' read -r local_page anchor name <<< "$key"
379+
resolved="${REEXPORT_RESOLVED[$key]}"
380+
escaped_local=$(printf '%s' "$local_page" | sed 's|[&/\]|\\&|g; s|\.|\\.|g')
381+
escaped_resolved=$(printf '%s' "$resolved" | sed 's|[&/\]|\\&|g')
382+
printf 's|href="%s#%s:%s"|href="%s#%s:%s"|g\n' \
383+
"$escaped_local" "$anchor" "$name" "$escaped_resolved" "$anchor" "$name" \
384+
>> "$REEXPORT_SCRIPT"
385+
reexport_rewritten=$((reexport_rewritten + 1))
386+
done
387+
reexport_unresolvable=$((reexport_total - reexport_rewritten))
388+
389+
if [[ -s "$REEXPORT_SCRIPT" ]]; then
390+
find "$WEBSITE_DIR" -name '*.html' -print0 | xargs -0 -P "$(nproc)" sed -i -f "$REEXPORT_SCRIPT"
391+
fi
392+
fi
393+
394+
echo " Rewritten: $reexport_rewritten"
395+
echo " Unresolvable: $reexport_unresolvable"
396+
239397
# ============================================================================
240398
# Phase 3: Validate external URLs and annotate dead ones
241399
# ============================================================================
242400

243401
echo "Phase 3: Validating external URLs..."
244402

245-
URLS_FILE=$(mktemp)
246-
DEAD_URLS_FILE=$(mktemp)
403+
URLS_FILE=$(mktemp_tracked)
404+
DEAD_URLS_FILE=$(mktemp_tracked)
247405
grep -rohP 'href="\Khttps://[^"#]+\.html' "$WEBSITE_DIR" 2>/dev/null | sort -u > "$URLS_FILE"
248406
url_count=$(wc -l < "$URLS_FILE")
249407

@@ -255,18 +413,107 @@ if [[ $url_count -gt 0 ]]; then
255413
fi
256414
dead_count=$(wc -l < "$DEAD_URLS_FILE" | tr -d ' ')
257415

416+
# Phase 3a: rescue dead URLs by probing doc-site subdirectories (e.g.
417+
# ouroboros-network/api/) and parent modules. Haddock frequently links to
418+
# internal defining modules that don't publish as standalone pages — the
419+
# type is actually documented on a parent module (stripping the last
420+
# dash-separated segment). When we find a valid parent, we rewrite the
421+
# link to <parent>#t:<LastComponent> so the browser still scrolls to the
422+
# type definition instead of showing a greyed-out span.
423+
declare -A DEAD_TO_RESCUE=() DEAD_TO_FRAGMENT=()
424+
rescued_count=0
425+
if [[ $dead_count -gt 0 ]]; then
426+
RESCUE_CANDIDATES=$(mktemp_tracked)
427+
while IFS= read -r dead_url; do
428+
[ -z "$dead_url" ] && continue
429+
base_path="${dead_url%/*}"
430+
module_file=$(basename "$dead_url" .html)
431+
432+
# Subdirectory variants at the same level. Use the last dash-component
433+
# of the module name as the fragment suffix — harmless if the browser
434+
# doesn't find it, useful for links emitted without a #fragment.
435+
stripped_first="${module_file##*-}"
436+
for subdir in "${DOC_SUBDIRS[@]}"; do
437+
echo "${dead_url} ${base_path}/${subdir}/${module_file}.html ${stripped_first}" >> "$RESCUE_CANDIDATES"
438+
done
439+
440+
# Progressively stripped parent modules (+ subdir variants).
441+
remaining="$module_file"
442+
parent_stripped_first=""
443+
while [[ "$remaining" == *-* ]]; do
444+
last="${remaining##*-}"
445+
remaining="${remaining%-*}"
446+
[[ -z "$parent_stripped_first" ]] && parent_stripped_first="$last"
447+
echo "${dead_url} ${base_path}/${remaining}.html ${parent_stripped_first}" >> "$RESCUE_CANDIDATES"
448+
for subdir in "${DOC_SUBDIRS[@]}"; do
449+
echo "${dead_url} ${base_path}/${subdir}/${remaining}.html ${parent_stripped_first}" >> "$RESCUE_CANDIDATES"
450+
done
451+
done
452+
done < "$DEAD_URLS_FILE"
453+
454+
RESCUE_VALID_FILE=$(mktemp_tracked)
455+
if [[ -s "$RESCUE_CANDIDATES" ]]; then
456+
# shellcheck disable=SC2016
457+
awk -F'\t' '{print $2}' "$RESCUE_CANDIDATES" | sort -u | \
458+
xargs -P 16 -I{} sh -c \
459+
'code=$(curl -sI -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 "{}"); if [ "$code" = "200" ] || [ "$code" = "301" ] || [ "$code" = "302" ]; then echo "{}"; fi' \
460+
> "$RESCUE_VALID_FILE" 2>/dev/null
461+
fi
462+
463+
declare -A RESCUE_VALID_SET=()
464+
while IFS= read -r url; do
465+
[ -z "$url" ] && continue
466+
RESCUE_VALID_SET["$url"]=1
467+
done < "$RESCUE_VALID_FILE"
468+
469+
# First-match-wins: direct subdir hit preferred over deeper parents.
470+
while IFS=$'\t' read -r dead cand stripped; do
471+
[ -z "$dead" ] && continue
472+
[[ -v "DEAD_TO_RESCUE[$dead]" ]] && continue
473+
if [[ -v "RESCUE_VALID_SET[$cand]" ]]; then
474+
DEAD_TO_RESCUE["$dead"]="$cand"
475+
DEAD_TO_FRAGMENT["$dead"]="$stripped"
476+
fi
477+
done < "$RESCUE_CANDIDATES"
478+
479+
rescued_count=${#DEAD_TO_RESCUE[@]}
480+
481+
if [[ $rescued_count -gt 0 ]]; then
482+
RESCUE_SCRIPT=$(mktemp_tracked)
483+
for dead in "${!DEAD_TO_RESCUE[@]}"; do
484+
rescue="${DEAD_TO_RESCUE[$dead]}"
485+
stripped="${DEAD_TO_FRAGMENT[$dead]}"
486+
escaped_dead=$(printf '%s' "$dead" | sed 's|[&/\]|\\&|g; s|\.|\\.|g')
487+
escaped_rescue=$(printf '%s' "$rescue" | sed 's|[&/\]|\\&|g')
488+
# href="DEAD" -> href="RESCUE#t:<stripped>" (bare URL, add fragment)
489+
# href="DEAD#frag" -> href="RESCUE#frag" (preserve fragment)
490+
printf 's|href="%s"|href="%s#t:%s"|g\n' \
491+
"$escaped_dead" "$escaped_rescue" "$stripped" >> "$RESCUE_SCRIPT"
492+
printf 's|href="%s#|href="%s#|g\n' \
493+
"$escaped_dead" "$escaped_rescue" >> "$RESCUE_SCRIPT"
494+
done
495+
find "$WEBSITE_DIR" -name '*.html' -print0 | xargs -0 -P "$(nproc)" sed -i -f "$RESCUE_SCRIPT"
496+
fi
497+
fi
498+
499+
dead_remaining=$((dead_count - rescued_count))
500+
258501
echo " URLs checked: $url_count"
259502
echo " Valid: $((url_count - dead_count))"
260503
echo " Dead: $dead_count"
504+
echo " Rescued: $rescued_count"
505+
echo " Unrescuable: $dead_remaining"
261506

262507
# Per-URL context tables, populated BEFORE the <a>→<span> replacement so
263508
# the original anchor text and reference sites are still in the HTML.
264509
declare -A DEAD_PKG DEAD_MODULE DEAD_SYMBOLS DEAD_REFS
265510

266-
DEAD_SCRIPT=$(mktemp)
267-
if [[ $dead_count -gt 0 ]]; then
511+
DEAD_SCRIPT=$(mktemp_tracked)
512+
if [[ $dead_remaining -gt 0 ]]; then
268513
while IFS= read -r dead_url; do
269514
[ -z "$dead_url" ] && continue
515+
# Rescued URLs have already been rewritten; no unclickable annotation.
516+
[[ -v "DEAD_TO_RESCUE[$dead_url]" ]] && continue
270517

271518
# Package + Haskell module name (Haddock encodes dots as dashes)
272519
if [[ "$dead_url" == *"hackage.haskell.org"* ]]; then
@@ -313,13 +560,16 @@ echo "=== fix-haddock-links summary ==="
313560
echo " Discovered: $discovered_total"
314561
echo " Local (skipped): $skipped"
315562
echo " Rewritten (doc site): $rewrote_docsite"
563+
echo " Re-exports rewritten: $reexport_rewritten"
564+
echo " Re-exports unresolved: $reexport_unresolvable"
316565
echo " Non-CHaP (unclickable): ${#NON_CHAP[@]}"
317566
echo " Unmapped CHaP: ${#UNMAPPED_CHAP[@]}"
318567
echo " URLs validated: $url_count"
319-
echo " Dead links annotated: $dead_count"
568+
echo " Dead links rescued: $rescued_count"
569+
echo " Dead links annotated: $dead_remaining"
320570
echo "================================="
321571

322-
total_dead=$(( ${#UNMAPPED_CHAP[@]} + dead_count ))
572+
total_dead=$(( ${#UNMAPPED_CHAP[@]} + dead_remaining ))
323573
if [[ $total_dead -eq 0 ]]; then
324574
exit 0
325575
fi
@@ -341,11 +591,13 @@ if [[ ${#UNMAPPED_CHAP[@]} -gt 0 ]]; then
341591
echo " to IOG_DOC_BASES in scripts/fix-haddock-links.sh and re-run."
342592
fi
343593

344-
if [[ $dead_count -gt 0 ]]; then
594+
if [[ $dead_remaining -gt 0 ]]; then
345595
echo ""
346-
echo "Module-level 404s (${dead_count}):"
596+
echo "Module-level 404s (${dead_remaining}):"
347597
while IFS= read -r dead_url; do
348598
[ -z "$dead_url" ] && continue
599+
# Skip rescued URLs — they were rewritten, not annotated.
600+
[[ -v "DEAD_TO_RESCUE[$dead_url]" ]] && continue
349601
echo ""
350602
echo " $dead_url"
351603
echo " Upstream: ${DEAD_MODULE[$dead_url]} (in package ${DEAD_PKG[$dead_url]})"

0 commit comments

Comments
 (0)