moss_core/ast/math_text.rs
1//! The P1 math fallback node and its inverse.
2//!
3//! P1's contract is that math is **never silently deleted**. Two shapes
4//! carry an equation through the AST, and this module owns both plus the
5//! conversion between them, so the format and its parser cannot drift:
6//!
7//! | Shape | Built by | Used where |
8//! |---|---|---|
9//! | `<code class="moss-math" data-moss-math="…">` in [`Inline::Other`] | [`math_inline`] | rendered HTML |
10//! | `$…$` / `$$…$$` markdown source | [`math_source`] | every plain-text collector |
11//!
12//! **Why plain-text collectors get the source and not bare TeX.** pulldown
13//! hands a walker the *inner* TeX only — delimiters stripped, inner bytes
14//! otherwise untouched — so `$` + tex + `$` reproduces the author's
15//! original bytes exactly. Restoring the delimiters is what keeps three
16//! independent surfaces in agreement:
17//!
18//! 1. **The math=off slug.** `# Euler $e^{i\pi}=-1$ identity` must produce
19//! the same `<h1 id>` whether or not `[site].math` is on, or flipping
20//! the flag silently breaks every existing deep link, in-page TOC entry
21//! and `[[Page#Heading]]` wikilink pointing at that heading.
22//! 2. **The wikilink graph.** `build/scan/scan.rs` slugs headings off the
23//! RAW heading line; being a line scanner it cannot know where math
24//! begins, so it always includes the `$` bytes. The render side has to
25//! match it, or the graph resolves a link to a fragment the page lacks.
26//! 3. **`heading::extract`**, whose module doc calls byte-identity with the
27//! rendered `<hN id>` "the keystone invariant".
28//!
29//! Bare TeX would satisfy none of the three. Markup would be actively wrong
30//! in these contexts — `alt` is an HTML attribute and the slug feeds a URL
31//! fragment.
32
33use super::hooks::escape_text;
34use super::node::Inline;
35
36const PREFIX_INLINE: &str = r#"<code class="moss-math" data-moss-math="inline">"#;
37const PREFIX_DISPLAY: &str = r#"<code class="moss-math" data-moss-math="display">"#;
38const SUFFIX: &str = "</code>";
39
40/// Build the P1 math fallback node: the equation's own markdown source —
41/// delimiters included — HTML-escaped, in a marked `<code>` span.
42///
43/// The `data-moss-math` attribute carries display-vs-inline so a later
44/// phase's renderer can typeset from the AST without re-deriving it, and so
45/// the CSS can size display math differently without a second class.
46///
47/// **Why the `$` delimiters are kept.** P1 ships no typesetting engine, so
48/// this span is what the reader actually sees. Emitting the bare inner TeX
49/// would silently swallow two characters of the author's prose, which is the
50/// same content-loss P1 exists to prevent — just moved from "equation
51/// deleted" to "delimiters deleted". It is invisible for a real equation and
52/// destructive for a false positive:
53///
54/// ```text
55/// 一个$5,两个$10 bare TeX → 一个5,两个10 (prices corrupted)
56/// source → 一个$5,两个$10 (byte-identical)
57/// ```
58///
59/// pulldown's close rule fires on any non-whitespace byte, so unspaced CJK
60/// currency parses as math (`moss doctor --math` exists to surface exactly
61/// this), and `[site].math` defaults on — the false positive is the case to
62/// optimize for. Keeping the delimiters also makes this node agree with
63/// [`math_source`], so an equation has ONE spelling across the body, image
64/// alt text, heading slugs and meta descriptions instead of two.
65///
66/// P2/P3 replace this span with typeset SVG, at which point the delimiters
67/// disappear along with the fallback.
68pub(crate) fn math_inline(tex: &str, display: bool) -> Inline {
69 let prefix = if display { PREFIX_DISPLAY } else { PREFIX_INLINE };
70 Inline::Other(format!("{prefix}{}{SUFFIX}", escape_text(&math_source(tex, display))))
71}
72
73/// Reconstruct an equation's markdown source — the TeX with its `$` / `$$`
74/// delimiters restored — from the inner TeX pulldown hands the walker.
75///
76/// `pub` because plain-text collectors are not confined to this crate: the
77/// email walker in `moss::infra::newsletter` builds an image `alt` attribute
78/// and needs the same restored-delimiter form, or the two surfaces disagree
79/// about what an equation looks like in plain text.
80pub fn math_source(tex: &str, display: bool) -> String {
81 let delim = if display { "$$" } else { "$" };
82 format!("{delim}{tex}{delim}")
83}
84
85/// Recover the markdown source of a math node from an [`Inline::Other`]
86/// payload, or `None` if the payload is some other raw-HTML passthrough.
87///
88/// The inverse of [`math_inline`]. Plain-text walkers that see the AST
89/// rather than the event stream (`heading::text::inlines_to_text`, the
90/// crate's one AST walker — `extract_hero` delegates to it) have no access
91/// to the original event, so recovering from the node is the only way to
92/// honor P1's never-silently-delete contract. Round-tripping is pinned by
93/// `math_source_round_trips_through_the_node` below — that test is what
94/// keeps this from drifting away from the builder three lines above it.
95pub(crate) fn math_source_from_other(html: &str) -> Option<String> {
96 let prefix = if html.starts_with(PREFIX_DISPLAY) {
97 PREFIX_DISPLAY
98 } else if html.starts_with(PREFIX_INLINE) {
99 PREFIX_INLINE
100 } else {
101 return None;
102 };
103 // The node's payload is ALREADY the delimited source — `math_inline`
104 // builds it with `math_source` — so this only has to unescape. Re-adding
105 // the delimiters here would double them (`$$E=mc^2$$` for inline math).
106 Some(
107 html.strip_prefix(prefix)?
108 .strip_suffix(SUFFIX)?
109 // Only the three characters `escape_text` writes, unescaped in the
110 // order that makes `&lt;` come back as the literal `<`
111 // rather than as `<`.
112 .replace("<", "<")
113 .replace(">", ">")
114 .replace("&", "&"),
115 )
116}
117
118/// Decode a math [`Inline::Other`] node into `(inner_tex, display)` — the raw
119/// LaTeX the author typed (delimiters stripped, unescaped) and whether it was
120/// `$$…$$` (display) or `$…$` (inline), or `None` for any non-math passthrough.
121///
122/// This is what lets the renderer route a math node through
123/// [`RenderHooks::render_math`](crate::ast::RenderHooks::render_math) without a
124/// dedicated `Inline::Math` AST variant (ADR-030 D3): the P1 node already
125/// carries the source verbatim, so P2's typesetter recovers the exact bytes the
126/// engine needs. Round-tripping `math_inline` → this is pinned by
127/// `node_parts_round_trips` below.
128pub(crate) fn math_node_parts(html: &str) -> Option<(String, bool)> {
129 let source = math_source_from_other(html)?;
130 // `math_source_from_other` returns the *delimited* source ($tex$ / $$tex$$);
131 // `math_source_from_other` already told prefix-vs-display via the same
132 // prefix check, so recover `display` the same way and strip the matching
133 // delimiter off both ends. `$$` before `$` so display is not mis-read.
134 let display = html.starts_with(PREFIX_DISPLAY);
135 let delim = if display { "$$" } else { "$" };
136 let inner = source
137 .strip_prefix(delim)
138 .and_then(|s| s.strip_suffix(delim))?;
139 Some((inner.to_string(), display))
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn node_parts_round_trips() {
148 for tex in ["E=mc^2", "a < b", "S = \\{x : x > 0\\}", "\\frac{a}{b}"] {
149 for display in [false, true] {
150 let Inline::Other(html) = math_inline(tex, display) else {
151 panic!("math_inline must build an Inline::Other");
152 };
153 assert_eq!(
154 math_node_parts(&html),
155 Some((tex.to_string(), display)),
156 "round-trip failed for {tex:?} display={display}"
157 );
158 }
159 }
160 }
161
162 #[test]
163 fn node_parts_rejects_non_math() {
164 assert_eq!(math_node_parts("<div>hi</div>"), None);
165 assert_eq!(math_node_parts("<code>plain</code>"), None);
166 }
167
168 #[test]
169 fn source_restores_delimiters() {
170 assert_eq!(math_source("E=mc^2", false), "$E=mc^2$");
171 assert_eq!(math_source("a+b", true), "$$a+b$$");
172 }
173
174 #[test]
175 fn math_source_round_trips_through_the_node() {
176 // Every TeX here exercises a character `escape_text` rewrites, so a
177 // change to either side of the escape pair fails this test.
178 for tex in ["E=mc^2", "a < b", "a > b", "x & y", "S = \\{x : x > 0\\}"] {
179 for display in [false, true] {
180 let Inline::Other(html) = math_inline(tex, display) else {
181 panic!("math_inline must build an Inline::Other");
182 };
183 assert_eq!(
184 math_source_from_other(&html).as_deref(),
185 Some(math_source(tex, display).as_str()),
186 "round-trip failed for {tex:?} display={display}"
187 );
188 }
189 }
190 }
191
192 #[test]
193 fn non_math_passthrough_is_not_claimed() {
194 assert_eq!(math_source_from_other("<div>hello</div>"), None);
195 assert_eq!(math_source_from_other("<code>plain</code>"), None);
196 // A `moss-math-error` node (P2) must not be mistaken for a fallback.
197 assert_eq!(
198 math_source_from_other(r#"<code class="moss-math-error">x</code>"#),
199 None
200 );
201 }
202}