Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/rules/cross-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ Never use platform-specific APIs unconditionally:

When writing tests that need platform-specific setup (e.g. making a script executable), create a helper with `#[cfg(unix)]` and `#[cfg(not(unix))]` variants.

## Test helper cfg gates

Gate each test helper with the same conditions as its callers. A `#[cfg(test)]` helper called only from `#[cfg(all(test, unix))]` code is dead code on Windows, so `-D warnings` causes the build to fail. Windows is not in CI, so other developers may not catch this failure. Use `#[allow(dead_code)]` only as a last resort and explain why it is needed. Note that `pub` items are exempt from the `dead_code` lint. A `pub` helper and a `pub(crate)` helper with identical callers can therefore fail differently; don't assume that one is valid because the other compiles.

Before applying `#[cfg(unix)]` to a test, separate the portable behavior from the platform-specific setup and assertions. For example, a loopback TCP handshake works on every platform, but a `kill -0` liveness check does not. Gate only the platform-specific code. Prefer a `#[cfg(unix)]`/`#[cfg(not(unix))]` helper pair so the test still runs on all three platforms. See `spawn_long_lived_child` and `echo_stdin_to_stderr_cmd` in `crates/quarto-core/src/engine/ts_process.rs` for examples of both patterns.

When gated code changes, update the comment that explains why the gate is needed. A stale explanation can lead someone to widen or copy an unnecessary gate.

## File paths

