` to its site URL or GitHub source URL."""
+ href = a.get("href", "") # type: ignore[attr-defined]
+ if not href or is_external(href) or href.startswith("#"):
+ return
+
+ anchor = ""
+ if "#" in href:
+ href, anchor_part = href.split("#", 1)
+ anchor = "#" + anchor_part
+ if not href:
+ return
+
+ rel_str = _resolve_repo_relative(href, page.source.parent, config.root_path)
+ if rel_str is None:
+ logger.debug(f"Link outside repo skipped: {href} (in {page.rel_source})")
+ return
+
+ candidates = [rel_str]
+ resolved = (page.source.parent / href).resolve()
+ if not rel_str.endswith(".md") and resolved.is_dir():
+ candidates.append(rel_str + "/README.md")
+
+ for candidate in candidates:
+ if candidate in state.source_to_url:
+ a["href"] = relative_link( # type: ignore[index]
+ page.output_url, state.source_to_url[candidate], anchor
+ )
+ return
+
+ github_url = _github_source_url(
+ config, rel_str, is_dir=resolved.is_dir(), anchor=anchor
+ )
+ a["href"] = github_url # type: ignore[index]
+ a["target"] = "_blank" # type: ignore[index]
+ a["rel"] = "noopener noreferrer" # type: ignore[index]
+
+
+def _rewrite_asset_ref(
+ element: object,
+ attr: str,
+ raw_value: str,
+ page: PageInfo,
+ config: WebsiteConfig,
+) -> None:
+ """Rewrite a single asset reference (img.src / source.srcset) to assets/."""
+ if not raw_value or is_external(raw_value):
+ return
+ first = raw_value.split(",", 1)[0].strip().split(" ", 1)[0]
+ if not first or is_external(first):
+ return
+ rel_str = _resolve_repo_relative(first, page.source.parent, config.root_path)
+ if rel_str is None:
+ return
+ target = "assets/" + rel_str
+ element[attr] = relative_link(page.output_url, target) # type: ignore[index]
+
+
+def rewrite_links(
+ html_content: str,
+ page: PageInfo,
+ state: BuildState,
+ config: WebsiteConfig,
+ logger: logging.Logger,
+) -> str:
+ """Rewrite anchor/image hrefs so links resolve correctly on the site."""
+ soup = BeautifulSoup(html_content, "html.parser")
+
+ for a in soup.find_all("a"):
+ _rewrite_anchor(a, page, state, config, logger)
+
+ for img in soup.find_all("img"):
+ _rewrite_asset_ref(img, "src", img.get("src", ""), page, config)
+ if img.get("src"):
+ img["loading"] = "lazy"
+
+ for source in soup.find_all("source"):
+ _rewrite_asset_ref(source, "srcset", source.get("srcset", ""), page, config)
+
+ return str(soup)
+
+
+# =============================================================================
+# Mermaid handling
+# =============================================================================
+
+
+MERMAID_BLOCK_RE = re.compile(r"```mermaid\n(.*?)```", re.DOTALL)
+
+
+def replace_mermaid_blocks(md_content: str) -> str:
+ """Replace ```mermaid``` fences with `` for client-side render."""
+
+ def _replace(match: re.Match[str]) -> str:
+ code = match.group(1)
+ return f'{html.escape(code)}'
+
+ return MERMAID_BLOCK_RE.sub(_replace, md_content)
+
+
+# =============================================================================
+# Markdown rendering
+# =============================================================================
+
+
+def normalise_heading_ids(html_content: str) -> str:
+ """Force GitHub-style anchor ids on every heading.
+
+ `python-markdown`'s `toc` extension generates its own slug; we re-write them
+ so they match `check_cross_references.heading_to_anchor`.
+ """
+ soup = BeautifulSoup(html_content, "html.parser")
+ used: dict[str, int] = {}
+ for level in ("h1", "h2", "h3", "h4", "h5", "h6"):
+ for h in soup.find_all(level):
+ text = h.get_text(strip=True)
+ anchor = heading_to_anchor(text)
+ if not anchor:
+ continue
+ count = used.get(anchor, 0)
+ final = anchor if count == 0 else f"{anchor}-{count}"
+ used[anchor] = count + 1
+ h["id"] = final
+ return str(soup)
+
+
+def extract_toc(html_content: str) -> list[dict[str, str]]:
+ """Pull H2/H3 headings into a flat list for in-page navigation."""
+ soup = BeautifulSoup(html_content, "html.parser")
+ toc: list[dict[str, str]] = []
+ for h in soup.find_all(["h2", "h3"]):
+ anchor = h.get("id")
+ if not anchor:
+ continue
+ toc.append(
+ {
+ "level": h.name,
+ "text": h.get_text(strip=True),
+ "anchor": anchor,
+ }
+ )
+ return toc
+
+
+def render_markdown(md_content: str) -> str:
+ """Convert markdown to HTML using the same extensions as the EPUB build."""
+ md_content = replace_mermaid_blocks(md_content)
+ html_content = markdown.markdown(
+ md_content,
+ extensions=["tables", "fenced_code", "codehilite", "toc"],
+ extension_configs={"codehilite": {"guess_lang": False}},
+ )
+ return normalise_heading_ids(html_content)
+
+
+# =============================================================================
+# Asset copying
+# =============================================================================
+
+
+ASSET_EXTENSIONS = {
+ ".svg",
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".webp",
+ ".ico",
+}
+
+
+def copy_assets(
+ config: WebsiteConfig, state: BuildState, logger: logging.Logger
+) -> None:
+ """Copy images referenced by any rendered page into `