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
4*/
5
6//! Djot output format.
7
8use super::format::OutputFormat;
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 emph(&self, content: Self::Output) -> Self::Output {
32        if content.is_empty() {
33            return content;
34        }
35        format!("_{content}_")
36    }
37
38    fn strong(&self, content: Self::Output) -> Self::Output {
39        if content.is_empty() {
40            return content;
41        }
42        format!("*{content}*")
43    }
44
45    fn small_caps(&self, content: Self::Output) -> Self::Output {
46        if content.is_empty() {
47            return content;
48        }
49        format!("[{content}]{{.small-caps}}")
50    }
51
52    fn superscript(&self, content: Self::Output) -> Self::Output {
53        if content.is_empty() {
54            return content;
55        }
56        format!("[{content}]{{.superscript}}")
57    }
58
59    fn quote(&self, content: Self::Output) -> Self::Output {
60        if content.is_empty() {
61            return content;
62        }
63        format!("\u{201C}{content}\u{201D}")
64    }
65
66    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
67        format!("{prefix}{content}{suffix}")
68    }
69
70    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
71        format!("{prefix}{content}{suffix}")
72    }
73
74    fn wrap_punctuation(&self, wrap: &WrapPunctuation, content: Self::Output) -> Self::Output {
75        match wrap {
76            WrapPunctuation::Parentheses => format!("({content})"),
77            WrapPunctuation::Brackets => format!("[{content}]"),
78            WrapPunctuation::Quotes => format!("\u{201C}{content}\u{201D}"),
79        }
80    }
81
82    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
83        if content.is_empty() {
84            return content;
85        }
86        format!("[{content}]{{.{class}}}")
87    }
88
89    fn annotation(&self, content: Self::Output) -> Self::Output {
90        if content.is_empty() {
91            return content;
92        }
93        format!("\n\n::: citum-annotation\n{content}\n:::")
94    }
95
96    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
97        if content.is_empty() {
98            return content;
99        }
100        format!("[{content}]({url})")
101    }
102
103    fn entry(
104        &self,
105        _id: &str,
106        content: Self::Output,
107        url: Option<&str>,
108        _metadata: &super::format::ProcEntryMetadata,
109    ) -> Self::Output {
110        if let Some(u) = url {
111            self.link(u, content)
112        } else {
113            content
114        }
115    }
116}
117
118#[cfg(test)]
119#[allow(
120    clippy::unwrap_used,
121    clippy::expect_used,
122    clippy::panic,
123    clippy::indexing_slicing,
124    clippy::todo,
125    clippy::unimplemented,
126    clippy::unreachable,
127    clippy::get_unwrap,
128    reason = "Panicking is acceptable and often desired in tests."
129)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_djot_emph() {
135        let fmt = Djot;
136
137        for (input, expected) in [("", ""), ("text", "_text_")] {
138            assert_eq!(fmt.emph(input.to_string()), expected);
139        }
140    }
141
142    #[test]
143    fn test_djot_strong() {
144        let fmt = Djot;
145
146        for (input, expected) in [("", ""), ("text", "*text*")] {
147            assert_eq!(fmt.strong(input.to_string()), expected);
148        }
149    }
150
151    #[test]
152    fn test_djot_small_caps() {
153        let fmt = Djot;
154
155        for (input, expected) in [("", ""), ("text", "[text]{.small-caps}")] {
156            assert_eq!(fmt.small_caps(input.to_string()), expected);
157        }
158    }
159
160    #[test]
161    fn test_djot_quote() {
162        let fmt = Djot;
163
164        for (input, expected) in [("", ""), ("text", "\u{201C}text\u{201D}")] {
165            assert_eq!(fmt.quote(input.to_string()), expected);
166        }
167    }
168
169    #[test]
170    fn test_djot_semantic() {
171        let fmt = Djot;
172
173        for (input, class, expected) in [("", "author", ""), ("text", "author", "[text]{.author}")]
174        {
175            assert_eq!(fmt.semantic(class, input.to_string()), expected);
176        }
177    }
178
179    #[test]
180    fn test_djot_link() {
181        let fmt = Djot;
182
183        for (input, url, expected) in [
184            ("", "https://example.com", ""),
185            ("text", "https://example.com", "[text](https://example.com)"),
186        ] {
187            assert_eq!(fmt.link(url, input.to_string()), expected);
188        }
189    }
190
191    #[test]
192    fn test_djot_wrap_punctuation() {
193        let fmt = Djot;
194
195        for (wrap, input, expected) in [
196            (WrapPunctuation::Parentheses, "text", "(text)"),
197            (WrapPunctuation::Brackets, "text", "[text]"),
198            (WrapPunctuation::Quotes, "text", "\u{201C}text\u{201D}"),
199        ] {
200            assert_eq!(fmt.wrap_punctuation(&wrap, input.to_string()), expected);
201        }
202    }
203}