#!/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: ?@sec-xxx
(and the parallel forms for `` and quarto-unresolved spans).
4. Converts them to proper HTML links: Title
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'{title}'
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: ...
2. Simple unresolved refs: ?@sec-xxx (common in selective builds)
3. EPUB unresolved refs: Link Text
"""
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 (common — every cross-chapter ref
# ships as this when the project is `type: website`).
# 3. EPUB-specific `…`.
pattern1 = rf'[^<]*'
pattern2 = rf'\?\@({CROSSREF_PREFIXES}-[a-zA-Z0-9-]+)'
pattern3 = rf']*)>([^<]*)'
# 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:
# \(\ref{pri-data-as-code}\)
# 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'\\\(\\ref\{(pri-[a-zA-Z0-9-]+)\}\\\)'
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'{title}'
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'{link_text}'
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'{link_text}'
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'{number}'
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 []")
sys.exit(1)
if __name__ == "__main__":
main()