Skip to main content

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 `&#8617;&#xFE0E;`: 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}">&#8617;&#xFE0E;</a>"##
443        );
444    }
445}