moss_core/ast/footnotes.rs
1//! Footnotes: numbering, and the endnote section.
2//!
3//! One module owns the whole feature because its two halves must agree by
4//! construction. The parser gives us `Inline::FootnoteRef(label)` and
5//! `Block::FootnoteDefinition { label, .. }`; everything a reader sees —
6//! the printed number, the marker id, the endnote `<li>`, the back-link —
7//! is derived HERE, at render time, from the document as a whole. Split
8//! across two modules, the marker id and the back-link href drift, and a
9//! dangling `#fnref-3` is invisible in a diff.
10//!
11//! Numbering is FIRST-REFERENCE order, not source order. Scope is the whole
12//! block tree except shortcode bodies, which render through
13//! [`super::render::render_blocks`] as their own little documents. See
14//! ADR-035 for the three call paths and why they differ.
15
16use std::collections::{HashMap, HashSet};
17use std::fmt::Write as _;
18
19use super::hooks::{escape_text, RenderHooks};
20use super::node::{Block, Inline};
21use super::render::{render_blocks_with, render_inlines};
22
23/// The document's footnotes, numbered.
24///
25/// Numbering is **first-reference order** — the order a reader meets the
26/// markers, not the order the author wrote the definitions. It is derived
27/// state, deliberately not stored on the AST (same rule as
28/// [`super::node::ColumnAlignment`]'s numeric auto-alignment): the same
29/// `Block::FootnoteDefinition` renders differently depending on which
30/// document it is part of, so the number cannot belong to the node.
31///
32/// Scope: the whole block tree EXCEPT shortcode bodies. A `:::grid` cell or
33/// `:::hero` overlay is parsed and rendered as its own little document
34/// through [`super::render::render_blocks`], which carries no index — see
35/// ADR-035 for why the three call paths differ.
36#[derive(Debug, Default, Clone, PartialEq, Eq)]
37pub struct FootnoteIndex {
38 numbers: std::collections::HashMap<String, usize>,
39 order: Vec<(usize, String)>,
40}
41
42impl FootnoteIndex {
43 /// Build the index for a block tree. Empty when the tree defines no
44 /// footnotes, which is the overwhelmingly common case.
45 pub fn build(blocks: &[Block]) -> Self {
46 let mut defined: Vec<String> = Vec::new();
47 collect_definitions(blocks, &mut defined, &mut HashSet::new());
48 if defined.is_empty() {
49 return Self::default();
50 }
51 let defined_set: HashSet<&str> = defined.iter().map(String::as_str).collect();
52
53 let mut order: Vec<String> = Vec::new();
54 let mut seen: HashSet<String> = HashSet::new();
55 let hoisted = first_definition_bodies(blocks, &defined);
56 collect_refs(blocks, &mut order, &mut seen, &hoisted);
57 // From here the reader is reading the endnote section top to
58 // bottom, and the numbering follows that reading. A HOISTED note's
59 // body can itself carry a marker (`[^1]: see [^2]`); those markers
60 // are met inside the endnote list, so they extend the order rather
61 // than seeding it. When the walked list runs out and
62 // never-referenced definitions remain (GFM drops these; moss
63 // numbers them after the referenced notes, in source order, so the
64 // author's text is never silently deleted — ADR-035), the reader's
65 // next stop is the first such note's body — so it joins the walk
66 // right there, not after the loop ends. Appending the tail after
67 // the loop mis-numbered a marker met only inside an unreferenced
68 // note's body BELOW notes the reader had not reached yet. (A
69 // REPEAT's body renders in place, so the main walk above already
70 // collected its markers at the position the reader meets them.)
71 let mut i = 0;
72 loop {
73 if i == order.len() {
74 let Some(next) = defined.iter().find(|l| !seen.contains(l.as_str())) else {
75 break;
76 };
77 seen.insert(next.clone());
78 order.push(next.clone());
79 }
80 if let Some(children) = Self::definition(blocks, &order[i]) {
81 collect_refs(children, &mut order, &mut seen, &hoisted);
82 }
83 i += 1;
84 }
85
86 let mut index = Self::default();
87 for label in order {
88 if !defined_set.contains(label.as_str()) {
89 continue;
90 }
91 let n = index.order.len() + 1;
92 index.numbers.insert(label.clone(), n);
93 index.order.push((n, label));
94 }
95 index
96 }
97
98 /// True when the document has no footnotes to render.
99 pub fn is_empty(&self) -> bool {
100 self.order.is_empty()
101 }
102
103 /// The printed number for `label`, or `None` when no definition in this
104 /// index owns it.
105 pub fn number(&self, label: &str) -> Option<usize> {
106 self.numbers.get(label).copied()
107 }
108
109 /// `(number, label)` pairs in endnote order.
110 pub fn entries(&self) -> &[(usize, String)] {
111 &self.order
112 }
113
114 /// The body blocks of the FIRST definition of `label` in document order,
115 /// at any depth. A repeated label is an authoring error; first wins.
116 pub fn definition<'a>(blocks: &'a [Block], label: &str) -> Option<&'a [Block]> {
117 for block in blocks {
118 let found = match block {
119 Block::FootnoteDefinition { label: l, children } if l == label => {
120 return Some(children)
121 }
122 Block::FootnoteDefinition { children, .. }
123 | Block::BlockQuote(children)
124 | Block::Callout { children, .. }
125 | Block::LinkCard { children, .. } => Self::definition(children, label),
126 Block::List { items, .. } => items
127 .iter()
128 .find_map(|item| Self::definition(item, label)),
129 _ => None,
130 };
131 if found.is_some() {
132 return found;
133 }
134 }
135 None
136 }
137}
138
139/// Collect footnote definition labels in document order, deduplicated.
140/// Descends into every structural container; stops at shortcode bodies.
141fn collect_definitions(blocks: &[Block], out: &mut Vec<String>, seen: &mut HashSet<String>) {
142 for block in blocks {
143 match block {
144 Block::FootnoteDefinition { label, children } => {
145 if seen.insert(label.clone()) {
146 out.push(label.clone());
147 }
148 collect_definitions(children, out, seen);
149 }
150 Block::BlockQuote(children)
151 | Block::Callout { children, .. }
152 | Block::LinkCard { children, .. } => collect_definitions(children, out, seen),
153 Block::List { items, .. } => {
154 for item in items {
155 collect_definitions(item, out, seen);
156 }
157 }
158 _ => {}
159 }
160 }
161}
162
163/// [`first_definition_bodies`] over every label the tree defines, for walks
164/// (e.g. `query.rs`'s cover search) that start from raw blocks rather than a
165/// built index. A label defined only inside a shortcode body is absent —
166/// `collect_definitions` stops there — so such definitions never test as
167/// hoisted, matching the renderer, which never hoists them.
168pub(super) fn hoisted_definition_bodies(blocks: &[Block]) -> HashMap<String, usize> {
169 let mut defined = Vec::new();
170 collect_definitions(blocks, &mut defined, &mut HashSet::new());
171 first_definition_bodies(blocks, &defined)
172}
173
174/// Body address of the doc-order-first definition per label — the identity
175/// [`is_hoisted`] decides by, shared so every walk that must agree with the
176/// renderer answers "is this occurrence hoisted?" from the same lookup.
177fn first_definition_bodies(blocks: &[Block], labels: &[String]) -> HashMap<String, usize> {
178 labels
179 .iter()
180 .filter_map(|label| {
181 FootnoteIndex::definition(blocks, label)
182 .map(|children| (label.clone(), children.as_ptr() as usize))
183 })
184 .collect()
185}
186
187/// Collect footnote marker labels in reading order, deduplicated. Skips
188/// HOISTED definition bodies (the caller walks those in endnote order, where
189/// they read) and shortcode bodies (they render through a separate entry
190/// point) — but descends into a REPEAT's body, which renders in place: its
191/// markers are body markers the reader meets mid-page, and skipping them
192/// numbered such a note after everything else while its printed marker sat
193/// mid-body, out of first-reference order.
194fn collect_refs(
195 blocks: &[Block],
196 out: &mut Vec<String>,
197 seen: &mut HashSet<String>,
198 hoisted: &HashMap<String, usize>,
199) {
200 for block in blocks {
201 match block {
202 Block::Heading { children, .. } | Block::Paragraph(children) => {
203 collect_refs_in_inlines(children, out, seen)
204 }
205 Block::BlockQuote(children)
206 | Block::Callout { children, .. }
207 | Block::LinkCard { children, .. } => collect_refs(children, out, seen, hoisted),
208 Block::FootnoteDefinition { label, children } => {
209 if hoisted.get(label.as_str()) != Some(&(children.as_ptr() as usize)) {
210 collect_refs(children, out, seen, hoisted);
211 }
212 }
213 Block::List { items, .. } => {
214 for item in items {
215 collect_refs(item, out, seen, hoisted);
216 }
217 }
218 Block::Table { header, rows, .. } => {
219 for cell in header.iter().chain(rows.iter().flatten()) {
220 collect_refs_in_inlines(cell, out, seen);
221 }
222 }
223 Block::Figure { caption, .. } => {
224 if let Some(caption) = caption {
225 collect_refs_in_inlines(caption, out, seen);
226 }
227 }
228 _ => {}
229 }
230 }
231}
232
233fn collect_refs_in_inlines(inlines: &[Inline], out: &mut Vec<String>, seen: &mut HashSet<String>) {
234 for inline in inlines {
235 match inline {
236 Inline::FootnoteRef(label) => {
237 if seen.insert(label.clone()) {
238 out.push(label.clone());
239 }
240 }
241 Inline::Emphasis(children)
242 | Inline::Strong(children)
243 | Inline::Strikethrough(children)
244 | Inline::Link { children, .. } => collect_refs_in_inlines(children, out, seen),
245 _ => {}
246 }
247 }
248}
249
250/// Per-render footnote state: the document's [`FootnoteIndex`] plus the
251/// counters that keep marker ids and back-links in step.
252///
253/// Default (empty index) is what every [`super::render::render_blocks`]
254/// entry point carries, so a marker there falls back to its literal source.
255#[derive(Default)]
256pub struct FootnoteCtx {
257 index: FootnoteIndex,
258 /// Markers emitted per label so far. Drives the `fnref-N-K` id suffix
259 /// and, once the whole document is rendered, the number of back-links.
260 emitted: HashMap<String, usize>,
261 /// Body address of the doc-order-FIRST definition per label — the one
262 /// the endnote section hoists, recorded by IDENTITY, not by a walk
263 /// counter. A "sightings so far" counter depends on walk order, and this
264 /// document is walked twice: the body pass skips a hoisted definition
265 /// WITHOUT descending, so a definition nested inside it was never
266 /// counted, and a later repeat of that label passed for the first — its
267 /// text vanished from every surface. The endnote pass then re-walked the
268 /// hoisted bodies and counted the same definitions again.
269 hoisted: HashMap<String, usize>,
270}
271
272impl FootnoteCtx {
273 /// The context `render_document` uses: numbering for the whole tree.
274 pub fn for_document(blocks: &[Block]) -> Self {
275 let index = FootnoteIndex::build(blocks);
276 // Built through the same `FootnoteIndex::definition` search the
277 // endnote section renders from, so "the definition the section
278 // hoists" and "the definition the body pass skips" are one lookup.
279 let labels: Vec<String> = index.entries().iter().map(|(_, l)| l.clone()).collect();
280 let hoisted = first_definition_bodies(blocks, &labels);
281 Self {
282 index,
283 emitted: HashMap::new(),
284 hoisted,
285 }
286 }
287}
288
289/// The id of the K-th marker for footnote N. The one place this shape is
290/// spelled, so the endnote's `href="#fnref-…"` can never drift from the
291/// body's `id="fnref-…"`.
292fn marker_id(n: usize, k: usize) -> String {
293 if k == 1 {
294 format!("fnref-{n}")
295 } else {
296 format!("fnref-{n}-{k}")
297 }
298}
299
300/// Emit an in-body `[^label]` marker.
301///
302/// A label the index doesn't own gets its literal source back instead of a
303/// superscript pointing at an id nobody will emit — the honest fallback on
304/// the shortcode-body render path.
305///
306/// `hooks.emit_footnote_anchors()` gates the `id=`/`href=` pair (default
307/// `true`, byte-identical to before this hook existed): a consumer that
308/// opts out still gets the visible superscript number, just no anchor to
309/// hang a fragment link off of. See `RenderHooks::emit_footnote_anchors`.
310pub(super) fn render_marker<H: RenderHooks + ?Sized>(
311 hooks: &H,
312 out: &mut String,
313 label: &str,
314 ctx: &mut FootnoteCtx,
315) {
316 let Some(n) = ctx.index.number(label) else {
317 out.push_str("[^");
318 out.push_str(&escape_text(label));
319 out.push(']');
320 return;
321 };
322 let k = ctx.emitted.entry(label.to_string()).or_insert(0);
323 *k += 1;
324 if hooks.emit_footnote_anchors() {
325 let id = marker_id(n, *k);
326 let _ = write!(
327 out,
328 r##"<sup class="moss-footnote-ref" id="{id}"><a href="#fn-{n}" role="doc-noteref">{n}</a></sup>"##
329 );
330 } else {
331 let _ = write!(out, r##"<sup class="moss-footnote-ref">{n}</sup>"##);
332 }
333}
334
335/// Whether this `Block::FootnoteDefinition` is the one the endnote section
336/// hoists (so the body render emits nothing for it).
337///
338/// Only the FIRST definition of a label in document order is hoisted. A
339/// repeated label is an authoring error; the repeat renders in place —
340/// wrong, but visible, which beats deleting the author's text.
341///
342/// Decided by identity — is `children` the body [`FootnoteCtx::hoisted`]
343/// recorded? — so the answer does not depend on which pass is asking or in
344/// what order the tree is walked. (Two same-label definitions with EMPTY
345/// bodies alias the dangling `Vec` pointer and both answer "hoisted"; both
346/// emit nothing either way, so no text is lost.) A `render_blocks` entry
347/// point carries an empty map and every definition renders in place,
348/// keeping the literal-source fallback for un-indexed labels.
349pub(super) fn is_hoisted(label: &str, children: &[Block], ctx: &FootnoteCtx) -> bool {
350 is_hoisted_in(label, children, &ctx.hoisted)
351}
352
353/// [`is_hoisted`]'s identity check, taking the hoisted-bodies map directly
354/// rather than a full [`FootnoteCtx`] — for walks (e.g.
355/// `ast::plain_text::render_plain_text`) that only need
356/// [`hoisted_definition_bodies`]'s numbering-free structural answer and
357/// never build a `FootnoteCtx`.
358pub(super) fn is_hoisted_in(
359 label: &str,
360 children: &[Block],
361 hoisted: &HashMap<String, usize>,
362) -> bool {
363 hoisted.get(label) == Some(&(children.as_ptr() as usize))
364}
365
366/// Emit the endnote section: one `<li>` per footnote in index order, each
367/// ending in a back-link per marker that pointed at it.
368///
369/// Two passes, and the order is load-bearing. A note body can itself carry a
370/// marker (`[^1]: see [^2]`), so every body must render before ANY back-link
371/// list is written — otherwise a marker emitted late would have no matching
372/// back-link and the `fnref` ids would dangle.
373pub fn render_section<H: RenderHooks + ?Sized>(
374 hooks: &H,
375 out: &mut String,
376 blocks: &[Block],
377 ctx: &mut FootnoteCtx,
378) {
379 if ctx.index.is_empty() {
380 return;
381 }
382 // (number, label, blocks before the trailing paragraph, that paragraph's
383 // inline HTML). The back-link rides inside the trailing paragraph so it
384 // sits at the end of the note's text, not on a line of its own.
385 let mut notes: Vec<(usize, String, String, Option<String>)> = Vec::new();
386 for (n, label) in ctx.index.entries().to_vec() {
387 let Some(children) = FootnoteIndex::definition(blocks, &label) else {
388 continue;
389 };
390 let (mut head, mut tail) = (String::new(), None);
391 match children.split_last() {
392 Some((Block::Paragraph(inlines), rest)) => {
393 render_blocks_with(hooks, &mut head, rest, ctx);
394 let mut last = String::new();
395 render_inlines(hooks, &mut last, inlines, ctx);
396 tail = Some(last);
397 }
398 _ => render_blocks_with(hooks, &mut head, children, ctx),
399 }
400 notes.push((n, label, head, tail));
401 }
402 let emit_anchors = hooks.emit_footnote_anchors();
403 out.push_str("<section class=\"moss-footnotes\" role=\"doc-endnotes\">\n<ol>\n");
404 for (n, label, head, tail) in notes {
405 let backrefs = ctx.emitted.get(&label).copied().unwrap_or(0);
406 if emit_anchors {
407 let _ = write!(out, "<li id=\"fn-{n}\">");
408 } else {
409 out.push_str("<li>");
410 }
411 out.push_str(&head);
412 if tail.is_some() || backrefs > 0 {
413 out.push_str("<p>");
414 out.push_str(tail.as_deref().unwrap_or_default());
415 if emit_anchors {
416 push_backrefs(out, n, backrefs);
417 }
418 out.push_str("</p>\n");
419 }
420 out.push_str("</li>\n");
421 }
422 out.push_str("</ol>\n</section>\n");
423}
424
425/// One return arrow per marker, so a note referenced twice ends with two.
426///
427/// The arrow is `↩︎`: U+21A9 followed by VARIATION SELECTOR-15,
428/// which forces text presentation. Without it, Chrome on Android/iOS picks the
429/// emoji form and the back-link renders as a coloured glyph instead of matching
430/// the surrounding body text. Same treatment as the comment reply button
431/// (`build/features/comment/render.rs`).
432fn push_backrefs(out: &mut String, n: usize, count: usize) {
433 for k in 1..=count {
434 let id = marker_id(n, k);
435 let nth = if k == 1 {
436 String::new()
437 } else {
438 format!(" ({k})")
439 };
440 let _ = write!(
441 out,
442 r##" <a class="moss-footnote-backref" href="#{id}" role="doc-backlink" aria-label="Back to reference {n}{nth}">↩︎</a>"##
443 );
444 }
445}