Files
noether 2d8f79b7fd build: replace ad-hoc run with a full make workflow + helper scripts
The old Makefile was a single 'run' target (hugo server + pagefind --serve).
Replace it with documented targets:

  build         hugo --gc -> public/
  index         build + pagefind --site public -> public/pagefind/
  serve / run   index + pagefind's own server on 127.0.0.1:1414 (local)
  serve-lan     static-serve public/ on 0.0.0.0:8090 for other machines
  link-check    offline internal-link scan (lychee --scheme file) -> JSON
  link-fix      comment out confirmed-dead internal links, then rebuild
  external-check network scan of external links (report-only)
  clean / all

Add scripts/:
  serve_lan.py            stdlib static server rooted at public/ (prints LAN IP)
  fix_dead_links.py       idempotent internal dead-link comment-out
  check_external_links.py external link report (drives the daily cron job)

.gitignore: add linkcheck/, resources/_gen/ (regenerable Hugo asset cache),
__pycache__/, and .hermes/.
2026-10-11 08:58:50 -06:00

101 lines
4.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Comment out confirmed dead INTERNAL page links, report external/bare-domain failures.
Reads lychee's JSON report (linkcheck/internal.json). A link is auto-commented ONLY
if lychee flagged its file:// path as a missing file under the built site (public/).
External https:// failures and bare-domain failures are written to a manual-review
report and left untouched. Idempotent: already-commented links are not detected by
lychee, so re-runs are safe.
IMPORTANT: run lychee with the SOURCE markdown as input (e.g. `lychee --root-dir public
--default-extension html content/*.md`) so the error_map keys are the markdown files
we actually edit. They are relative to the repo root.
Usage:
python3 scripts/fix_dead_links.py --report linkcheck/internal.json # dry-run
python3 scripts/fix_dead_links.py --report linkcheck/internal.json --apply # edit
"""
import argparse
import json
import re
from pathlib import Path
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--report", required=True)
ap.add_argument("--apply", action="store_true")
ap.add_argument("--public-dir", default="public",
help="built site dir that lychee's --root-dir pointed at")
args = ap.parse_args()
data = json.loads(Path(args.report).read_text())
err_map = data.get("error_map", {})
to_comment = [] # (md_file_relative, line, dest)
manual = [] # (md_file_relative, line, url, status_text)
for md_file, items in err_map.items():
for it in items:
url = it.get("url", "")
status = it.get("status", {}).get("text", "")
span = it.get("span", {})
if url.startswith("file://"):
after = url[len("file://"):]
m = re.search(r"/" + re.escape(args.public_dir) + r"/(.*)$", after)
if m:
to_comment.append((md_file, span.get("line"), "/" + m.group(1)))
continue
manual.append((md_file, span.get("line"), url, status))
# Group by source file (error_map keys are relative to repo root).
by_file = {}
for md_file, line, dest in to_comment:
by_file.setdefault(md_file, []).append((line, dest))
for md_file in by_file:
by_file[md_file].sort(reverse=True) # edit bottom-up; keep line count stable
plan = 0
for md_file, edits in sorted(by_file.items()):
p = Path(md_file)
if not p.exists():
print(f" ! missing source, skipping: {p}")
continue
lines = p.read_text().splitlines(keepends=True)
changed = False
for line_no, dest in edits:
if not line_no:
continue
idx = line_no - 1
if idx < 0 or idx >= len(lines):
continue
cur = lines[idx]
# lychee reports the resolved file path (trailing slash stripped); the
# markdown link may keep a trailing slash, so allow an optional "/".
m = re.search(r"\[([^\]]*)\]\(" + re.escape(dest) + r"/?\s*\)", cur)
if not m:
print(f" ? could not locate link on {md_file}:{line_no} for {dest}")
continue
link_txt = m.group(0)
if link_txt.count("<!--") or (cur.count("<!--") > cur.count("-->")):
continue # already commented
lines[idx] = cur[:m.start()] + "<!-- " + link_txt + " -->" + cur[m.end():]
changed = True
plan += 1
print(f" {'APPLY ' if args.apply else 'DRY '} {md_file}:{line_no} {link_txt}")
if changed and args.apply:
p.write_text("".join(lines))
outdir = Path("linkcheck")
outdir.mkdir(parents=True, exist_ok=True)
with (outdir / "manual_review.txt").open("w") as f:
f.write("Manual review (NOT auto-commented). External/bare-domain failures.\n\n")
for md_file, line, url, status in sorted(manual):
f.write(f"{md_file}:{line}\t{url}\t[{status}]\n")
print(f"\nAuto-comment planned: {plan} (apply={args.apply})")
print(f"Manual review entries: {len(manual)} -> {outdir/'manual_review.txt'}")
if __name__ == "__main__":
main()