Skip to main content

citum_engine/render/
djot.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Djot output format.
7
8use super::format::{OutputFormat, QuoteMarks};
9use citum_schema::template::WrapPunctuation;
10
11#[derive(Default, Clone)]
12/// Renders processed citations and bibliography entries as Djot markup.
13pub struct Djot;
14
15impl OutputFormat for Djot {
16    type Output = String;
17
18    fn text(&self, s: &str) -> Self::Output {
19        // No escaping for Djot as requested.
20        s.to_string()
21    }
22
23    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
24        items.join(delimiter)
25    }
26
27    fn finish(&self, output: Self::Output) -> String {
28        output
29    }
30
31    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
32        let marks = "#".repeat(level.max(1) as usize);
33        format!("{marks} {content}\n\n")
34    }
35
36    fn emph(&self, content: Self::Output) -> Self::Output {
37        if content.is_empty() {
38            return content;
39        }
40        format!("_{content}_")
41    }
42
43    fn strong(&self, content: Self::Output) -> Self::Output {
44        if content.is_empty() {
45            return content;
46        }
47        format!("*{content}*")
48    }
49
50    fn small_caps(&self, content: Self::Output) -> Self::Output {
51        if content.is_empty() {
52            return content;
53        }
54        format!("[{content}]{{.small-caps}}")
55    }
56
57    fn superscript(&self, content: Self::Output) -> Self::Output {
58        if content.is_empty() {
59            return content;
60        }
61        format!("[{content}]{{.superscript}}")
62    }
63
64    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
65        if content.is_empty() {
66            return content;
67        }
68        let (open, close) = marks.for_depth(0);
69        format!("{open}{content}{close}")
70    }
71
72    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
73        format!("{prefix}{content}{suffix}")
74    }
75
76    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
77        format!("{prefix}{content}{suffix}")
78    }
79
80    fn wrap_punctuation(
81        &self,
82        wrap: &WrapPunctuation,
83        content: Self::Output,
84        marks: &QuoteMarks,
85    ) -> Self::Output {
86        match wrap {
87            WrapPunctuation::Parentheses => format!("({content})"),
88            WrapPunctuation::Brackets => format!("[{content}]"),
89            WrapPunctuation::Quotes => self.quote(content, marks),
90        }
91    }
92
93    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
94        if content.is_empty() {
95            return content;
96        }
97        format!("[{content}]{{.{class}}}")
98    }
99
100    fn annotation(&self, content: Self::Output) -> Self::Output {
101        if content.is_empty() {
102            return content;
103        }
104        format!("\n\n::: citum-annotation\n{content}\n:::")
105    }
106
107    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
108        if content.is_empty() {
109            return content;
110        }
111        format!("[{content}]({url})")
112    }
113
114    fn entry(
115        &self,
116        _id: &str,
117        content: Self::Output,
118        url: Option<&str>,
119        _metadata: &super::format::ProcEntryMetadata,
120    ) -> Self::Output {
121        if let Some(u) = url {
122            self.link(u, content)
123        } else {
124            content
125        }
126    }
127}
128
129#[cfg(test)]
130#[allow(
131    clippy::unwrap_used,
132    clippy::expect_used,
133    clippy::panic,
134    clippy::indexing_slicing,
135    clippy::todo,
136    clippy::unimplemented,
137    clippy::unreachable,
138    clippy::get_unwrap,
139    reason = "Panicking is acceptable and often desired in tests."
140)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_djot_emph() {
146        let fmt = Djot;
147
148        for (input, expected) in [("", ""), ("text", "_text_")] {
149            assert_eq!(fmt.emph(input.to_string()), expected);
150        }
151    }
152
153    #[test]
154    fn test_djot_strong() {
155        let fmt = Djot;
156
157        for (input, expected) in [("", ""), ("text", "*text*")] {
158            assert_eq!(fmt.strong(input.to_string()), expected);
159        }
160    }
161
162    #[test]
163    fn test_djot_small_caps() {
164        let fmt = Djot;
165
166        for (input, expected) in [("", ""), ("text", "[text]{.small-caps}")] {
167            assert_eq!(fmt.small_caps(input.to_string()), expected);
168        }
169    }
170
171    #[test]
172    fn test_djot_quote() {
173        let fmt = Djot;
174        let marks = QuoteMarks::default();
175
176        for (input, expected) in [("", ""), ("text", "\u{201C}text\u{201D}")] {
177            assert_eq!(fmt.quote(input.to_string(), &marks), expected);
178        }
179    }
180
181    #[test]
182    fn test_djot_quote_uses_locale_marks() {
183        let fmt = Djot;
184        let marks = QuoteMarks {
185            open: "\u{ab}".to_string(),
186            close: "\u{bb}".to_string(),
187            open_inner: "\u{2039}".to_string(),
188            close_inner: "\u{203a}".to_string(),
189        };
190
191        assert_eq!(fmt.quote("text".to_string(), &marks), "\u{ab}text\u{bb}");
192    }
193
194    #[test]
195    fn test_djot_semantic() {
196        let fmt = Djot;
197
198        for (input, class, expected) in [("", "author", ""), ("text", "author", "[text]{.author}")]
199        {
200            assert_eq!(fmt.semantic(class, input.to_string()), expected);
201        }
202    }
203
204    #[test]
205    fn test_djot_link() {
206        let fmt = Djot;
207
208        for (input, url, expected) in [
209            ("", "https://example.com", ""),
210            ("text", "https://example.com", "[text](https://example.com)"),
211        ] {
212            assert_eq!(fmt.link(url, input.to_string()), expected);
213        }
214    }
215
216    #[test]
217    fn test_djot_wrap_punctuation() {
218        let fmt = Djot;
219        let marks = QuoteMarks::default();
220
221        for (wrap, input, expected) in [
222            (WrapPunctuation::Parentheses, "text", "(text)"),
223            (WrapPunctuation::Brackets, "text", "[text]"),
224            (WrapPunctuation::Quotes, "text", "\u{201C}text\u{201D}"),
225        ] {
226            assert_eq!(
227                fmt.wrap_punctuation(&wrap, input.to_string(), &marks),
228                expected
229            );
230        }
231    }
232}