Skip to main content

citum_engine/render/
markdown.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! CommonMark (Markdown) output format.
7//!
8//! This renderer is designed for **Pandoc interop**: citum processes citations
9//! inline (replacing `[@key]` markers with rendered text) and emits the document
10//! body verbatim, so the output can be piped directly to `pandoc` or any other
11//! CommonMark-aware formatter. Only citation and bibliography strings are
12//! rendered in CommonMark markup; block-level document markup passes through
13//! unchanged.
14//!
15//! # Note styles
16//!
17//! Note-based styles (Chicago notes, etc.) emit `[^label]` anchors in prose and
18//! `[^label]: …` footnote definitions at the end of the document. These follow
19//! the Pandoc/GFM footnote extension — **not** core CommonMark. Downstream
20//! consumers must enable the extension:
21//! `pandoc --from commonmark+footnotes` (or `--from gfm`).
22
23use std::ops::Range;
24
25use super::format::{OutputFormat, QuoteMarks};
26use super::visible_scan::{RunBuilder, find_matching, skip_balanced};
27use citum_schema::template::WrapPunctuation;
28
29/// Escape CommonMark-active characters in raw bibliography data text.
30///
31/// Backslash-escapes `\`, `*`, `_`, `[`, `]`, `` ` ``, `<`, `>`, and `&`
32/// so that data fields (titles, author names, etc.) cannot accidentally
33/// activate emphasis, strong, link, code-span, autolink, inline HTML, or
34/// HTML-entity syntax. Style-applied markup (`emph`, `strong`, `link`)
35/// wraps already-escaped text, so intentional markup is unaffected.
36fn escape_commonmark_text(s: &str) -> String {
37    let mut out = String::with_capacity(s.len() + 4);
38    for ch in s.chars() {
39        match ch {
40            '\\' | '*' | '_' | '[' | ']' | '`' | '<' | '>' | '&' => {
41                out.push('\\');
42                out.push(ch);
43            }
44            _ => out.push(ch),
45        }
46    }
47    out
48}
49
50/// Renders processed citations and bibliography entries as CommonMark markup.
51#[derive(Default, Clone)]
52pub struct Markdown;
53
54impl OutputFormat for Markdown {
55    type Output = String;
56
57    fn text(&self, s: &str) -> Self::Output {
58        escape_commonmark_text(s)
59    }
60
61    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
62        items.join(delimiter)
63    }
64
65    fn finish(&self, output: Self::Output) -> String {
66        output
67    }
68
69    /// Render a heading using ATX syntax (`#`, `##`, ...).
70    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
71        let marks = "#".repeat(level.max(1) as usize);
72        format!("{marks} {content}\n\n")
73    }
74
75    /// Render emphasis as `*content*` (CommonMark italic).
76    fn emph(&self, content: Self::Output) -> Self::Output {
77        if content.is_empty() {
78            return content;
79        }
80        format!("*{content}*")
81    }
82
83    /// Render strong emphasis as `**content**` (CommonMark bold).
84    fn strong(&self, content: Self::Output) -> Self::Output {
85        if content.is_empty() {
86            return content;
87        }
88        format!("**{content}**")
89    }
90
91    /// Render small caps as raw inline HTML.
92    ///
93    /// CommonMark has no native small-caps syntax. Raw `<span>` HTML is passed
94    /// through by Pandoc's CommonMark reader and most other processors.
95    fn small_caps(&self, content: Self::Output) -> Self::Output {
96        if content.is_empty() {
97            return content;
98        }
99        format!("<span style=\"font-variant:small-caps\">{content}</span>")
100    }
101
102    /// Render superscript as raw inline HTML.
103    ///
104    /// CommonMark has no native superscript syntax. Raw `<sup>` HTML is passed
105    /// through by Pandoc and most processors.
106    fn superscript(&self, content: Self::Output) -> Self::Output {
107        if content.is_empty() {
108            return content;
109        }
110        format!("<sup>{content}</sup>")
111    }
112
113    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
114        if content.is_empty() {
115            return content;
116        }
117        let (open, close) = marks.for_depth(0);
118        format!("{open}{content}{close}")
119    }
120
121    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
122        format!("{prefix}{content}{suffix}")
123    }
124
125    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
126        format!("{prefix}{content}{suffix}")
127    }
128
129    fn wrap_punctuation(
130        &self,
131        wrap: &WrapPunctuation,
132        content: Self::Output,
133        marks: &QuoteMarks,
134    ) -> Self::Output {
135        match wrap {
136            WrapPunctuation::Parentheses => format!("({content})"),
137            WrapPunctuation::Brackets => format!("[{content}]"),
138            WrapPunctuation::Quotes => self.quote(content, marks),
139        }
140    }
141
142    /// Render a semantic class as a plain passthrough.
143    ///
144    /// CommonMark has no attribute syntax. Content is returned unchanged so
145    /// citations remain readable plain text. Use `--format html` or `--format djot`
146    /// if machine-readable semantic spans are needed.
147    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
148        content
149    }
150
151    fn annotation(&self, content: Self::Output) -> Self::Output {
152        if content.is_empty() {
153            return content;
154        }
155        format!("\n\n{content}")
156    }
157
158    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
159        if content.is_empty() {
160            return content;
161        }
162        format!("[{content}]({url})")
163    }
164
165    fn entry(
166        &self,
167        _id: &str,
168        content: Self::Output,
169        url: Option<&str>,
170        _metadata: &super::format::ProcEntryMetadata,
171    ) -> Self::Output {
172        if let Some(u) = url {
173            self.link(u, content)
174        } else {
175            content
176        }
177    }
178
179    /// Strip `*`/`**` emphasis delimiters, `<span>`/`<sup>` raw-HTML wrappers,
180    /// and a `[content](url)` link's `[`/`](url)` markup — keeping link text
181    /// visible but the URL hidden. A bracket pair *not* followed by `(url)`
182    /// (i.e. from [`Self::wrap_punctuation`]'s `WrapPunctuation::Brackets`)
183    /// keeps its brackets visible, since those are house-style punctuation.
184    /// Backslash-escaped characters are visible as themselves.
185    fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
186        let mut runs = RunBuilder::default();
187        let chars: Vec<(usize, char)> = fragment.char_indices().collect();
188        let mut i = 0;
189        let mut in_tag = false;
190        let mut pending_link_close: Option<usize> = None;
191        while let Some(&(pos, ch)) = chars.get(i) {
192            if in_tag {
193                if ch == '>' {
194                    in_tag = false;
195                }
196                i += 1;
197                continue;
198            }
199            match ch {
200                '\\' => {
201                    if let Some(&(epos, echar)) = chars.get(i + 1) {
202                        runs.push_visible(epos, epos + echar.len_utf8());
203                    }
204                    i += 2;
205                }
206                '<' => {
207                    in_tag = true;
208                    i += 1;
209                }
210                '*' => {
211                    i += 1;
212                    if chars.get(i).map(|&(_, c)| c) == Some('*') {
213                        i += 1;
214                    }
215                }
216                '[' => {
217                    let close_i = find_matching(&chars, i, '[', ']', true);
218                    let is_link = close_i
219                        .is_some_and(|c| chars.get(c + 1).map(|&(_, next)| next) == Some('('));
220                    if is_link {
221                        pending_link_close = close_i;
222                        i += 1;
223                    } else {
224                        runs.push_visible(pos, pos + 1);
225                        i += 1;
226                    }
227                }
228                ']' => {
229                    if pending_link_close == Some(i) {
230                        pending_link_close = None;
231                        let mut j = i + 1;
232                        if chars.get(j).map(|&(_, c)| c) == Some('(') {
233                            j = skip_balanced(&chars, j, '(', ')', true);
234                        }
235                        i = j;
236                    } else {
237                        runs.push_visible(pos, pos + 1);
238                        i += 1;
239                    }
240                }
241                _ => {
242                    runs.push_visible(pos, pos + ch.len_utf8());
243                    i += 1;
244                }
245            }
246        }
247        runs.finish()
248    }
249}
250
251#[cfg(test)]
252#[allow(
253    clippy::unwrap_used,
254    clippy::expect_used,
255    clippy::panic,
256    clippy::indexing_slicing,
257    reason = "tests"
258)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_markdown_emph() {
264        let fmt = Markdown;
265        for (input, expected) in [("", ""), ("text", "*text*")] {
266            assert_eq!(fmt.emph(input.to_string()), expected);
267        }
268    }
269
270    #[test]
271    fn test_markdown_strong() {
272        let fmt = Markdown;
273        for (input, expected) in [("", ""), ("text", "**text**")] {
274            assert_eq!(fmt.strong(input.to_string()), expected);
275        }
276    }
277
278    #[test]
279    fn test_markdown_small_caps() {
280        let fmt = Markdown;
281        assert_eq!(fmt.small_caps(String::new()), "");
282        assert_eq!(
283            fmt.small_caps("Smith".to_string()),
284            "<span style=\"font-variant:small-caps\">Smith</span>"
285        );
286    }
287
288    #[test]
289    fn test_markdown_superscript() {
290        let fmt = Markdown;
291        assert_eq!(fmt.superscript(String::new()), "");
292        assert_eq!(fmt.superscript("2".to_string()), "<sup>2</sup>");
293    }
294
295    #[test]
296    fn test_markdown_quote() {
297        let fmt = Markdown;
298        let marks = QuoteMarks::default();
299        for (input, expected) in [("", ""), ("text", "\u{201C}text\u{201D}")] {
300            assert_eq!(fmt.quote(input.to_string(), &marks), expected);
301        }
302    }
303
304    #[test]
305    fn test_markdown_quote_uses_locale_marks() {
306        let fmt = Markdown;
307        let marks = QuoteMarks {
308            open: "\u{ab}".to_string(),
309            close: "\u{bb}".to_string(),
310            open_inner: "\u{2039}".to_string(),
311            close_inner: "\u{203a}".to_string(),
312        };
313
314        assert_eq!(fmt.quote("text".to_string(), &marks), "\u{ab}text\u{bb}");
315    }
316
317    #[test]
318    fn test_markdown_semantic_passthrough() {
319        let fmt = Markdown;
320        assert_eq!(fmt.semantic("author", "Jane Doe".to_string()), "Jane Doe");
321        assert_eq!(fmt.semantic("title", String::new()), "");
322    }
323
324    #[test]
325    fn test_markdown_link() {
326        let fmt = Markdown;
327        assert_eq!(fmt.link("https://example.com", String::new()), "");
328        assert_eq!(
329            fmt.link("https://example.com", "Example".to_string()),
330            "[Example](https://example.com)"
331        );
332    }
333
334    #[test]
335    fn test_markdown_wrap_punctuation() {
336        let fmt = Markdown;
337        let marks = QuoteMarks::default();
338        for (wrap, input, expected) in [
339            (WrapPunctuation::Parentheses, "text", "(text)"),
340            (WrapPunctuation::Brackets, "text", "[text]"),
341            (WrapPunctuation::Quotes, "text", "\u{201C}text\u{201D}"),
342        ] {
343            assert_eq!(
344                fmt.wrap_punctuation(&wrap, input.to_string(), &marks),
345                expected
346            );
347        }
348    }
349
350    #[test]
351    fn test_markdown_text_escapes_active_chars() {
352        let fmt = Markdown;
353        assert_eq!(fmt.text("plain"), "plain");
354        assert_eq!(fmt.text("A * B"), "A \\* B");
355        assert_eq!(fmt.text("use [x]"), "use \\[x\\]");
356        assert_eq!(fmt.text("code `foo`"), "code \\`foo\\`");
357        assert_eq!(fmt.text("back\\slash"), "back\\\\slash");
358        assert_eq!(fmt.text("under_score"), "under\\_score");
359        // Angle brackets and ampersand: escape to prevent autolinks,
360        // inline HTML, and HTML entity expansion.
361        assert_eq!(fmt.text("<doi:10.1/x>"), "\\<doi:10.1/x\\>");
362        assert_eq!(fmt.text("Smith & Jones"), "Smith \\& Jones");
363        assert_eq!(fmt.text("<em>bold</em>"), "\\<em\\>bold\\</em\\>");
364    }
365
366    #[test]
367    fn visible_text_strips_emph_and_strong_delimiters() {
368        let fmt = Markdown;
369        assert_eq!(fmt.visible_text("*Title.*"), "Title.");
370        assert_eq!(fmt.visible_text("**Title.**"), "Title.");
371    }
372
373    #[test]
374    fn visible_text_hides_link_url_keeps_text() {
375        let fmt = Markdown;
376        assert_eq!(
377            fmt.visible_text("[Example](https://example.com/a.b)"),
378            "Example"
379        );
380    }
381
382    #[test]
383    fn visible_text_keeps_literal_wrap_brackets_visible() {
384        let fmt = Markdown;
385        // WrapPunctuation::Brackets: bare `[content]`, not a link.
386        assert_eq!(fmt.visible_text("[Dataset]"), "[Dataset]");
387    }
388
389    #[test]
390    fn visible_text_strips_raw_html_spans() {
391        let fmt = Markdown;
392        assert_eq!(
393            fmt.visible_text("<span style=\"font-variant:small-caps\">Smith</span>"),
394            "Smith"
395        );
396        assert_eq!(fmt.visible_text("<sup>2</sup>"), "2");
397    }
398
399    #[test]
400    fn visible_text_keeps_escaped_punctuation() {
401        let fmt = Markdown;
402        assert_eq!(fmt.visible_text(r"A \* B"), "A * B");
403    }
404}