- Use `std::path::Path`/`PathBuf`, never hardcode `/` or `\` separators
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions claude-notes/designs/transform-pipeline-phases.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,41 @@ it never mentions `revealjs`, only phases.

---

## The post-filter presentation slot (stages, not transforms)

Every Finalization transform runs inside `AstTransformsStage`, which the
HTML stage list places **between** `UserFiltersStage::pre` and
`UserFiltersStage::post`. So a Finalization transform, however late, runs
*before* any user post filter. That is the right place for most
presentation work, but not for a step whose intermediate representation is
part of the filter-author contract.

The concrete case is equation numbering (bd-vlhi2zkj). `crossref-render`
numbers an equation but does not choose how the number is typeset: `\tag{N}`
is an `amsmath` command only MathJax and KaTeX read, so a MathML converter
(or no engine at all) needs ` \qquad(N)` or a label outside the math. The
transform records the number on the reserved span attribute
`quarto-eq-number` and leaves the TeX alone; `EquationNumberStage` picks the
encoding from the format and `html-math-method` and removes the attribute.
Had that stage been a Finalization transform, a Lua post filter could never
see the attribute: it would already be gone. As a stage after
`UserFiltersStage::post` it can be read, rewritten or deleted from Lua
(`el.attributes["quarto-eq-number"]`), which is the escape hatch we want.

**Rule:** a format-specific step that consumes crossref output belongs in
`Finalization` (the author rule above) **unless** its input is a reserved
attribute or node that user post filters are meant to see; then it is a
*stage* between `UserFiltersStage::post` and `RenderHtmlBodyStage`, next to
`CodeHighlightStage` (AST-level annotation, same slot, same reason) and
`EquationNumberStage`. Such a stage still satisfies the invariant's intent:
it runs after every transform, so all crossref structure is final. It is
not covered by the transform ordering test; document the placement at the
`stages.push` site and cover it with an end-to-end test that runs a post
filter against the attribute (see
`crates/quarto-core/tests/integration/equation_numbering_pipeline.rs`).

---

## The preview-pipeline shape contract (the anti-recurrence rule)

The bug had a deeper enabler worth stating as its own rule.
Expand Down
55 changes: 46 additions & 9 deletions claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Format-specific equation numbering and `html-math-method: mathml`

**Status:** approved 2026-09-21 (all five open decisions settled with the
user, each as recommended); executing Phase 1.
user, each as recommended). Phases 1–3 implemented the same day. PRs: #708 (Phase 1,
against `main`), #709 (Phase 2, stacked on #706) and #710 (Phase 3, stacked
on #709), the last two linked into the GitHub stack #705 → #706 → #709 → #710.
**Strands:** bd-vlhi2zkj (Phase 1, equation numbering; branch
`braid/bd-vlhi2zkj-equation-numbering` off `main`), bd-9z83tcv0 (Phase 2,
MathML writer, p2), bd-3evfzwal (Phase 3, `MathMlStage`; blocked on the
Expand Down Expand Up @@ -273,42 +275,77 @@ as its base, filed as bd-0mzhnxft.

### Phase 3 — `MathMlStage` for `format: html` (new strand, stacked on #706 + Phase 1)

- [ ] **End-to-end tests in `math_mode_pipeline.rs`**: `html-math-method:
- [x] (2026-09-21; 5 tests appended to `math_mode_pipeline.rs`) **End-to-end tests in `math_mode_pipeline.rs`**: `html-math-method:
mathml` with inline + display + numbered math → `<math` present, the
`\(`/`\[` delimiters absent, no MathJax/KaTeX loader, the numbered
equation carries its sibling label; an expression with an unknown
command → verbatim TeX span retained, a `Q-22-1` warning in the
render diagnostics, and (hybrid, see decisions) the MathJax loader
present; a math-free document → no `<math`, no loader; a website with
one mathml page and one math-free page.
- [ ] **Stage unit tests**: converts `Inline::Math` inside paragraphs,
- [x] (2026-09-21; 5 tests in `math_ml.rs`; the walker is now the shared `crate::ast_walk::for_each_inline_mut`, which `EquationNumberStage` uses too) **Stage unit tests**: converts `Inline::Math` inside paragraphs,
headers, list items, table cells and `CustomNode` slots (reuse the
walker shape of `doc_has_math`); passes `Math.text_source` (falling
back to `source_info`) so diagnostics point into the `.qmd`; leaves
failed expressions as `Inline::Math`.
- [ ] **`MathJsStage` hybrid test**: for method `mathml`, injection happens
- [x] (2026-09-21) **`MathJsStage` hybrid test**: for method `mathml`, injection happens
iff an `Inline::Math` survives the MathML stage.
- [ ] Implement `crates/quarto-core/src/stage/stages/math_ml.rs`, registered
- [x] (2026-09-21; conversion errors are downgraded to warnings since the page still renders via MathJax; quarto-core now depends on quarto-math) Implement `crates/quarto-core/src/stage/stages/math_ml.rs`, registered
right after `EquationNumberStage`, gated on `MathMethod::MathMl`, using
`quarto_math::convert(text, mode, Target::MathMl, &text_source,
Spec::builtin())`, `ctx.add_diagnostics` for every conversion.
Add `"math-ml"` to `Q2_PREVIEW_STAGE_EXCLUDED` (the preview renders
`Inline::Math` with KaTeX client-side).
- [ ] `MathEngine::from_meta` (via `MathMethod`): `MathMl` maps to the
- [x] (2026-09-21) `MathEngine::from_meta` (via `MathMethod`): `MathMl` maps to the
MathJax default engine when leftovers exist, `None` otherwise.
- [ ] Docs: new `docs/guides/formats/html/math.qmd` documenting
- [x] (2026-09-21; linked from `Q-22-1`'s page; the HTML format guides are not in the docs sidebar today, same as `themes.qmd`) Docs: new `docs/guides/formats/html/math.qmd` documenting
`html-math-method` (`mathjax` default, `katex`, `mathml` and its
browser/font caveats, the hybrid fallback and the `Q-22-*` warnings);
link `Q-22-1`'s page to it.
- [ ] **End-to-end browser verification** (required by CLAUDE.md): render
- [x] (2026-09-21, see below) **End-to-end browser verification** (required by CLAUDE.md): render
the probe document with `cargo run --bin q2 -- render`, open it in a
real browser (Chrome MCP, or the headless Playwright fallback), confirm
`document.querySelector('math')` has a non-zero box and the label sits
on the right of the numbered equation; screenshot. Record invocation
and output snippet here.
- [ ] Full `cargo xtask verify` (quarto-core changed; hub-client WASM leg
- [x] (2026-09-21: full `cargo xtask verify` green — 14248 Rust tests, ts-packages, hub-client build incl. the WASM leg with quarto-math linked in, hub-client tests) Full `cargo xtask verify` (quarto-core changed; hub-client WASM leg
picks up the new quarto-math code path).

**End-to-end verification (2026-09-21, real binary + headless Chromium,
output inspected).** Probe document with `html-math-method: mathml`:
inline `$x^2 + \frac{a}{b}$` and `$\alpha \leq \beta$`, a numbered
display equation (`\sum … \int …` with `{#eq-one}`), a display block with
`pmatrix` + `cases`, an `@eq-one` reference, and one deliberately
unconvertible `$x + \bogus y$`. `cargo run --bin q2 -- render doc.qmd`
prints exactly one diagnostic:

```
Warning: [Q-22-1] Unknown Math Command
╭─[ doc.qmd:20:40 ]
20 │ See @eq-one. This one falls back: $x + \bogus y$.
│ ───┬──
│ ╰──── unknown command `\bogus`
```

The HTML has four `<math xmlns=…>` elements (two `display="block"`), the
numbered equation as
`…</math></span><span class="quarto-eq-number">(1)</span></span>`, the
leftover as `<span class="math inline">\(x + \bogus y\)</span>`, and one
MathJax config whose `skipHtmlTags` now lists `annotation`. Headless
Chromium (Playwright, MathJax allowed to load from the CDN) reports: the
four native `<math>` boxes have heights 22/16/27/39 px; exactly one
MathJax-typeset element exists (`x+\bogus y`); the `(1)` label sits to
the right of the equation, flush with the row's right edge, vertically
centred on it. Screenshot reviewed: fractions, the sum with under/over
limits, the integral with side limits, the fenced matrix and the cases
brace all render natively.

The first browser pass caught a real bug: q2's MathJax config overrode
MathJax's default `skipHtmlTags`, dropping `annotation`, so the fallback
loader re-typeset the `\begin{pmatrix}`/`\begin{cases}` text inside the
converted math's annotations (zero-size assistive MathML in the DOM).
Fixed with a unit test on the config and an end-to-end assertion.

## Decisions (all settled 2026-09-21, each as recommended)

1. **Attribute key.** `quarto-eq-number` as proposed, or a shorter
Expand Down
2 changes: 2 additions & 0 deletions crates/quarto-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ pampa = { workspace = true, features = ["lua-filter", "json-filter"] }
# `quarto-highlight` crate itself to avoid compiling wasmtime for the
# wasm32-unknown-unknown target.
quarto-highlight.workspace = true
# `MathMlStage` (html-math-method: mathml) converts math at render time.
quarto-math = { path = "../quarto-math" }

# Used by the listings module's `b64_encode_unicode` helper (mirroring
# Q1's `b64EncodeUnicode` for the categories click handler). Available
Expand Down
151 changes: 151 additions & 0 deletions crates/quarto-core/src/ast_walk.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
* ast_walk.rs
* Copyright (c) 2026 Posit, PBC
*/

//! Mutable traversal of every inline in a document.
//!
//! Several post-filter stages need to visit each `Inline` wherever it
//! sits — paragraph, header, list item, table cell, caption, footnote, or
//! a custom node's slots — and either edit it in place or replace it.
//! This is that walk, written once. It is pre-order: `f` sees a node
//! before the walk descends into the node's own content, so a
//! replacement's children are visited too.

use quarto_pandoc_types::block::Block;
use quarto_pandoc_types::custom::Slot;
use quarto_pandoc_types::inline::{Inline, Inlines};

/// Call `f` on every inline under `blocks`, in document order.
pub fn for_each_inline_mut(blocks: &mut [Block], f: &mut dyn FnMut(&mut Inline)) {
for block in blocks.iter_mut() {
visit_block(block, f);
}
}

fn visit_block(block: &mut Block, f: &mut dyn FnMut(&mut Inline)) {
match block {
Block::Plain(p) => visit_inlines(&mut p.content, f),
Block::Paragraph(p) => visit_inlines(&mut p.content, f),
Block::LineBlock(lb) => {
for line in lb.content.iter_mut() {
visit_inlines(line, f);
}
}
Block::BlockQuote(bq) => for_each_inline_mut(&mut bq.content, f),
Block::OrderedList(ol) => {
for item in ol.content.iter_mut() {
for_each_inline_mut(item, f);
}
}
Block::BulletList(bl) => {
for item in bl.content.iter_mut() {
for_each_inline_mut(item, f);
}
}
Block::DefinitionList(dl) => {
for (term, defs) in dl.content.iter_mut() {
visit_inlines(term, f);
for def in defs.iter_mut() {
for_each_inline_mut(def, f);
}
}
}
Block::Header(h) => visit_inlines(&mut h.content, f),
Block::Div(d) => for_each_inline_mut(&mut d.content, f),
Block::Figure(fig) => {
if let Some(short) = fig.caption.short.as_mut() {
visit_inlines(short, f);
}
if let Some(long) = fig.caption.long.as_mut() {
for_each_inline_mut(long, f);
}
for_each_inline_mut(&mut fig.content, f);
}
Block::Table(t) => {
if let Some(short) = t.caption.short.as_mut() {
visit_inlines(short, f);
}
if let Some(long) = t.caption.long.as_mut() {
for_each_inline_mut(long, f);
}
for row in t.head.rows.iter_mut().chain(t.foot.rows.iter_mut()) {
for cell in row.cells.iter_mut() {
for_each_inline_mut(&mut cell.content, f);
}
}
for body in t.bodies.iter_mut() {
for row in body.head.iter_mut().chain(body.body.iter_mut()) {
for cell in row.cells.iter_mut() {
for_each_inline_mut(&mut cell.content, f);
}
}
}
}
Block::CaptionBlock(cb) => visit_inlines(&mut cb.content, f),
Block::Custom(c) => {
for (_name, slot) in c.slots.iter_mut() {
visit_slot(slot, f);
}
}
Block::CodeBlock(_)
| Block::RawBlock(_)
| Block::HorizontalRule(_)
| Block::BlockMetadata(_)
| Block::NoteDefinitionPara(_)
| Block::NoteDefinitionFencedBlock(_) => {}
}
}

fn visit_inlines(inlines: &mut Inlines, f: &mut dyn FnMut(&mut Inline)) {
for inline in inlines.iter_mut() {
visit_inline(inline, f);
}
}

fn visit_inline(inline: &mut Inline, f: &mut dyn FnMut(&mut Inline)) {
f(inline);
match inline {
Inline::Emph(e) => visit_inlines(&mut e.content, f),
Inline::Underline(u) => visit_inlines(&mut u.content, f),
Inline::Strong(s) => visit_inlines(&mut s.content, f),
Inline::Strikeout(s) => visit_inlines(&mut s.content, f),
Inline::Superscript(s) => visit_inlines(&mut s.content, f),
Inline::Subscript(s) => visit_inlines(&mut s.content, f),
Inline::SmallCaps(s) => visit_inlines(&mut s.content, f),
Inline::Quoted(q) => visit_inlines(&mut q.content, f),
Inline::Link(l) => visit_inlines(&mut l.content, f),
Inline::Image(i) => visit_inlines(&mut i.content, f),
Inline::Span(s) => visit_inlines(&mut s.content, f),
Inline::Note(n) => for_each_inline_mut(&mut n.content, f),
Inline::Insert(i) => visit_inlines(&mut i.content, f),
Inline::Delete(d) => visit_inlines(&mut d.content, f),
Inline::Highlight(h) => visit_inlines(&mut h.content, f),
Inline::Custom(c) => {
for (_name, slot) in c.slots.iter_mut() {
visit_slot(slot, f);
}
}
Inline::Str(_)
| Inline::Cite(_)
| Inline::Code(_)
| Inline::Space(_)
| Inline::SoftBreak(_)
| Inline::LineBreak(_)
| Inline::Math(_)
| Inline::RawInline(_)
| Inline::Shortcode(_)
| Inline::NoteReference(_)
| Inline::Attr(_)
| Inline::EditComment(_) => {}
}
}

fn visit_slot(slot: &mut Slot, f: &mut dyn FnMut(&mut Inline)) {
match slot {
Slot::Block(b) => visit_block(b, f),
Slot::Blocks(bs) => for_each_inline_mut(bs, f),
Slot::Inline(i) => visit_inline(i, f),
Slot::Inlines(is) => visit_inlines(is, f),
}
}
16 changes: 16 additions & 0 deletions crates/quarto-core/src/crossref/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ pub const PROOF: &str = "Proof";
/// `plain_data.order` (set by the indexer); the id prefix is always `"eq"`.
pub const EQUATION: &str = "Equation";

/// The reserved attribute `CrossrefRenderTransform` writes on a rendered
/// equation span (`Span#eq-… .quarto-math-with-attribute`) to carry the
/// equation's number, as text (`"1"`).
///
/// This is the hand-off between format-agnostic numbering and
/// format-specific *encoding* of the number: crossref-render leaves the
/// `Math` text byte-identical to the source and records the number here;
/// `EquationNumberStage` (which runs after user post filters) turns it into
/// `\tag{N}` for MathJax/KaTeX, ` \qquad(N)` for engines that only read
/// math, or a sibling label for MathML, and removes the attribute. Between
/// the two, a Lua post filter can read, rewrite or delete
/// `el.attributes["quarto-eq-number"]` — the supported way to customize
/// equation numbers from a filter. Plan:
/// `claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md`.
pub const EQ_NUMBER_ATTR: &str = "quarto-eq-number";

/// The `type_name` used on `CustomNode` for resolved crossref references in
/// the front-end AST.
///
Expand Down
Loading