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 super::format::{OutputFormat, QuoteMarks};
24use citum_schema::template::WrapPunctuation;
25
26/// Escape CommonMark-active characters in raw bibliography data text.
27///
28/// Backslash-escapes `\`, `*`, `_`, `[`, `]`, `` ` ``, `<`, `>`, and `&`
29/// so that data fields (titles, author names, etc.) cannot accidentally
30/// activate emphasis, strong, link, code-span, autolink, inline HTML, or
31/// HTML-entity syntax. Style-applied markup (`emph`, `strong`, `link`)
32/// wraps already-escaped text, so intentional markup is unaffected.
33fn escape_commonmark_text(s: &str) -> String {
34    let mut out = String::with_capacity(s.len() + 4);
35    for ch in s.chars() {
36        match ch {
37            '\\' | '*' | '_' | '[' | ']' | '`' | '<' | '>' | '&' => {
38                out.push('\\');
39                out.push(ch);
40            }
41            _ => out.push(ch),
42        }
43    }
44    out
45}
46
47/// Renders processed citations and bibliography entries as CommonMark markup.
48#[derive(Default, Clone)]
49pub struct Markdown;
50
51impl OutputFormat for Markdown {
52    type Output = String;
53
54    fn text(&self, s: &str) -> Self::Output {
55        escape_commonmark_text(s)
56    }
57
58    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
59        items.join(delimiter)
60    }
61
62    fn finish(&self, output: Self::Output) -> String {
63        output
64    }
65
66    /// Render a heading using ATX syntax (`#`, `##`, ...).
67    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
68        let marks = "#".repeat(level.max(1) as usize);
69        format!("{marks} {content}\n\n")
70    }
71
72    /// Render emphasis as `*content*` (CommonMark italic).
73    fn emph(&self, content: Self::Output) -> Self::Output {
74        if content.is_empty() {
75            return content;
76        }
77        format!("*{content}*")
78    }
79
80    /// Render strong emphasis as `**content**` (CommonMark bold).
81    fn strong(&self, content: Self::Output) -> Self::Output {
82        if content.is_empty() {
83            return content;
84        }
85        format!("**{content}**")
86    }
87
88    /// Render small caps as raw inline HTML.
89    ///
90    /// CommonMark has no native small-caps syntax. Raw `<span>` HTML is passed
91    /// through by Pandoc's CommonMark reader and most other processors.
92    fn small_caps(&self, content: Self::Output) -> Self::Output {
93        if content.is_empty() {
94            return content;
95        }
96        format!("<span style=\"font-variant:small-caps\">{content}</span>")
97    }
98
99    /// Render superscript as raw inline HTML.
100    ///
101    /// CommonMark has no native superscript syntax. Raw `<sup>` HTML is passed
102    /// through by Pandoc and most processors.
103    fn superscript(&self, content: Self::Output) -> Self::Output {
104        if content.is_empty() {
105            return content;
106        }
107        format!("<sup>{content}</sup>")
108    }
109
110    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
111        if content.is_empty() {
112            return content;
113        }
114        let (open, close) = marks.for_depth(0);
115        format!("{open}{content}{close}")
116    }
117
118    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
119        format!("{prefix}{content}{suffix}")
120    }
121
122    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
123        format!("{prefix}{content}{suffix}")
124    }
125
126    fn wrap_punctuation(
127        &self,
128        wrap: &WrapPunctuation,
129        content: Self::Output,
130        marks: &QuoteMarks,
131    ) -> Self::Output {
132        match wrap {
133            WrapPunctuation::Parentheses => format!("({content})"),
134            WrapPunctuation::Brackets => format!("[{content}]"),
135            WrapPunctuation::Quotes => self.quote(content, marks),
136        }
137    }
138
139    /// Render a semantic class as a plain passthrough.
140    ///
141    /// CommonMark has no attribute syntax. Content is returned unchanged so
142    /// citations remain readable plain text. Use `--format html` or `--format djot`
143    /// if machine-readable semantic spans are needed.
144    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
145        content
146    }
147
148    fn annotation(&self, content: Self::Output) -> Self::Output {
149        if content.is_empty() {
150            return content;
151        }
152        format!("\n\n{content}")
153    }
154
155    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
156        if content.is_empty() {
157            return content;
158        }
159        format!("[{content}]({url})")
160    }
161
162    fn entry(
163        &self,
164        _id: &str,
165        content: Self::Output,
166        url: Option<&str>,
167        _metadata: &super::format::ProcEntryMetadata,
168    ) -> Self::Output {
169        if let Some(u) = url {
170            self.link(u, content)
171        } else {
172            content
173        }
174    }
175}
176
177#[cfg(test)]
178#[allow(
179    clippy::unwrap_used,
180    clippy::expect_used,
181    clippy::panic,
182    clippy::indexing_slicing,
183    reason = "tests"
184)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn test_markdown_emph() {
190        let fmt = Markdown;
191        for (input, expected) in [("", ""), ("text", "*text*")] {
192            assert_eq!(fmt.emph(input.to_string()), expected);
193        }
194    }
195
196    #[test]
197    fn test_markdown_strong() {
198        let fmt = Markdown;
199        for (input, expected) in [("", ""), ("text", "**text**")] {
200            assert_eq!(fmt.strong(input.to_string()), expected);
201        }
202    }
203
204    #[test]
205    fn test_markdown_small_caps() {
206        let fmt = Markdown;
207        assert_eq!(fmt.small_caps(String::new()), "");
208        assert_eq!(
209            fmt.small_caps("Smith".to_string()),
210            "<span style=\"font-variant:small-caps\">Smith</span>"
211        );
212    }
213
214    #[test]
215    fn test_markdown_superscript() {
216        let fmt = Markdown;
217        assert_eq!(fmt.superscript(String::new()), "");
218        assert_eq!(fmt.superscript("2".to_string()), "<sup>2</sup>");
219    }
220
221    #[test]
222    fn test_markdown_quote() {
223        let fmt = Markdown;
224        let marks = QuoteMarks::default();
225        for (input, expected) in [("", ""), ("text", "\u{201C}text\u{201D}")] {
226            assert_eq!(fmt.quote(input.to_string(), &marks), expected);
227        }
228    }
229
230    #[test]
231    fn test_markdown_quote_uses_locale_marks() {
232        let fmt = Markdown;
233        let marks = QuoteMarks {
234            open: "\u{ab}".to_string(),
235            close: "\u{bb}".to_string(),
236            open_inner: "\u{2039}".to_string(),
237            close_inner: "\u{203a}".to_string(),
238        };
239
240        assert_eq!(fmt.quote("text".to_string(), &marks), "\u{ab}text\u{bb}");
241    }
242
243    #[test]
244    fn test_markdown_semantic_passthrough() {
245        let fmt = Markdown;
246        assert_eq!(fmt.semantic("author", "Jane Doe".to_string()), "Jane Doe");
247        assert_eq!(fmt.semantic("title", String::new()), "");
248    }
249
250    #[test]
251    fn test_markdown_link() {
252        let fmt = Markdown;
253        assert_eq!(fmt.link("https://example.com", String::new()), "");
254        assert_eq!(
255            fmt.link("https://example.com", "Example".to_string()),
256            "[Example](https://example.com)"
257        );
258    }
259
260    #[test]
261    fn test_markdown_wrap_punctuation() {
262        let fmt = Markdown;
263        let marks = QuoteMarks::default();
264        for (wrap, input, expected) in [
265            (WrapPunctuation::Parentheses, "text", "(text)"),
266            (WrapPunctuation::Brackets, "text", "[text]"),
267            (WrapPunctuation::Quotes, "text", "\u{201C}text\u{201D}"),
268        ] {
269            assert_eq!(
270                fmt.wrap_punctuation(&wrap, input.to_string(), &marks),
271                expected
272            );
273        }
274    }
275
276    #[test]
277    fn test_markdown_text_escapes_active_chars() {
278        let fmt = Markdown;
279        assert_eq!(fmt.text("plain"), "plain");
280        assert_eq!(fmt.text("A * B"), "A \\* B");
281        assert_eq!(fmt.text("use [x]"), "use \\[x\\]");
282        assert_eq!(fmt.text("code `foo`"), "code \\`foo\\`");
283        assert_eq!(fmt.text("back\\slash"), "back\\\\slash");
284        assert_eq!(fmt.text("under_score"), "under\\_score");
285        // Angle brackets and ampersand: escape to prevent autolinks,
286        // inline HTML, and HTML entity expansion.
287        assert_eq!(fmt.text("<doi:10.1/x>"), "\\<doi:10.1/x\\>");
288        assert_eq!(fmt.text("Smith & Jones"), "Smith \\& Jones");
289        assert_eq!(fmt.text("<em>bold</em>"), "\\<em\\>bold\\</em\\>");
290    }
291}