1use super::format::{OutputFormat, QuoteMarks};
24use citum_schema::template::WrapPunctuation;
25
26fn 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#[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 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 fn emph(&self, content: Self::Output) -> Self::Output {
74 if content.is_empty() {
75 return content;
76 }
77 format!("*{content}*")
78 }
79
80 fn strong(&self, content: Self::Output) -> Self::Output {
82 if content.is_empty() {
83 return content;
84 }
85 format!("**{content}**")
86 }
87
88 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 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 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 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}