mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-07-18 08:54:25 -05:00
896 lines
34 KiB
Python
896 lines
34 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Post-render step: resolve cross-chapter cross-references in HTML output.
|
||
|
||
WHY THIS SCRIPT EXISTS:
|
||
-----------------------
|
||
vol1 and vol2 build as `project.type: website` (each chapter is its own Pandoc
|
||
invocation), so Quarto cannot resolve `@sec-`, `@fig-`, `@tbl-`, `@eq-`, `@lst-`,
|
||
`@pri-`, or `@nb-` references that point into a sibling chapter. They ship as
|
||
literal `?@xxx-yyy` text in the rendered HTML. Selective builds (only rendering
|
||
a few chapters during development) hit this for every cross-chapter ref.
|
||
|
||
This script is the **resolution step** of the render pipeline — not a "fix" for
|
||
a bug. The website-mode build pipeline relies on it to wire cross-chapter
|
||
references end-to-end.
|
||
|
||
WHAT THIS SCRIPT DOES:
|
||
----------------------
|
||
1. Scans QMD source files to dynamically build a mapping of section/figure/table
|
||
IDs → HTML paths and titles.
|
||
2. Scans ALL HTML files and the generated search index in the build directory
|
||
after Quarto finishes.
|
||
3. Finds unresolved references that appear as: <strong>?@sec-xxx</strong>
|
||
(and the parallel forms for `<a href="@xxx-yyy">` and quarto-unresolved spans).
|
||
4. Converts them to proper HTML links: <strong><a href="../path/to/chapter.html#sec-xxx">Title</a></strong>
|
||
|
||
The dynamic approach means you never need to update this script when adding
|
||
chapters or renaming sections — it reads the source QMDs directly.
|
||
|
||
WHEN IT RUNS:
|
||
-------------
|
||
This script runs as a post-render hook in the Quarto configuration:
|
||
post-render:
|
||
- scripts/clean_svgs.py
|
||
- scripts/resolve_cross_references.py # <-- Runs after all HTML is generated
|
||
|
||
HOW TO USE:
|
||
-----------
|
||
1. Automatic: Runs automatically during `quarto render` as a post-render hook
|
||
2. Manual: python3 scripts/resolve_cross_references.py [specific-file.html]
|
||
3. Test all: python3 scripts/resolve_cross_references.py # processes all HTML files
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Crossref prefixes that we resolve at post-render time.
|
||
#
|
||
# Quarto book projects resolve cross-chapter refs natively, but vol1/vol2 build
|
||
# as `project.type: website` (see config/_quarto-html-vol*.yml), so any ref
|
||
# pointing into a sibling chapter ships as a literal `?@xxx-yyy` in the HTML.
|
||
# This script patches those refs by maintaining its own label registry.
|
||
#
|
||
# Prefixes covered: Quarto's built-ins (sec/fig/tbl/eq/lst/thm-family) plus the
|
||
# book's custom prefixes (pri for callout-principle, nb for notebook callouts).
|
||
# When adding a new prefix elsewhere in the codebase, add it here too.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
CROSSREF_PREFIXES_LIST = [
|
||
"sec", "pri", "fig", "tbl", "eq", "lst", "nb",
|
||
"thm", "lem", "cor", "prp", "cnj", "def", "exm", "exr", "rem", "sol",
|
||
]
|
||
CROSSREF_PREFIXES = "(?:" + "|".join(CROSSREF_PREFIXES_LIST) + ")"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Dynamic mapping: built by scanning QMD sources at runtime
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _extract_heading_text(line: str) -> str:
|
||
"""Strip Markdown heading markers, {#...} anchors, and attributes from a line."""
|
||
# Remove leading # chars and space
|
||
text = re.sub(r'^#+\s*', '', line)
|
||
# Remove {#id ...} block (Quarto heading attribute)
|
||
text = re.sub(r'\{[^}]*\}', '', text)
|
||
return _to_visible_title_text(text.strip())
|
||
|
||
|
||
# Pull the **Bold Title** slice out of a caption line, fig-cap, or tbl-cap.
|
||
# Captions in this book follow the convention `**Bold Title**: Explanation.`
|
||
# (book-prose.md §6 Figure Captions & Alt-Text). The bold title is the
|
||
# reader-facing name of the figure/table — exactly the right link text for a
|
||
# cross-chapter reference.
|
||
_BOLD_TITLE_RE = re.compile(r'\*\*([^*]+?)\*\*')
|
||
_TITLE_TEXT_WRAP_RE = re.compile(r"\\(?:text|mathrm|mathbf|mathit|mathsf|operatorname)\{([^{}]*)\}")
|
||
_TITLE_SPACING_RE = re.compile(r"\\[,;:! ]|\\quad|\\qquad")
|
||
_TITLE_GLYPHS = (
|
||
(r"\times", "×"),
|
||
(r"\cdot", "·"),
|
||
(r"\approx", "≈"),
|
||
(r"\leq", "≤"),
|
||
(r"\geq", "≥"),
|
||
(r"\neq", "≠"),
|
||
(r"\le", "≤"),
|
||
(r"\ge", "≥"),
|
||
(r"\pm", "±"),
|
||
(r"\sim", "~"),
|
||
(r"\rightarrow", "→"),
|
||
(r"\to", "→"),
|
||
(r"\leftarrow", "←"),
|
||
(r"\infty", "∞"),
|
||
(r"\mu", "μ"),
|
||
(r"\alpha", "α"),
|
||
(r"\beta", "β"),
|
||
(r"\rho", "ρ"),
|
||
)
|
||
_TITLE_CURRENCY_SENTINEL = "\x00USD\x00"
|
||
|
||
|
||
def _to_visible_title_text(text: str) -> str:
|
||
"""Convert simple inline math in source-extracted titles to visible text.
|
||
|
||
Cross-chapter xrefs are patched after rendering, so titles pulled from QMD
|
||
source do not pass through Pandoc's math parser. This handles short title
|
||
fragments such as ``$\\times$`` without treating captions themselves as
|
||
invalid math contexts.
|
||
"""
|
||
text = text.replace(r"\$", _TITLE_CURRENCY_SENTINEL)
|
||
for _ in range(3):
|
||
text, changed = _TITLE_TEXT_WRAP_RE.subn(r"\1", text)
|
||
if not changed:
|
||
break
|
||
text = text.replace(r"\%", "%")
|
||
for command, glyph in _TITLE_GLYPHS:
|
||
text = re.sub(re.escape(command) + r"(?![A-Za-z])", glyph, text)
|
||
text = _TITLE_SPACING_RE.sub("", text)
|
||
text = text.replace("$$", "").replace("$", "")
|
||
text = text.replace(_TITLE_CURRENCY_SENTINEL, "$")
|
||
return re.sub(r"\s+", " ", text).strip()
|
||
|
||
|
||
def _extract_bold_title(text: str) -> str | None:
|
||
m = _BOLD_TITLE_RE.search(text)
|
||
if not m:
|
||
return None
|
||
title = m.group(1)
|
||
# Defensive: strip any \index{...} that might have slipped inside the bold span.
|
||
title = re.sub(r'\\index\{[^}]*\}', '', title).strip()
|
||
title = _to_visible_title_text(title)
|
||
return title or None
|
||
|
||
|
||
def _fallback_label(ref_id: str) -> str:
|
||
"""Human-readable fallback when a label has no caption or title attribute."""
|
||
prefix = ref_id.split("-", 1)[0]
|
||
return {
|
||
"eq": "equation",
|
||
"lst": "listing",
|
||
"fig": "figure",
|
||
"tbl": "table",
|
||
"nb": "Notebook",
|
||
"thm": "Theorem",
|
||
"lem": "Lemma",
|
||
"cor": "Corollary",
|
||
"prp": "Proposition",
|
||
"cnj": "Conjecture",
|
||
"def": "Definition",
|
||
"exm": "Example",
|
||
"exr": "Exercise",
|
||
"rem": "Remark",
|
||
"sol": "Solution",
|
||
}.get(prefix, ref_id)
|
||
|
||
|
||
def build_qmd_mapping(qmd_roots: list[Path]) -> tuple[dict, dict]:
|
||
"""
|
||
Scan QMD source trees to build section ID → (html_path, title) mappings.
|
||
|
||
For each QMD file we:
|
||
- Find heading-level IDs: ## Heading Text {#sec-foo}
|
||
- Find div-level IDs: :::: {#pri-foo .callout-principle title="The Title"}
|
||
The HTML output path is derived by replacing .qmd → .html, keeping the same
|
||
relative path from the project root (which mirrors the build output layout).
|
||
|
||
Each qmd_root is a (scan_dir, path_prefix) pair — scan_dir is where we recurse,
|
||
path_prefix is prepended to every output path so it matches the Quarto build tree.
|
||
|
||
Args:
|
||
qmd_roots: list of (scan_dir, path_prefix) tuples, or plain Path objects
|
||
(plain Path → scan_dir == path_prefix parent == scan_dir)
|
||
|
||
Returns:
|
||
(chapter_mapping, chapter_titles) — same shape as the old hardcoded dicts
|
||
"""
|
||
chapter_mapping: dict[str, str] = {}
|
||
chapter_titles: dict[str, str] = {}
|
||
|
||
# Regex patterns to find IDs in QMD source
|
||
# Heading: # Title {#sec-foo} or # Title {#sec-foo .class}
|
||
heading_id_re = re.compile(r'^(#+)\s+(.+?)\s*\{#([\w-]+)[^}]*\}')
|
||
# Div / callout — match any attribute block containing #id, regardless of position
|
||
# Handles both:
|
||
# :::: {#pri-foo .callout-principle title="..."}
|
||
# :::: {.callout-principle #pri-foo title="..."}
|
||
div_id_re = re.compile(r'^\s*:{2,}\s*\{([^}]*)\}')
|
||
id_attr_re = re.compile(r'#([\w-]+)')
|
||
title_attr_re = re.compile(r'title="([^"]*)"')
|
||
# fig-cap / tbl-cap / lst-cap on figure/table/listing divs.
|
||
cap_attr_re = re.compile(r'(?:fig|tbl|lst)-cap="([^"]*)"')
|
||
|
||
# Caption-attribute label syntax (Quarto table & listing captions).
|
||
# Example: : **Bold Title**: explanation. {#tbl-foo tbl-colwidths="..."}
|
||
# Captures (caption_text, label_id) where label_id starts with tbl-/lst-/fig-.
|
||
# We also accept any other prefix in case a future caption-attribute form
|
||
# appears for another type; the prefix filter in CROSSREF_PREFIXES gates the
|
||
# patching pass anyway.
|
||
caption_id_re = re.compile(r'^:\s+(.+?)\s*\{[^}]*#([\w-]+)[^}]*\}')
|
||
|
||
# Equation label syntax: $$ ... $$ {#eq-foo}
|
||
# Single-line equations always have the {#eq-id} on the same line as the
|
||
# closing $$. For multi-line equations Pandoc accepts the label on a line
|
||
# immediately after the closing $$ as well — both cases land here because
|
||
# we walk every line and match the {#eq-id} fragment anywhere.
|
||
equation_id_re = re.compile(r'\{[^}]*#(eq-[\w-]+)[^}]*\}')
|
||
|
||
# Normalise qmd_roots: accept plain Path (scan from project root)
|
||
# We need paths relative to the *project* root, not the contents/ subdir
|
||
normalised: list[tuple[Path, Path]] = []
|
||
for entry in qmd_roots:
|
||
if isinstance(entry, tuple):
|
||
normalised.append(entry)
|
||
else:
|
||
# entry is a Path like Path("contents"); its parent is the project root
|
||
normalised.append((entry, entry.parent))
|
||
|
||
for scan_dir, path_root in normalised:
|
||
if not scan_dir.exists():
|
||
continue
|
||
|
||
for qmd_file in sorted(scan_dir.rglob("*.qmd")):
|
||
# Derive the relative HTML path from the project root
|
||
rel_qmd = qmd_file.relative_to(path_root)
|
||
rel_html = rel_qmd.with_suffix(".html")
|
||
html_path_str = str(rel_html).replace("\\", "/")
|
||
|
||
try:
|
||
lines = qmd_file.read_text(encoding="utf-8").splitlines()
|
||
except Exception:
|
||
continue
|
||
|
||
for line in lines:
|
||
# Try heading pattern first
|
||
m = heading_id_re.match(line)
|
||
if m:
|
||
hashes, raw_title, sec_id = m.group(1), m.group(2), m.group(3)
|
||
title = _extract_heading_text(raw_title)
|
||
depth = len(hashes)
|
||
# Only add if not already registered (first occurrence wins)
|
||
if sec_id not in chapter_mapping:
|
||
chapter_mapping[sec_id] = f"{html_path_str}#{sec_id}"
|
||
chapter_titles[sec_id] = title
|
||
continue
|
||
|
||
# Try div/callout pattern (handles any attribute order inside { })
|
||
m = div_id_re.match(line)
|
||
if m:
|
||
attrs = m.group(1)
|
||
im = id_attr_re.search(attrs)
|
||
if not im:
|
||
continue
|
||
sec_id = im.group(1)
|
||
# Prefer explicit title=, then bold-title from fig-cap/tbl-cap/lst-cap,
|
||
# then a human fallback ("Figure", "Equation", ...), then the bare ID.
|
||
tm = title_attr_re.search(attrs)
|
||
if tm:
|
||
title = tm.group(1)
|
||
else:
|
||
title = None
|
||
cm = cap_attr_re.search(attrs)
|
||
if cm:
|
||
title = _extract_bold_title(cm.group(1))
|
||
if not title:
|
||
title = _fallback_label(sec_id)
|
||
if sec_id not in chapter_mapping:
|
||
chapter_mapping[sec_id] = f"{html_path_str}#{sec_id}"
|
||
chapter_titles[sec_id] = title
|
||
continue
|
||
|
||
# Caption-attribute label syntax (table & listing captions).
|
||
# Form: `: **Bold Title**: explanation. {#tbl-foo ...}`
|
||
m = caption_id_re.match(line)
|
||
if m:
|
||
caption_text, sec_id = m.group(1), m.group(2)
|
||
if sec_id not in chapter_mapping:
|
||
title = _extract_bold_title(caption_text) or _fallback_label(sec_id)
|
||
chapter_mapping[sec_id] = f"{html_path_str}#{sec_id}"
|
||
chapter_titles[sec_id] = title
|
||
continue
|
||
|
||
# Equation label syntax: $$ ... $$ {#eq-foo}.
|
||
# We match the {#eq-id} fragment anywhere on the line so both the
|
||
# single-line and trailing-attribute multi-line forms register.
|
||
# Skip lines starting with `:` (already handled above as caption)
|
||
# or `#` (already handled as heading) to avoid double-registration.
|
||
if not line.lstrip().startswith((":", "#")):
|
||
m = equation_id_re.search(line)
|
||
if m:
|
||
sec_id = m.group(1)
|
||
if sec_id not in chapter_mapping:
|
||
chapter_mapping[sec_id] = f"{html_path_str}#{sec_id}"
|
||
chapter_titles[sec_id] = _fallback_label(sec_id)
|
||
|
||
return chapter_mapping, chapter_titles
|
||
|
||
|
||
def _find_qmd_roots() -> list[tuple[Path, Path]]:
|
||
"""
|
||
Locate the Quarto project source tree from common run locations.
|
||
|
||
Quarto post-render hooks run from the project root (where _quarto.yml lives).
|
||
Manual runs may happen from the scripts/ directory or the repo root.
|
||
|
||
Returns list of (scan_dir, path_root) tuples for build_qmd_mapping.
|
||
"""
|
||
candidates = [
|
||
Path("."), # run from project root (most common)
|
||
Path(".."), # run from scripts/
|
||
Path("book/quarto"), # run from repo root
|
||
]
|
||
for c in candidates:
|
||
if (c / "contents").exists():
|
||
return [(c / "contents", c)]
|
||
return []
|
||
|
||
|
||
# Build the mapping once at import time (lazy cache via module-level variable)
|
||
_CHAPTER_MAPPING: dict | None = None
|
||
_CHAPTER_TITLES: dict | None = None
|
||
_PRINCIPLE_NUMBERS: dict | None = None
|
||
|
||
|
||
def get_mappings() -> tuple[dict, dict]:
|
||
global _CHAPTER_MAPPING, _CHAPTER_TITLES
|
||
if _CHAPTER_MAPPING is None:
|
||
roots = _find_qmd_roots()
|
||
if roots:
|
||
_CHAPTER_MAPPING, _CHAPTER_TITLES = build_qmd_mapping(roots)
|
||
else:
|
||
_CHAPTER_MAPPING, _CHAPTER_TITLES = {}, {}
|
||
return _CHAPTER_MAPPING, _CHAPTER_TITLES
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Principle numbering
|
||
#
|
||
# Principle callouts use a per-volume global counter that increments across the
|
||
# four parts/*_principles.qmd files in declared order. The Lua filter does this
|
||
# at render time, but the count is only available within the Pandoc invocation
|
||
# that's currently rendering a parts file — sibling chapters that reference
|
||
# principles via `Principle \ref{pri-X}` never see the number.
|
||
#
|
||
# We replay the count here so cross-chapter references in HTML can substitute
|
||
# the resolved `Principle N` text. The parts file render order must match what
|
||
# Quarto + Lua see during a full build (the order in config/_quarto-html-vol*.yml).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Stable parts-file order per volume. Matches the declared order in
|
||
# config/_quarto-html-vol*.yml and config/_quarto-pdf-vol*-copyedit.yml.
|
||
PRINCIPLE_PARTS_ORDER: dict[str, list[str]] = {
|
||
"vol1": [
|
||
"contents/vol1/parts/foundations_principles.qmd",
|
||
"contents/vol1/parts/build_principles.qmd",
|
||
"contents/vol1/parts/optimize_principles.qmd",
|
||
"contents/vol1/parts/deploy_principles.qmd",
|
||
],
|
||
"vol2": [
|
||
"contents/vol2/parts/fleet_principles.qmd",
|
||
"contents/vol2/parts/distributed_ml_principles.qmd",
|
||
"contents/vol2/parts/deployment_principles.qmd",
|
||
"contents/vol2/parts/responsible_fleet_principles.qmd",
|
||
],
|
||
}
|
||
|
||
|
||
def build_principle_numbers(roots: list[tuple[Path, Path]]) -> dict[str, str]:
|
||
"""
|
||
Walk the parts/*_principles.qmd files in declared order and count
|
||
`.callout-principle` divs to compute each principle's assigned number.
|
||
|
||
Returns {pri_id: number_str}. Per-volume independent numbering.
|
||
"""
|
||
# Match a div opener that carries BOTH `.callout-principle` and `#pri-X`.
|
||
# Attributes can appear in any order inside the {...} block.
|
||
div_opener_re = re.compile(r'^\s*:{2,}\s*\{([^}]*)\}')
|
||
pri_id_re = re.compile(r'#(pri-[\w-]+)')
|
||
has_callout_principle = re.compile(r'\.callout-principle\b')
|
||
|
||
result: dict[str, str] = {}
|
||
for _, path_root in roots:
|
||
if not isinstance(path_root, Path):
|
||
continue
|
||
for vol, parts_paths in PRINCIPLE_PARTS_ORDER.items():
|
||
counter = 0
|
||
for rel in parts_paths:
|
||
qmd = path_root / rel
|
||
if not qmd.exists():
|
||
continue
|
||
try:
|
||
lines = qmd.read_text(encoding="utf-8").splitlines()
|
||
except Exception:
|
||
continue
|
||
for line in lines:
|
||
m_open = div_opener_re.match(line)
|
||
if not m_open:
|
||
continue
|
||
attrs = m_open.group(1)
|
||
if not has_callout_principle.search(attrs):
|
||
continue
|
||
m_id = pri_id_re.search(attrs)
|
||
if not m_id:
|
||
continue
|
||
counter += 1
|
||
pri_id = m_id.group(1)
|
||
if pri_id not in result:
|
||
result[pri_id] = str(counter)
|
||
break # roots[0] is the project root we want
|
||
return result
|
||
|
||
|
||
def get_principle_numbers() -> dict:
|
||
global _PRINCIPLE_NUMBERS
|
||
if _PRINCIPLE_NUMBERS is None:
|
||
roots = _find_qmd_roots()
|
||
if roots:
|
||
_PRINCIPLE_NUMBERS = build_principle_numbers(roots)
|
||
else:
|
||
_PRINCIPLE_NUMBERS = {}
|
||
return _PRINCIPLE_NUMBERS
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# EPUB support
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def build_epub_section_mapping(epub_dir: Path) -> dict:
|
||
"""
|
||
Build mapping from section IDs to EPUB chapter files by scanning actual chapters.
|
||
|
||
Args:
|
||
epub_dir: Path to EPUB build directory (_build/epub or extracted EPUB root)
|
||
|
||
Returns:
|
||
Dictionary mapping section IDs to chapter filenames (e.g., {"sec-xxx": "ch004.xhtml"})
|
||
"""
|
||
mapping: dict[str, str] = {}
|
||
|
||
possible_text_dirs = [
|
||
epub_dir / "text",
|
||
epub_dir / "EPUB" / "text",
|
||
]
|
||
|
||
text_dir = None
|
||
for dir_path in possible_text_dirs:
|
||
if dir_path.exists():
|
||
text_dir = dir_path
|
||
break
|
||
|
||
if not text_dir:
|
||
return mapping
|
||
|
||
for xhtml_file in sorted(text_dir.glob("ch*.xhtml")):
|
||
try:
|
||
content = xhtml_file.read_text(encoding="utf-8")
|
||
for sec_id in re.findall(r'id="(sec-[^"]+)"', content):
|
||
mapping[sec_id] = xhtml_file.name
|
||
except Exception:
|
||
continue
|
||
|
||
return mapping
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Path calculation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def calculate_relative_path(
|
||
from_file: Path,
|
||
to_path: str,
|
||
build_dir: Path,
|
||
epub_mapping: dict | None = None,
|
||
) -> str:
|
||
"""
|
||
Calculate relative path from one file to another.
|
||
|
||
Args:
|
||
from_file: Path object of the source file
|
||
to_path: String path from build root (e.g., "contents/vol1/chapter/file.html#anchor")
|
||
build_dir: Path object of the build directory root
|
||
epub_mapping: Optional dict mapping section IDs to EPUB chapter files
|
||
|
||
Returns:
|
||
Relative path string from from_file to to_path
|
||
"""
|
||
if epub_mapping is not None:
|
||
if "#" in to_path:
|
||
_, sec_id = to_path.split("#", 1)
|
||
target_chapter = epub_mapping.get(sec_id)
|
||
if target_chapter:
|
||
return f"{target_chapter}#{sec_id}"
|
||
return to_path
|
||
|
||
if "#" in to_path:
|
||
target_path_str, anchor = to_path.split("#", 1)
|
||
anchor = f"#{anchor}"
|
||
else:
|
||
target_path_str = to_path
|
||
anchor = ""
|
||
|
||
target_abs = build_dir / target_path_str
|
||
source_abs = from_file
|
||
|
||
try:
|
||
rel_path = Path(target_abs).relative_to(source_abs.parent)
|
||
result = str(rel_path).replace("\\", "/")
|
||
except ValueError:
|
||
source_parts = source_abs.parent.parts
|
||
target_parts = target_abs.parts
|
||
|
||
common_length = 0
|
||
for s, t in zip(source_parts, target_parts):
|
||
if s == t:
|
||
common_length += 1
|
||
else:
|
||
break
|
||
|
||
up_levels = len(source_parts) - common_length
|
||
down_parts = target_parts[common_length:]
|
||
rel_parts = [".."] * up_levels + list(down_parts)
|
||
result = "/".join(rel_parts)
|
||
|
||
return result + anchor
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cross-reference fixing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def fix_cross_reference_link(match, from_file, build_dir, epub_mapping=None):
|
||
"""Replace a single cross-reference link with proper HTML link."""
|
||
full_match = match.group(0)
|
||
sec_ref = match.group(1)
|
||
|
||
chapter_mapping, chapter_titles = get_mappings()
|
||
abs_path = chapter_mapping.get(sec_ref)
|
||
title = chapter_titles.get(sec_ref)
|
||
|
||
if abs_path and title:
|
||
rel_path = calculate_relative_path(from_file, abs_path, build_dir, epub_mapping)
|
||
return f'<a href="{rel_path}">{title}</a>'
|
||
else:
|
||
print(f" ⚠️ No mapping found for: {sec_ref}")
|
||
return full_match
|
||
|
||
|
||
def resolve_cross_references(
|
||
html_content: str,
|
||
from_file: Path,
|
||
build_dir: Path,
|
||
epub_mapping: dict | None = None,
|
||
) -> tuple[str, int, list]:
|
||
"""
|
||
Resolve all cross-reference links in HTML/XHTML content.
|
||
|
||
Quarto generates three types of unresolved references when chapters aren't built:
|
||
1. Full unresolved links: <a href="#sec-xxx" class="quarto-xref"><span class="quarto-unresolved-ref">...</span></a>
|
||
2. Simple unresolved refs: <strong>?@sec-xxx</strong> (common in selective builds)
|
||
3. EPUB unresolved refs: <a href="@sec-xxx">Link Text</a>
|
||
"""
|
||
chapter_mapping, chapter_titles = get_mappings()
|
||
|
||
# All three patterns cover the full set of crossref prefixes (CROSSREF_PREFIXES)
|
||
# — sec, pri, fig, tbl, eq, lst, nb, plus Quarto's theorem family. Quarto emits
|
||
# unresolved refs in three shapes:
|
||
# 1. Full xref link with a quarto-unresolved-ref span (rare in our build).
|
||
# 2. Bare `?@xxx-yyy` wrapped in <strong> (common — every cross-chapter ref
|
||
# ships as this when the project is `type: website`).
|
||
# 3. EPUB-specific `<a href="@xxx-yyy">…</a>`.
|
||
pattern1 = rf'<a href="#({CROSSREF_PREFIXES}-[a-zA-Z0-9-]+)" class="quarto-xref"><span class="quarto-unresolved-ref">[^<]*</span></a>'
|
||
pattern2 = rf'<strong>\?\@({CROSSREF_PREFIXES}-[a-zA-Z0-9-]+)</strong>'
|
||
pattern3 = rf'<a href="@({CROSSREF_PREFIXES}-[a-zA-Z0-9-]+)"([^>]*)>([^<]*)</a>'
|
||
# Pattern 4 — `Principle \ref{pri-X}` leaks as an inline-math span.
|
||
# Source prose says: `Principle \ref{pri-data-as-code}`. Pandoc parses the
|
||
# `\ref{...}` as inline math, so it ships to HTML as:
|
||
# <span class="math inline">\(\ref{pri-data-as-code}\)</span>
|
||
# MathJax then renders the undefined `\ref` as `???`. We replace the whole
|
||
# math span with a link to the principles page using the resolved number.
|
||
pattern4 = r'<span class="math inline">\\\(\\ref\{(pri-[a-zA-Z0-9-]+)\}\\\)</span>'
|
||
|
||
matches1 = re.findall(pattern1, html_content)
|
||
matches2 = re.findall(pattern2, html_content)
|
||
matches3 = re.findall(pattern3, html_content)
|
||
matches4 = re.findall(pattern4, html_content)
|
||
total_matches = len(matches1) + len(matches2) + len(matches3) + len(matches4)
|
||
|
||
# Fix Pattern 1
|
||
fixed_content = re.sub(
|
||
pattern1,
|
||
lambda m: fix_cross_reference_link(m, from_file, build_dir, epub_mapping),
|
||
html_content,
|
||
)
|
||
|
||
# Fix Pattern 2
|
||
unmapped_refs = []
|
||
|
||
def fix_simple_reference(match):
|
||
sec_ref = match.group(1)
|
||
abs_path = chapter_mapping.get(sec_ref)
|
||
title = chapter_titles.get(sec_ref)
|
||
if abs_path and title:
|
||
rel_path = calculate_relative_path(from_file, abs_path, build_dir, epub_mapping)
|
||
return f'<strong><a href="{rel_path}">{title}</a></strong>'
|
||
else:
|
||
unmapped_refs.append(sec_ref)
|
||
return match.group(0)
|
||
|
||
fixed_content = re.sub(pattern2, fix_simple_reference, fixed_content)
|
||
|
||
# Fix Pattern 3 (EPUB)
|
||
def fix_epub_reference(match):
|
||
sec_ref = match.group(1)
|
||
attrs = match.group(2)
|
||
link_text = match.group(3)
|
||
|
||
if epub_mapping:
|
||
target_chapter = epub_mapping.get(sec_ref)
|
||
if target_chapter:
|
||
return f'<a href="{target_chapter}#{sec_ref}"{attrs}>{link_text}</a>'
|
||
else:
|
||
unmapped_refs.append(sec_ref)
|
||
return match.group(0)
|
||
else:
|
||
abs_path = chapter_mapping.get(sec_ref)
|
||
if abs_path:
|
||
rel_path = calculate_relative_path(from_file, abs_path, build_dir, None)
|
||
return f'<a href="{rel_path}"{attrs}>{link_text}</a>'
|
||
else:
|
||
unmapped_refs.append(sec_ref)
|
||
return match.group(0)
|
||
|
||
fixed_content = re.sub(pattern3, fix_epub_reference, fixed_content)
|
||
|
||
# Fix Pattern 4 — Principle \ref{pri-X} math-span leak
|
||
principle_numbers = get_principle_numbers()
|
||
|
||
def fix_principle_reference(match):
|
||
pri_ref = match.group(1)
|
||
number = principle_numbers.get(pri_ref)
|
||
abs_path = chapter_mapping.get(pri_ref)
|
||
if number and abs_path:
|
||
rel_path = calculate_relative_path(from_file, abs_path, build_dir, epub_mapping)
|
||
return f'<a href="{rel_path}">{number}</a>'
|
||
elif number:
|
||
# Have the number but no link target — emit number as plain text.
|
||
return number
|
||
else:
|
||
unmapped_refs.append(pri_ref)
|
||
return match.group(0)
|
||
|
||
fixed_content = re.sub(pattern4, fix_principle_reference, fixed_content)
|
||
|
||
remaining1 = re.findall(pattern1, fixed_content)
|
||
remaining2 = re.findall(pattern2, fixed_content)
|
||
remaining3 = re.findall(pattern3, fixed_content)
|
||
remaining4 = re.findall(pattern4, fixed_content)
|
||
fixed_count = total_matches - len(remaining1) - len(remaining2) - len(remaining3) - len(remaining4)
|
||
|
||
return fixed_content, fixed_count, unmapped_refs
|
||
|
||
|
||
def resolve_search_text(text: str) -> tuple[str, int, list[str]]:
|
||
"""Resolve bare `?@label` tokens in Quarto's generated search index text."""
|
||
chapter_mapping, chapter_titles = get_mappings()
|
||
unmapped_refs: list[str] = []
|
||
|
||
pattern = rf'\?\@({CROSSREF_PREFIXES}-[a-zA-Z0-9-]+)'
|
||
|
||
def fix_search_reference(match):
|
||
ref = match.group(1)
|
||
title = chapter_titles.get(ref)
|
||
if ref in chapter_mapping and title:
|
||
return title
|
||
unmapped_refs.append(ref)
|
||
return match.group(0)
|
||
|
||
fixed_text, fixed_count = re.subn(pattern, fix_search_reference, text)
|
||
return fixed_text, fixed_count, unmapped_refs
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# File processing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def process_html_file(html_file: Path, base_dir: Path, epub_mapping: dict | None = None):
|
||
"""Process a single HTML/XHTML file to fix cross-references."""
|
||
try:
|
||
html_content = html_file.read_text(encoding="utf-8")
|
||
except Exception:
|
||
return None, 0, []
|
||
|
||
fixed_content, fixed_count, unmapped = resolve_cross_references(
|
||
html_content, html_file, base_dir, epub_mapping
|
||
)
|
||
|
||
if fixed_count > 0:
|
||
try:
|
||
html_file.write_text(fixed_content, encoding="utf-8")
|
||
return html_file.relative_to(base_dir), fixed_count, unmapped
|
||
except Exception:
|
||
return None, 0, []
|
||
|
||
return None, 0, []
|
||
|
||
|
||
def _map_json_strings(value, mapper):
|
||
"""Return JSON value with mapper applied to every nested string."""
|
||
if isinstance(value, str):
|
||
return mapper(value)
|
||
if isinstance(value, list):
|
||
return [_map_json_strings(item, mapper) for item in value]
|
||
if isinstance(value, dict):
|
||
return {key: _map_json_strings(item, mapper) for key, item in value.items()}
|
||
return value
|
||
|
||
|
||
def process_search_json_file(search_file: Path, base_dir: Path):
|
||
"""Process Quarto's search.json to remove stale `?@label` text."""
|
||
try:
|
||
data = json.loads(search_file.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return None, 0, []
|
||
|
||
total_fixed = 0
|
||
all_unmapped: list[str] = []
|
||
|
||
def mapper(text: str) -> str:
|
||
nonlocal total_fixed, all_unmapped
|
||
fixed_text, fixed_count, unmapped = resolve_search_text(text)
|
||
total_fixed += fixed_count
|
||
all_unmapped.extend(unmapped)
|
||
return fixed_text
|
||
|
||
fixed_data = _map_json_strings(data, mapper)
|
||
|
||
if total_fixed > 0:
|
||
try:
|
||
search_file.write_text(
|
||
json.dumps(fixed_data, ensure_ascii=False, indent=2) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
return search_file.relative_to(base_dir), total_fixed, all_unmapped
|
||
except Exception:
|
||
return None, 0, []
|
||
|
||
return None, 0, []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry point
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main():
|
||
"""
|
||
Main entry point. Runs in three modes:
|
||
1. Post-render hook (no args): Processes HTML or EPUB builds from _build/
|
||
2. Directory mode (dir arg): Processes extracted EPUB directory
|
||
3. Manual mode (file arg): Processes a specific file
|
||
"""
|
||
# Pre-load mappings and report count
|
||
chapter_mapping, chapter_titles = get_mappings()
|
||
if chapter_mapping:
|
||
print(f"📖 Loaded {len(chapter_mapping)} section IDs from QMD sources")
|
||
else:
|
||
print("⚠️ No QMD sources found — cross-reference mapping will be empty")
|
||
|
||
skip_patterns = [
|
||
"search.html", "404.html", "site_libs",
|
||
"nav.xhtml", "cover.xhtml", "title_page.xhtml",
|
||
]
|
||
|
||
if len(sys.argv) == 1:
|
||
# MODE 1: Running as Quarto post-render hook
|
||
build_root = Path("_build")
|
||
html_candidates = sorted(
|
||
[p for p in build_root.glob("html*") if p.is_dir()],
|
||
key=lambda p: p.stat().st_mtime,
|
||
reverse=True,
|
||
)
|
||
epub_candidates = sorted(
|
||
[p for p in build_root.glob("epub*") if p.is_dir()],
|
||
key=lambda p: p.stat().st_mtime,
|
||
reverse=True,
|
||
)
|
||
|
||
epub_mapping = None
|
||
if html_candidates:
|
||
build_dir = html_candidates[0]
|
||
file_pattern = "*.html"
|
||
file_type = "HTML"
|
||
elif epub_candidates and list(epub_candidates[0].glob("*.xhtml")):
|
||
build_dir = epub_candidates[0]
|
||
file_pattern = "*.xhtml"
|
||
file_type = "XHTML (EPUB)"
|
||
print("📚 Building EPUB section mapping...")
|
||
epub_mapping = build_epub_section_mapping(build_dir)
|
||
print(f" Found {len(epub_mapping)} section IDs across chapters")
|
||
elif Path("EPUB").exists() and list(Path("EPUB").rglob("*.xhtml")):
|
||
build_dir = Path(".")
|
||
file_pattern = "*.xhtml"
|
||
file_type = "XHTML (EPUB - extracted)"
|
||
print("📚 Building EPUB section mapping...")
|
||
epub_mapping = build_epub_section_mapping(Path("."))
|
||
print(f" Found {len(epub_mapping)} section IDs across chapters")
|
||
else:
|
||
print("⚠️ No HTML or EPUB build directory found — skipping")
|
||
sys.exit(0)
|
||
|
||
files = list(build_dir.rglob(file_pattern))
|
||
search_files = [build_dir / "search.json"] if (build_dir / "search.json").exists() else []
|
||
print(
|
||
f"🔗 [Cross-Reference Fix] Scanning {len(files)} {file_type} files"
|
||
f" and {len(search_files)} search index file(s)..."
|
||
)
|
||
|
||
files_fixed = []
|
||
total_refs_fixed = 0
|
||
all_unmapped: set[str] = set()
|
||
|
||
for file in files:
|
||
if any(skip in str(file) for skip in skip_patterns):
|
||
continue
|
||
|
||
rel_path, fixed_count, unmapped = process_html_file(file, build_dir, epub_mapping)
|
||
if fixed_count > 0:
|
||
files_fixed.append((rel_path, fixed_count))
|
||
total_refs_fixed += fixed_count
|
||
all_unmapped.update(unmapped)
|
||
|
||
for file in search_files:
|
||
rel_path, fixed_count, unmapped = process_search_json_file(file, build_dir)
|
||
if fixed_count > 0:
|
||
files_fixed.append((rel_path, fixed_count))
|
||
total_refs_fixed += fixed_count
|
||
all_unmapped.update(unmapped)
|
||
|
||
if files_fixed:
|
||
print(f"✅ Fixed {total_refs_fixed} cross-references in {len(files_fixed)} files:")
|
||
for path, count in files_fixed:
|
||
print(f" 📄 {path}: {count} refs")
|
||
else:
|
||
print("✅ No unresolved cross-references found")
|
||
|
||
if all_unmapped:
|
||
print(f"⚠️ Unmapped references: {', '.join(sorted(all_unmapped))}")
|
||
|
||
elif len(sys.argv) == 2:
|
||
# MODE 2: Running with explicit file argument
|
||
html_file = Path(sys.argv[1])
|
||
if not html_file.exists():
|
||
print(f"❌ File not found: {html_file}")
|
||
sys.exit(1)
|
||
|
||
epub_mapping = None
|
||
if "text" in html_file.parts and html_file.suffix == ".xhtml":
|
||
epub_base = html_file.parent.parent
|
||
print("📚 Building EPUB section mapping...")
|
||
epub_mapping = build_epub_section_mapping(epub_base)
|
||
print(f" Found {len(epub_mapping)} section IDs across chapters")
|
||
|
||
print(f"🔗 Fixing cross-reference links in: {html_file}")
|
||
if html_file.name == "search.json":
|
||
rel_path, fixed_count, unmapped = process_search_json_file(
|
||
html_file, html_file.parent
|
||
)
|
||
else:
|
||
rel_path, fixed_count, unmapped = process_html_file(
|
||
html_file, html_file.parent, epub_mapping
|
||
)
|
||
if fixed_count > 0:
|
||
print(f"✅ Fixed {fixed_count} cross-references")
|
||
if unmapped:
|
||
print(f"⚠️ Unmapped references: {', '.join(sorted(unmapped))}")
|
||
else:
|
||
print("✅ No cross-reference fixes needed")
|
||
|
||
else:
|
||
print("Usage: python3 resolve_cross_references.py [<html-or-xhtml-file>]")
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|