Skip to main content

citum_engine/processor/document/
markdown.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Markdown document parsing for Pandoc-style citations.
7
8use super::djot::parsing::parse_frontmatter;
9use super::{CitationParser, CitationPlacement, CitationStructure, ParsedCitation, ParsedDocument};
10use crate::processor::document::ManualNoteReference;
11use crate::{Citation, CitationItem};
12use citum_schema::citation::{CitationMode, normalize_locator_text};
13use citum_schema::locale::Locale;
14use std::collections::HashSet;
15use std::ops::Range;
16
17/// Byte range of a manual footnote definition in the document body.
18///
19/// Used to classify citations found inside `[^label]: …` blocks as
20/// [`CitationPlacement::ManualFootnote`] rather than
21/// [`CitationPlacement::InlineProse`].
22struct FootnoteRange {
23    label: String,
24    content: Range<usize>,
25}
26
27/// A parser for Markdown documents with Pandoc-style citation syntax.
28pub struct MarkdownParser;
29
30impl Default for MarkdownParser {
31    fn default() -> Self {
32        Self
33    }
34}
35
36impl CitationParser for MarkdownParser {
37    /// Convert Markdown body markup to HTML after citation splicing.
38    ///
39    /// NUL placeholder tokens (`\x00CITUMHTML…TOKEN…\x00`) are temporarily
40    /// re-encoded as HTML comments before the Markdown parser runs, because
41    /// pulldown-cmark normalises `\x00` to U+FFFD. The comments survive the
42    /// conversion verbatim and are swapped back so that the caller's
43    /// `HtmlPlaceholderRegistry::apply()` can still locate them.
44    fn finalize_html_output(&self, rendered: &str) -> String {
45        use pulldown_cmark::{Options, html};
46
47        let (remapped, token_map) = remap_nul_tokens(rendered);
48        let parser = pulldown_cmark::Parser::new_ext(
49            &remapped,
50            Options::ENABLE_STRIKETHROUGH | Options::ENABLE_FOOTNOTES | Options::ENABLE_TABLES,
51        );
52        let mut out = String::new();
53        html::push_html(&mut out, parser);
54
55        // Restore original NUL tokens so HtmlPlaceholderRegistry::apply() works.
56        for (comment, original) in token_map {
57            out = out.replace(&comment, &original);
58        }
59        out
60    }
61
62    /// Convert Markdown body markup to the target terminal format (Typst, LaTeX)
63    /// after citation placeholder tokens have been spliced in.
64    fn render_body_markup<F>(&self, body: &str, fmt: &F) -> String
65    where
66        F: crate::render::format::OutputFormat<Output = String>,
67    {
68        crate::render::markup::render_markdown_body(body, fmt)
69    }
70
71    fn parse_document(&self, content: &str, locale: &Locale) -> ParsedDocument {
72        let (frontmatter_result, body) = parse_frontmatter(content);
73        let body_start = content.len() - body.len();
74        let (frontmatter, frontmatter_error) = match frontmatter_result {
75            Ok(fm) => (fm, None),
76            Err(e) => (None, Some(e)),
77        };
78        let frontmatter_options = frontmatter.as_ref().and_then(|fm| fm.options.clone());
79        // Legacy top-level fields are superseded by their `options.*` counterparts.
80        let frontmatter_integral_name_memory = frontmatter
81            .as_ref()
82            .and_then(|fm| fm.integral_name_memory.clone())
83            .filter(|_| {
84                frontmatter_options
85                    .as_ref()
86                    .and_then(|o| o.integral_name_memory.as_ref())
87                    .is_none()
88            });
89        let frontmatter_org_abbreviation_memory = frontmatter
90            .and_then(|fm| fm.org_abbreviation_memory)
91            .filter(|_| {
92                frontmatter_options
93                    .as_ref()
94                    .and_then(|o| o.org_abbreviation_memory.as_ref())
95                    .is_none()
96            });
97
98        let (raw_note_refs, manual_note_labels, footnote_ranges) = scan_manual_notes_markdown(body);
99
100        // All offsets below are relative to `body` (the frontmatter-stripped
101        // slice), matching the DjotParser convention. The pipeline splices
102        // citations into `body`, not the original `content`, so absolute
103        // offsets here would desync as soon as frontmatter is present.
104        let mut seen_labels = HashSet::new();
105        let mut manual_note_order = Vec::new();
106        let manual_note_references: Vec<ManualNoteReference> = raw_note_refs
107            .into_iter()
108            .map(|r| ManualNoteReference {
109                label: r.label.clone(),
110                start: r.start,
111            })
112            .inspect(|r| {
113                if seen_labels.insert(r.label.clone()) {
114                    manual_note_order.push(r.label.clone());
115                }
116            })
117            .collect();
118
119        let citations = find_citations(body, locale)
120            .into_iter()
121            .map(|(start, end, citation)| {
122                let placement = footnote_placement(start, end, &footnote_ranges);
123                ParsedCitation {
124                    start,
125                    end,
126                    citation,
127                    placement,
128                    structure: CitationStructure::default(),
129                }
130            })
131            .collect();
132
133        ParsedDocument {
134            citations,
135            manual_note_order,
136            manual_note_references,
137            manual_note_labels,
138            bibliography_blocks: Vec::new(),
139            frontmatter_groups: None,
140            frontmatter_integral_name_memory,
141            frontmatter_org_abbreviation_memory,
142            frontmatter_options,
143            frontmatter_error,
144            body_start,
145        }
146    }
147}
148
149/// Scan a Markdown document body for manual footnote references and definitions.
150///
151/// Uses pulldown-cmark with `ENABLE_FOOTNOTES` to find:
152/// - `[^label]` references in prose → [`ManualNoteReference`] entries
153/// - `[^label]: …` definition blocks → [`FootnoteRange`] entries whose byte
154///   ranges cover the entire definition in the source text
155///
156/// The returned triple mirrors the contract of the Djot parser's
157/// `scan_manual_notes`, enabling the shared pipeline to classify citations
158/// found inside footnote definitions as [`CitationPlacement::ManualFootnote`].
159fn scan_manual_notes_markdown(
160    content: &str,
161) -> (
162    Vec<ManualNoteReference>,
163    HashSet<String>,
164    Vec<FootnoteRange>,
165) {
166    use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
167
168    let opts = Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH;
169    let mut manual_note_references = Vec::new();
170    let mut manual_note_labels = HashSet::new();
171    let mut footnote_ranges = Vec::new();
172    let mut footnote_stack: Vec<(String, usize)> = Vec::new();
173
174    for (event, range) in Parser::new_ext(content, opts).into_offset_iter() {
175        match event {
176            Event::FootnoteReference(label) if footnote_stack.is_empty() => {
177                manual_note_references.push(ManualNoteReference {
178                    label: label.to_string(),
179                    start: range.start,
180                });
181                manual_note_labels.insert(label.to_string());
182            }
183            Event::Start(Tag::FootnoteDefinition(label)) => {
184                manual_note_labels.insert(label.to_string());
185                footnote_stack.push((label.to_string(), range.start));
186            }
187            Event::End(TagEnd::FootnoteDefinition) => {
188                if let Some((open_label, content_start)) = footnote_stack.pop() {
189                    footnote_ranges.push(FootnoteRange {
190                        label: open_label,
191                        content: content_start..range.end,
192                    });
193                }
194            }
195            _ => {}
196        }
197    }
198
199    (manual_note_references, manual_note_labels, footnote_ranges)
200}
201
202/// Determine the citation placement given the byte range of a citation
203/// and the set of footnote definition ranges in the document.
204fn footnote_placement(start: usize, end: usize, ranges: &[FootnoteRange]) -> CitationPlacement {
205    ranges
206        .iter()
207        .find(|fr| fr.content.start <= start && end <= fr.content.end)
208        .map_or(CitationPlacement::InlineProse, |fr| {
209            CitationPlacement::ManualFootnote {
210                label: fr.label.clone(),
211            }
212        })
213}
214
215#[allow(
216    clippy::string_slice,
217    clippy::unreachable,
218    reason = "Markdown scanning logic"
219)]
220fn find_citations(content: &str, locale: &Locale) -> Vec<(usize, usize, Citation)> {
221    let mut results = Vec::new();
222    let mut offset = 0;
223
224    while offset < content.len() {
225        let remaining = &content[offset..];
226        let next_at = remaining.find('@');
227        let next_bracket = remaining.find('[');
228
229        let (relative_start, kind) = match (next_at, next_bracket) {
230            (Some(at), Some(bracket)) if bracket <= at => (bracket, ScanKind::Bracket),
231            (Some(at), Some(bracket)) if at < bracket => (at, ScanKind::Textual),
232            (Some(at), None) => (at, ScanKind::Textual),
233            (None, Some(bracket)) => (bracket, ScanKind::Bracket),
234            (None, None) => break,
235            _ => unreachable!(),
236        };
237
238        let start = offset + relative_start;
239        let candidate = &content[start..];
240
241        let parsed = match kind {
242            ScanKind::Bracket => parse_bracketed_citation(candidate, locale),
243            ScanKind::Textual => parse_textual_citation(content, start, locale),
244        };
245
246        if let Some((consumed, citation)) = parsed {
247            results.push((start, start + consumed, citation));
248            offset = start + consumed;
249        } else if matches!(kind, ScanKind::Bracket) {
250            offset = start + candidate.find(']').map_or(1, |idx| idx + 1);
251        } else {
252            offset = start + 1;
253        }
254    }
255
256    results
257}
258
259#[derive(Debug, Clone, Copy)]
260enum ScanKind {
261    Bracket,
262    Textual,
263}
264
265#[allow(clippy::string_slice, reason = "Brackets and @ are 1-byte ASCII")]
266fn parse_bracketed_citation(input: &str, locale: &Locale) -> Option<(usize, Citation)> {
267    if !input.starts_with('[') {
268        return None;
269    }
270
271    let closing = input.find(']')?;
272    let inner = input[1..closing].trim();
273    if inner.is_empty() || !inner.contains('@') {
274        return None;
275    }
276
277    let mut items = Vec::new();
278    let mut suppress_author = None;
279
280    for segment in inner.split(';') {
281        let (item, suppress) = parse_bracketed_item(segment, locale)?;
282        if let Some(existing) = suppress_author {
283            if existing != suppress {
284                return None;
285            }
286        } else {
287            suppress_author = Some(suppress);
288        }
289        items.push(item);
290    }
291
292    Some((
293        closing + 1,
294        Citation {
295            items,
296            suppress_author: suppress_author.unwrap_or(false),
297            ..Default::default()
298        },
299    ))
300}
301
302#[allow(
303    clippy::string_slice,
304    clippy::indexing_slicing,
305    reason = "Citations are ASCII-heavy; indices from find() are on char boundaries"
306)]
307fn parse_bracketed_item(segment: &str, locale: &Locale) -> Option<(CitationItem, bool)> {
308    let segment = segment.trim();
309    let at_pos = segment.find('@')?;
310    let mut suppress_author = false;
311    let prefix_end = if at_pos > 0 && segment.as_bytes()[at_pos - 1] == b'-' {
312        suppress_author = true;
313        at_pos - 1
314    } else {
315        at_pos
316    };
317
318    let prefix = normalize_prefix(&segment[..prefix_end]);
319    let after_at = &segment[at_pos + 1..];
320    let key_end = cite_key_len(after_at)?;
321    let key = &after_at[..key_end];
322    let remainder = after_at[key_end..].trim_start();
323
324    let mut item = CitationItem {
325        id: key.to_string(),
326        prefix,
327        ..Default::default()
328    };
329
330    if let Some(rest) = remainder.strip_prefix(',') {
331        let rest = rest.trim();
332        if !rest.is_empty() {
333            item.locator = normalize_locator_text(rest, locale);
334            if item.locator.is_none() {
335                item.suffix = Some(rest.to_string());
336            }
337        }
338    } else if !remainder.is_empty() {
339        item.suffix = Some(remainder.trim().to_string());
340    }
341
342    Some((item, suppress_author))
343}
344
345#[allow(clippy::string_slice, reason = "@ and indices from find() are safe")]
346fn parse_textual_citation(
347    content: &str,
348    start: usize,
349    locale: &Locale,
350) -> Option<(usize, Citation)> {
351    if !is_valid_textual_start(content, start) {
352        return None;
353    }
354
355    let after_at = &content[start + 1..];
356    let key_end = cite_key_len(after_at)?;
357    let key = &after_at[..key_end];
358    let mut consumed = 1 + key_end;
359
360    let mut item = CitationItem {
361        id: key.to_string(),
362        ..Default::default()
363    };
364
365    let trailing = &content[start + consumed..];
366    if let Some((locator_consumed, locator)) = parse_textual_locator_suffix(trailing, locale) {
367        item.locator = Some(locator);
368        consumed += locator_consumed;
369    }
370
371    Some((
372        consumed,
373        Citation {
374            mode: CitationMode::Integral,
375            items: vec![item],
376            ..Default::default()
377        },
378    ))
379}
380
381#[allow(clippy::string_slice, reason = "Brackets and @ are 1-byte ASCII")]
382fn parse_textual_locator_suffix(
383    input: &str,
384    locale: &Locale,
385) -> Option<(usize, citum_schema::citation::CitationLocator)> {
386    let whitespace_len = input.len() - input.trim_start_matches(char::is_whitespace).len();
387    let rest = &input[whitespace_len..];
388    if !rest.starts_with('[') {
389        return None;
390    }
391
392    let closing = rest.find(']')?;
393    let inner = rest[1..closing].trim();
394    if inner.is_empty() || inner.contains('@') {
395        return None;
396    }
397
398    let locator = normalize_locator_text(inner, locale)?;
399    Some((whitespace_len + closing + 1, locator))
400}
401
402fn cite_key_len(input: &str) -> Option<usize> {
403    let len = input
404        .char_indices()
405        .take_while(
406            |(_, ch)| matches!(ch, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | ':' | '.'),
407        )
408        .map(|(idx, ch)| idx + ch.len_utf8())
409        .last()
410        .unwrap_or(0);
411
412    if len == 0 { None } else { Some(len) }
413}
414
415fn normalize_prefix(prefix: &str) -> Option<String> {
416    let trimmed = prefix.trim();
417    if trimmed.is_empty() {
418        None
419    } else {
420        Some(format!("{trimmed} "))
421    }
422}
423
424#[allow(clippy::string_slice, reason = "start index from find() is safe")]
425fn is_valid_textual_start(content: &str, start: usize) -> bool {
426    let prev = content[..start].chars().next_back();
427    !matches!(prev, Some(ch) if ch.is_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | '@'))
428}
429
430/// Re-encode NUL placeholder tokens as HTML comments and return a mapping.
431///
432/// pulldown-cmark normalises `\x00` to U+FFFD, which would corrupt the
433/// `HtmlPlaceholderRegistry` tokens. Replacing them with HTML comments
434/// (`<!--CITUM-TOKEN-N-->`) before parsing lets them pass through as
435/// `InlineHtml` or `Html` events and survive the conversion intact.
436/// The returned pairs map each comment back to the original token so the
437/// caller can restore them after `push_html` runs.
438fn remap_nul_tokens(s: &str) -> (String, Vec<(String, String)>) {
439    let mut result = String::with_capacity(s.len());
440    let mut map: Vec<(String, String)> = Vec::new();
441    let mut outside = true;
442    let mut token_body = String::new();
443    for ch in s.chars() {
444        if ch == '\x00' {
445            if outside {
446                // Opening NUL: start accumulating the token body.
447                token_body.clear();
448            } else {
449                // Closing NUL: emit the comment placeholder.
450                let idx = map.len();
451                let comment = format!("<!--CITUM-TOKEN-{idx}-->");
452                let original = format!("\x00{token_body}\x00");
453                result.push_str(&comment);
454                map.push((comment, original));
455            }
456            outside = !outside;
457        } else if outside {
458            result.push(ch);
459        } else {
460            token_body.push(ch);
461        }
462    }
463    (result, map)
464}
465
466#[cfg(test)]
467#[allow(
468    clippy::unwrap_used,
469    clippy::expect_used,
470    clippy::panic,
471    clippy::indexing_slicing,
472    clippy::string_slice,
473    clippy::todo,
474    clippy::unimplemented,
475    clippy::unreachable,
476    clippy::get_unwrap,
477    reason = "Panicking is acceptable and often desired in tests."
478)]
479mod tests {
480    use super::*;
481    use citum_schema::citation::{CitationLocator, LocatorType};
482
483    #[test]
484    fn test_parse_bracketed_multi_cite() {
485        let parser = MarkdownParser;
486        let citations =
487            parser.parse_citations("See [@kuhn1962; @watson1953, ch. 2].", &Locale::en_us());
488
489        assert_eq!(citations.len(), 1);
490        let (_, _, citation) = &citations[0];
491        assert_eq!(citation.items.len(), 2);
492        assert_eq!(citation.items[0].id, "kuhn1962");
493        assert_eq!(
494            citation.items[1].locator,
495            Some(CitationLocator::single(LocatorType::Chapter, "2"))
496        );
497    }
498
499    #[test]
500    fn test_parse_bracketed_prefix_and_suppress_author() {
501        let parser = MarkdownParser;
502        let citations = parser.parse_citations("[see -@kuhn1962, p. 10]", &Locale::en_us());
503
504        assert_eq!(citations.len(), 1);
505        let (_, _, citation) = &citations[0];
506        assert!(citation.suppress_author);
507        assert_eq!(citation.items[0].prefix.as_deref(), Some("see "));
508        assert_eq!(
509            citation.items[0].locator,
510            Some(CitationLocator::single(LocatorType::Page, "10"))
511        );
512    }
513
514    #[test]
515    fn test_parse_textual_citation() {
516        let parser = MarkdownParser;
517        let citations = parser.parse_citations(
518            "Kuhn argued that @kuhn1962 changed science.",
519            &Locale::en_us(),
520        );
521
522        assert_eq!(citations.len(), 1);
523        let (_, _, citation) = &citations[0];
524        assert_eq!(citation.mode, CitationMode::Integral);
525        assert_eq!(citation.items[0].id, "kuhn1962");
526    }
527
528    #[test]
529    fn test_parse_textual_citation_with_locator_suffix() {
530        let parser = MarkdownParser;
531        let citations =
532            parser.parse_citations("@kuhn1962 [p. 10] argues this point.", &Locale::en_us());
533
534        assert_eq!(citations.len(), 1);
535        let (_, _, citation) = &citations[0];
536        assert_eq!(citation.mode, CitationMode::Integral);
537        assert_eq!(
538            citation.items[0].locator,
539            Some(CitationLocator::single(LocatorType::Page, "10"))
540        );
541    }
542
543    #[test]
544    fn given_frontmatter_when_parse_document_then_citation_offsets_are_body_relative() {
545        let parser = MarkdownParser;
546        let content = "---\ntitle: T\n---\n\nText [@kuhn1962] here.";
547        let parsed = parser.parse_document(content, &Locale::en_us());
548
549        assert_eq!(parsed.citations.len(), 1);
550        let citation = &parsed.citations[0];
551        let body = &content[parsed.body_start..];
552
553        // Offsets must index `body` (post-frontmatter), not `content`.
554        assert!(
555            citation.end <= body.len(),
556            "citation end {} must be within body of length {}",
557            citation.end,
558            body.len()
559        );
560        assert_eq!(&body[citation.start..citation.end], "[@kuhn1962]");
561    }
562
563    #[test]
564    fn test_parse_document_marks_citations_as_inline_prose() {
565        let parser = MarkdownParser;
566        let parsed = parser.parse_document("Text [@kuhn1962].", &Locale::en_us());
567
568        assert_eq!(parsed.citations.len(), 1);
569        assert_eq!(
570            parsed.citations[0].placement,
571            CitationPlacement::InlineProse
572        );
573        assert!(parsed.manual_note_order.is_empty());
574        assert!(parsed.bibliography_blocks.is_empty());
575    }
576
577    #[test]
578    fn test_does_not_parse_email_address() {
579        let parser = MarkdownParser;
580        let citations =
581            parser.parse_citations("Contact test@example.com for details.", &Locale::en_us());
582
583        assert!(citations.is_empty());
584    }
585
586    #[test]
587    fn test_unsupported_bracket_cluster_does_not_fall_back_to_textual_citations() {
588        let parser = MarkdownParser;
589        let citations =
590            parser.parse_citations("Mixed [@kuhn1962; -@watson1953] cluster.", &Locale::en_us());
591
592        assert!(citations.is_empty());
593    }
594
595    #[test]
596    fn given_markdown_body_when_finalize_html_output_then_markup_is_converted_to_html() {
597        let parser = MarkdownParser;
598        let input = "**bold** and _em_ text.";
599        let output = parser.finalize_html_output(input);
600        assert!(
601            output.contains("<strong>bold</strong>"),
602            "expected <strong>bold</strong> in: {output}"
603        );
604        assert!(
605            output.contains("<em>em</em>"),
606            "expected <em>em</em> in: {output}"
607        );
608    }
609
610    #[test]
611    fn given_markdown_with_nul_tokens_when_finalize_html_output_then_tokens_survive_conversion() {
612        let parser = MarkdownParser;
613        // NUL tokens stand in for spliced citation HTML; they must survive the
614        // pulldown-cmark pass so HtmlPlaceholderRegistry::apply() can substitute them.
615        let token = "\x00CITUMHTMLINLINETOKEN0\x00";
616        let input = format!("Some prose with {token} inline.");
617        let output = parser.finalize_html_output(&input);
618        assert!(
619            output.contains(token),
620            "NUL token must survive pulldown-cmark conversion; output: {output}"
621        );
622    }
623
624    #[test]
625    fn given_markdown_blockquote_when_finalize_html_output_then_blockquote_element_emitted() {
626        let parser = MarkdownParser;
627        let input = "> block quote with *italic* text";
628        let output = parser.finalize_html_output(input);
629        assert!(
630            output.contains("<blockquote>"),
631            "expected <blockquote> in: {output}"
632        );
633        assert!(
634            output.contains("<em>italic</em>"),
635            "expected <em>italic</em> in: {output}"
636        );
637    }
638
639    #[test]
640    fn given_markdown_pipe_table_when_finalize_html_output_then_table_element_emitted() {
641        let parser = MarkdownParser;
642        let input = "| A | B |\n|---|---|\n| 1 | 2 |";
643        let output = parser.finalize_html_output(input);
644        assert!(
645            output.contains("<table>"),
646            "pipe table should render as <table>: {output}"
647        );
648    }
649
650    #[test]
651    fn given_markdown_footnote_def_when_finalize_html_output_then_footnote_rendered() {
652        let parser = MarkdownParser;
653        let input = "Text[^1].\n\n[^1]: A note.";
654        let output = parser.finalize_html_output(input);
655        assert!(
656            output.contains("footnote") || output.contains("fn1"),
657            "footnote definition should produce HTML footnote markup: {output}"
658        );
659    }
660
661    #[test]
662    fn given_citation_inside_footnote_def_when_parse_document_then_placement_is_manual_footnote() {
663        let parser = MarkdownParser;
664        // The citation [@kuhn1962] appears inside a footnote definition, not in prose.
665        let doc = "See note[^1].\n\n[^1]: See [@kuhn1962].";
666        let parsed = parser.parse_document(doc, &Locale::en_us());
667
668        assert_eq!(parsed.citations.len(), 1, "one citation expected");
669        assert!(
670            matches!(
671                parsed.citations[0].placement,
672                CitationPlacement::ManualFootnote { .. }
673            ),
674            "citation inside [^n]: block should be ManualFootnote, got: {:?}",
675            parsed.citations[0].placement
676        );
677        assert!(
678            parsed.manual_note_labels.contains("1"),
679            "footnote label '1' should be tracked: {:?}",
680            parsed.manual_note_labels
681        );
682        assert_eq!(parsed.manual_note_order, vec!["1".to_string()]);
683    }
684
685    #[test]
686    fn given_citation_in_prose_when_parse_document_then_placement_is_inline_prose() {
687        let parser = MarkdownParser;
688        let doc = "As shown by [@kuhn1962], the method works.\n\n[^1]: Unrelated note.";
689        let parsed = parser.parse_document(doc, &Locale::en_us());
690
691        assert_eq!(parsed.citations.len(), 1);
692        assert!(
693            matches!(
694                parsed.citations[0].placement,
695                CitationPlacement::InlineProse
696            ),
697            "prose citation should be InlineProse: {:?}",
698            parsed.citations[0].placement
699        );
700    }
701
702    #[test]
703    fn given_multiple_footnotes_when_parse_document_then_note_order_is_first_reference_order() {
704        let parser = MarkdownParser;
705        let doc = "First[^b] then[^a].\n\n[^a]: [@kuhn1962].\n\n[^b]: [@smith2010].";
706        let parsed = parser.parse_document(doc, &Locale::en_us());
707
708        // Note order follows the order references appear in prose, not definition order.
709        assert_eq!(
710            parsed.manual_note_order,
711            vec!["b".to_string(), "a".to_string()]
712        );
713        assert_eq!(parsed.citations.len(), 2);
714        for c in &parsed.citations {
715            assert!(
716                matches!(c.placement, CitationPlacement::ManualFootnote { .. }),
717                "both citations are inside footnote definitions: {:?}",
718                c.placement
719            );
720        }
721    }
722}