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
3139WEBSITE_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+
6586derive_name_candidates () {
6687 local name=" $1 "
6788 while true ; do
@@ -135,7 +156,7 @@ for dir in "$WEBSITE_DIR"/*/; do
135156done
136157
137158# Fetch CHaP index
138- CHAP_PKGS_FILE=$( mktemp )
159+ CHAP_PKGS_FILE=$( mktemp_tracked )
139160curl -sL https://chap.intersectmbo.org/01-index.tar.gz \
140161 | tar -tz | grep -oP ' ^[^/]+' | sort -u > " $CHAP_PKGS_FILE "
141162declare -A CHAP_SET
@@ -172,9 +193,10 @@ echo " Symlinks created: $symlink_count"
172193
173194echo " Phase 2: Resolving doc sites and rewriting links..."
174195
175- REWRITE_SCRIPT=$( mktemp )
196+ REWRITE_SCRIPT=$( mktemp_tracked )
176197UNMAPPED_CHAP=() # CHaP packages we couldn't resolve to a doc site — gap in config
177198NON_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)
178200skipped=0 rewrote_docsite=0
179201
180202while 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 "
237260fi
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
243401echo " 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 )
247405grep -rohP ' href="\Khttps://[^"#]+\.html' " $WEBSITE_DIR " 2> /dev/null | sort -u > " $URLS_FILE "
248406url_count=$( wc -l < " $URLS_FILE " )
249407
@@ -255,18 +413,107 @@ if [[ $url_count -gt 0 ]]; then
255413fi
256414dead_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+
258501echo " URLs checked: $url_count "
259502echo " Valid: $(( url_count - dead_count)) "
260503echo " 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.
264509declare -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 ==="
313560echo " Discovered: $discovered_total "
314561echo " Local (skipped): $skipped "
315562echo " Rewritten (doc site): $rewrote_docsite "
563+ echo " Re-exports rewritten: $reexport_rewritten "
564+ echo " Re-exports unresolved: $reexport_unresolvable "
316565echo " Non-CHaP (unclickable): ${# NON_CHAP[@]} "
317566echo " Unmapped CHaP: ${# UNMAPPED_CHAP[@]} "
318567echo " 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 "
320570echo " ================================="
321571
322- total_dead=$(( ${# UNMAPPED_CHAP[@]} + dead_count ))
572+ total_dead=$(( ${# UNMAPPED_CHAP[@]} + dead_remaining ))
323573if [[ $total_dead -eq 0 ]]; then
324574 exit 0
325575fi
@@ -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."
342592fi
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