Files
CleverThis Engineering 0ea0f32711 chore: fix pre-commit hooks — update paths and lint/format issues
- Fix vulture path from src/cleveragents to src/cleveractors
- Add B905 strict=True to zip() in math_prerender.py
- Fix B026 star-arg-after-kwarg in coverage_report session
- Ignore vulture_whitelist.py in ruff
- End-of-file newlines and trailing whitespace fixes from hooks
2026-05-27 21:27:36 +00:00

359 lines
14 KiB
Python

"""MkDocs hook that pre-renders LaTeX math at build time using KaTeX.
Motivation
----------
The IHUFT proposal contains several hundred numbered equations and inline
math expressions. Rendering these at *view* time using MathJax-in-JavaScript
imposes a noticeable cost on the reader's browser: the entire JavaScript
engine has to parse LaTeX, lay out math, and paint dozens of glyphs before
the page becomes responsive. This hook moves that cost to *build* time
instead. Each equation is rendered once by KaTeX during ``mkdocs build``
and stored in the output HTML as static markup and standard MathML. The
runtime cost in the browser drops to a single download of a CSS stylesheet
and the fonts that it references.
LaTeX-source preservation
-------------------------
KaTeX's ``output: 'htmlAndMathml'`` mode emits both:
1. A semantically-marked-up MathML subtree (for assistive technology,
accessibility, and copy-as-MathML), and
2. A visually-styled HTML subtree (for layout in browsers without good
MathML rendering).
Inside the MathML subtree, KaTeX automatically wraps the original LaTeX
source in an ``<annotation encoding="application/x-tex">…</annotation>``
element, exactly as the MathML 3 specification recommends. This means
the LaTeX source travels with the rendered math forever and is recoverable
by any conformant reader — including a tiny "show source" widget that
this hook adds for display math (see the surrounding ``<details>`` block).
Markdown integration
--------------------
The hook runs at ``on_page_markdown``, *before* Python-Markdown parses the
page. It replaces every ``$$…$$`` (display) and ``$…$`` (inline) span with
a unique placeholder token, batches all extracted math into a single
subprocess call to ``scripts/render_math.js`` (Node + KaTeX), and finally
substitutes the rendered HTML back in place of the placeholders. Block
math is wrapped in ``<div class="math math-display" markdown="0">…</div>``
so that the surrounding Markdown processor (and the ``md_in_html``
extension) leaves the pre-rendered HTML untouched.
Failure mode
------------
If KaTeX raises an error on any individual expression, that expression
falls back to a ``<code>…</code>`` block with the original LaTeX source
visible. No build is aborted by a malformed equation.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_HOOK_DIR = Path(__file__).resolve().parent
_PROJECT_ROOT = _HOOK_DIR.parent
_RENDER_SCRIPT = _PROJECT_ROOT / "scripts" / "render_math.js"
_NODE_MODULES = _PROJECT_ROOT / "node_tools" / "node_modules"
# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------
# Display math: $$…$$ across multiple lines. Greedy with a non-greedy
# inner match so that consecutive blocks don't merge.
_DISPLAY_RE = re.compile(r"\$\$([\s\S]+?)\$\$", re.MULTILINE)
# Inline math: $…$ on a single line, not preceded by another $ (to avoid
# eating the second $ of a $$ pair) and not preceded by a backslash (so
# that a literal escaped \$ is left alone). The content cannot contain a
# newline or a bare $.
_INLINE_RE = re.compile(r"(?<![\\$])\$(?!\$)([^\n$]+?)(?<!\\)\$(?!\$)")
# ---------------------------------------------------------------------------
# Fenced-code shielding
# ---------------------------------------------------------------------------
#
# We must not touch ``$…$`` that appears inside a fenced code block or an
# inline-code span — that would corrupt user-visible code samples. The
# strategy is to extract every fenced block and every inline-code span,
# replace each with an opaque sentinel, run the math-replacement pass, then
# put the code blocks back. The sentinels are designed to be inert to the
# math regexes and to Markdown.
_FENCE_RE = re.compile(
r"(^|\n)([ \t]{0,3})(```|~~~)(.*?)(\n\2\3[ \t]*(?:\n|$))", re.DOTALL
)
_INLINE_CODE_RE = re.compile(r"(`+)([^`\n]*?)\1")
_FENCE_SENTINEL = "\u241f__IHUFT_FENCE_{}__\u241f"
_CODE_SENTINEL = "\u241f__IHUFT_CODE_{}__\u241f"
_MATH_SENTINEL = "\u241f__IHUFT_MATH_{}__\u241f"
def _shield_code(text: str):
"""Replace fenced/inline code spans with sentinels so math regexes leave
them alone. Returns ``(shielded_text, list_of_originals_for_fences,
list_of_originals_for_inline_code)``."""
fences: list[str] = []
inline_codes: list[str] = []
def _fence_repl(match: re.Match[str]) -> str:
# Preserve the leading newline (or start-of-string) prefix from the
# match so block boundaries are unchanged.
prefix = match.group(1)
full = match.group(0)[len(prefix) :]
idx = len(fences)
fences.append(full)
return f"{prefix}{_FENCE_SENTINEL.format(idx)}"
text = _FENCE_RE.sub(_fence_repl, text)
def _code_repl(match: re.Match[str]) -> str:
idx = len(inline_codes)
inline_codes.append(match.group(0))
return _CODE_SENTINEL.format(idx)
text = _INLINE_CODE_RE.sub(_code_repl, text)
return text, fences, inline_codes
def _unshield_code(text: str, fences: list[str], inline_codes: list[str]) -> str:
"""Reverse of ``_shield_code`` — used after math substitution."""
for idx, original in enumerate(inline_codes):
text = text.replace(_CODE_SENTINEL.format(idx), original, 1)
for idx, original in enumerate(fences):
text = text.replace(_FENCE_SENTINEL.format(idx), original, 1)
return text
# ---------------------------------------------------------------------------
# KaTeX batch call
# ---------------------------------------------------------------------------
class KatexRenderError(RuntimeError):
"""Raised when the Node subprocess itself fails (not when a single
equation fails to render — those produce a per-item ``ok: false`` entry
in the JSON response)."""
def _render_batch(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Invoke ``scripts/render_math.js`` once with the entire batch."""
if not items:
return []
if not _RENDER_SCRIPT.exists():
raise KatexRenderError(
f"KaTeX render script not found at {_RENDER_SCRIPT}. "
"Did you forget to install KaTeX into node_tools/?"
)
env = os.environ.copy()
env["NODE_PATH"] = str(_NODE_MODULES)
proc = subprocess.run(
["node", str(_RENDER_SCRIPT)],
input=json.dumps(items),
capture_output=True,
text=True,
env=env,
cwd=str(_PROJECT_ROOT),
)
if proc.returncode != 0:
raise KatexRenderError(
f"KaTeX subprocess failed with code {proc.returncode}:\n"
f"STDERR:\n{proc.stderr}\n"
f"STDOUT (truncated):\n{proc.stdout[:500]}"
)
try:
return json.loads(proc.stdout)
except json.JSONDecodeError as exc:
raise KatexRenderError(
f"KaTeX subprocess produced invalid JSON: {exc}\n"
f"STDOUT (truncated):\n{proc.stdout[:500]}"
) from exc
# ---------------------------------------------------------------------------
# HTML wrapping
# ---------------------------------------------------------------------------
def _html_escape(text: str) -> str:
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;")
)
# ---------------------------------------------------------------------------
# Copy-to-clipboard button for display math
# ---------------------------------------------------------------------------
#
# The button itself carries no text — only an inline SVG clipboard glyph
# styled in CSS to match the surrounding text colour by default and the
# Material accent colour on hover. A small CSS ``::after`` pseudo-element
# (see ``docs/stylesheets/extra.css``) supplies the "Copy LaTeX" /
# "Copied!" tooltip and the success-state feedback driven by the
# ``.is-copied`` class that ``docs/javascripts/math_copy.js`` toggles on
# click. The SVG is the standard "content_copy" Material Design icon.
_CLIPBOARD_SVG = (
'<svg class="math-copy__icon" '
'xmlns="http://www.w3.org/2000/svg" '
'viewBox="0 0 24 24" width="16" height="16" '
'fill="none" stroke="currentColor" stroke-width="2" '
'stroke-linecap="round" stroke-linejoin="round" '
'aria-hidden="true" focusable="false">'
'<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>'
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1">'
"</path>"
"</svg>"
)
_CHECK_SVG = (
'<svg class="math-copy__check" '
'xmlns="http://www.w3.org/2000/svg" '
'viewBox="0 0 24 24" width="16" height="16" '
'fill="none" stroke="currentColor" stroke-width="2.5" '
'stroke-linecap="round" stroke-linejoin="round" '
'aria-hidden="true" focusable="false">'
'<polyline points="20 6 9 17 4 12"></polyline>'
"</svg>"
)
def _wrap_display(katex_html: str, latex_source: str) -> str:
"""Wrap a rendered display equation with a click-to-copy button.
The button reads ``data-latex`` from the wrapping ``<div>`` (set
immediately below) and is driven by ``docs/javascripts/math_copy.js``.
The KaTeX MathML continues to carry the LaTeX source in its
standards-compliant ``<annotation encoding="application/x-tex">``
element, so the source is recoverable even with JavaScript disabled.
"""
safe_src = _html_escape(latex_source.strip())
return (
f'<div class="math math-display" markdown="0" data-latex="{safe_src}">'
f"{katex_html}"
'<button class="math-copy" type="button" '
'aria-label="Copy LaTeX source for this equation" '
'data-tooltip="Copy LaTeX">'
f"{_CLIPBOARD_SVG}{_CHECK_SVG}"
"</button>"
"</div>"
)
def _wrap_inline(katex_html: str, latex_source: str) -> str:
"""Wrap a rendered inline equation.
Inline math does not get a visible copy button — placing one beside
every ``$…$`` would shred prose flow. Instead, the ``data-latex``
attribute feeds a CSS-only ``::after`` hover tooltip (see
``docs/stylesheets/extra.css``) that reveals the LaTeX source
immediately on mouseover, without the 1-1.5 s delay that browsers
impose on the native ``title=`` attribute. The MathML
``<annotation>`` element continues to carry the source for
programmatic access by screen readers and scripts.
"""
safe_src = _html_escape(latex_source.strip())
return f'<span class="math math-inline" data-latex="{safe_src}">{katex_html}</span>'
def _fallback_display(latex_source: str, error: str) -> str:
safe_src = _html_escape(latex_source.strip())
safe_err = _html_escape(error)
return (
'<div class="math math-display math-fallback" markdown="0" '
f'title="KaTeX error: {safe_err}">'
f'<pre><code class="language-latex">{safe_src}</code></pre>'
"</div>"
)
def _fallback_inline(latex_source: str, error: str) -> str:
safe_src = _html_escape(latex_source.strip())
safe_err = _html_escape(error)
return (
f'<code class="math math-inline math-fallback" '
f'title="KaTeX error: {safe_err}">${safe_src}$</code>'
)
# ---------------------------------------------------------------------------
# Main hook
# ---------------------------------------------------------------------------
def on_page_markdown(markdown, page, config, files): # noqa: ARG001 (mkdocs API)
if "$" not in markdown:
return markdown
# 1. Shield fenced and inline code so we don't replace math inside them.
shielded, fences, codes = _shield_code(markdown)
# 2. Extract every math span, replacing it with a sentinel.
items: list[dict[str, Any]] = []
def _grab_display(match: re.Match[str]) -> str:
idx = len(items)
items.append({"tex": match.group(1).strip(), "display": True})
return _MATH_SENTINEL.format(idx)
def _grab_inline(match: re.Match[str]) -> str:
idx = len(items)
items.append({"tex": match.group(1), "display": False})
return _MATH_SENTINEL.format(idx)
shielded = _DISPLAY_RE.sub(_grab_display, shielded)
shielded = _INLINE_RE.sub(_grab_inline, shielded)
if not items:
# Nothing to render — restore code spans and return unchanged.
return _unshield_code(shielded, fences, codes)
# 3. Render the batch in a single KaTeX call.
try:
results = _render_batch(items)
except KatexRenderError as exc:
print(f"math_prerender: {exc}", file=sys.stderr)
raise
# 4. Substitute rendered HTML back in place of the sentinels.
for idx, (item, result) in enumerate(zip(items, results, strict=True)):
sentinel = _MATH_SENTINEL.format(idx)
if result.get("ok"):
html = result["html"]
wrapper = _wrap_display if item["display"] else _wrap_inline
replacement = wrapper(html, item["tex"])
else:
err = result.get("error", "<unknown KaTeX error>")
print(
f"math_prerender: KaTeX failed on equation #{idx} "
f"({item['tex'][:60]!r}): {err}",
file=sys.stderr,
)
fallback = _fallback_display if item["display"] else _fallback_inline
replacement = fallback(item["tex"], err)
# Display math should sit on its own line so Markdown treats the
# surrounding HTML as a block element.
if item["display"]:
replacement = "\n\n" + replacement + "\n\n"
shielded = shielded.replace(sentinel, replacement, 1)
# 5. Restore code spans.
return _unshield_code(shielded, fences, codes)