Skip to main content

citum_engine/render/
format.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Output format trait for pluggable renderers.
7
8use citum_schema::template::WrapPunctuation;
9
10/// Return Unicode quote marks for a nesting depth.
11///
12/// Even depths use outer double quotes; odd depths use inner single quotes.
13#[must_use]
14pub fn unicode_quote_marks(depth: usize) -> (&'static str, &'static str) {
15    if depth.is_multiple_of(2) {
16        ("\u{201C}", "\u{201D}")
17    } else {
18        ("\u{2018}", "\u{2019}")
19    }
20}
21
22/// Extra attributes applied to semantic wrappers when a renderer supports them.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SemanticAttribute {
25    /// The attribute name.
26    pub name: &'static str,
27    /// The attribute value.
28    pub value: String,
29}
30
31/// Trait for defining how to render template components into a specific format.
32///
33/// Implementations of this trait define how various formatting instructions
34/// (emphasis, quotes, links, etc.) are translated into specific markup or text.
35pub trait OutputFormat: Default + Clone {
36    /// The type used for intermediate rendered content.
37    ///
38    /// For simple text formats, this is usually `String`. More complex formats
39    /// might use an AST or a specialized builder type.
40    type Output;
41
42    /// Convert a raw string into the format's output type.
43    ///
44    /// The implementation should handle any necessary character escaping
45    /// required by the target format.
46    fn text(&self, s: &str) -> Self::Output;
47
48    /// Join multiple outputs into a single output using a delimiter.
49    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output;
50
51    /// Convert the intermediate output into the final result string.
52    ///
53    /// This is called exactly once at the end of the rendering process
54    /// for a top-level component (citation or bibliography entry).
55    fn finish(&self, output: Self::Output) -> String;
56
57    /// Render content with emphasis (typically italics).
58    fn emph(&self, content: Self::Output) -> Self::Output;
59
60    /// Render content with strong emphasis (typically bold).
61    fn strong(&self, content: Self::Output) -> Self::Output;
62
63    /// Render content in small capitals.
64    fn small_caps(&self, content: Self::Output) -> Self::Output;
65
66    /// Render content as superscript text.
67    fn superscript(&self, content: Self::Output) -> Self::Output;
68
69    /// Return the opening and closing quote delimiters for a nesting depth.
70    ///
71    /// Depth 0 is an outer quote pair, depth 1 is the first inner quote pair,
72    /// and deeper levels alternate between those two pairs.
73    fn quote_marks(&self, depth: usize) -> (&'static str, &'static str) {
74        unicode_quote_marks(depth)
75    }
76
77    /// Render content enclosed in quotation marks at a specific nesting depth.
78    fn quote_with_depth(&self, content: Self::Output, depth: usize) -> Self::Output {
79        let (open, close) = self.quote_marks(depth);
80        self.affix(open, content, close)
81    }
82
83    /// Render content enclosed in outer quotation marks.
84    fn quote(&self, content: Self::Output) -> Self::Output {
85        self.quote_with_depth(content, 0)
86    }
87
88    /// Apply outer prefix and suffix strings to the content.
89    ///
90    /// These are typically the "prefix" and "suffix" fields from the Citum style.
91    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
92
93    /// Apply inner prefix and suffix strings to the content.
94    ///
95    /// These are applied inside any wrapping punctuation.
96    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
97
98    /// Wrap the content in specific punctuation (parentheses, brackets, or quotes).
99    fn wrap_punctuation(&self, wrap: &WrapPunctuation, content: Self::Output) -> Self::Output;
100
101    /// Apply a semantic identifier (class) to the content.
102    ///
103    /// This is used for machine readability or fine-grained CSS styling.
104    /// Examples include "citum-title", "citum-author", "citum-doi".
105    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output;
106
107    /// Render an annotation block.
108    ///
109    /// This is typically called at the end of a bibliography entry to render
110    /// reader-supplied notes.
111    fn annotation(&self, content: Self::Output) -> Self::Output;
112
113    // ── Block-level methods (used by the body markup renderer) ─────────────
114    // Defaults produce plain passthrough so existing format impls need not change.
115
116    /// Render a paragraph block.
117    fn paragraph(&self, content: Self::Output) -> Self::Output {
118        content
119    }
120
121    /// Render a block quotation.
122    fn block_quote(&self, content: Self::Output) -> Self::Output {
123        content
124    }
125
126    /// Render an unordered (bullet) list from pre-rendered item strings.
127    fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
128        self.join(items, "\n")
129    }
130
131    /// Render an ordered (numbered) list from pre-rendered item strings.
132    fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
133        self.join(items, "\n")
134    }
135
136    /// Render a list item.
137    fn list_item(&self, content: Self::Output) -> Self::Output {
138        content
139    }
140
141    /// Render a heading at the given level (1 = top-level).
142    fn heading(&self, _level: u8, content: Self::Output) -> Self::Output {
143        content
144    }
145
146    /// Render a fenced or indented code block with an optional language hint.
147    ///
148    /// `content` is the raw (unescaped) code text.
149    fn code_block(&self, _lang: Option<&str>, content: Self::Output) -> Self::Output {
150        content
151    }
152
153    /// Render inline code.
154    fn inline_code(&self, content: Self::Output) -> Self::Output {
155        content
156    }
157
158    /// Render strikethrough text.
159    fn strikeout(&self, content: Self::Output) -> Self::Output {
160        content
161    }
162
163    /// Render a hard line break.
164    fn hard_break(&self) -> Self::Output {
165        self.text(" ")
166    }
167
168    /// Apply a semantic identifier plus optional attributes to the content.
169    ///
170    /// Formats that do not support extra attributes can ignore them and reuse
171    /// [`Self::semantic`].
172    fn semantic_with_attributes(
173        &self,
174        class: &str,
175        content: Self::Output,
176        _attributes: &[SemanticAttribute],
177    ) -> Self::Output {
178        self.semantic(class, content)
179    }
180
181    /// Render a full citation container with one or more reference IDs.
182    fn citation(&self, _ids: Vec<String>, content: Self::Output) -> Self::Output {
183        content
184    }
185
186    /// Hyperlink the content to a URL.
187    fn link(&self, url: &str, content: Self::Output) -> Self::Output;
188
189    /// Format a reference ID for use as a target or link (e.g. adding a prefix).
190    fn format_id(&self, id: &str) -> String {
191        id.to_string()
192    }
193
194    /// Render a full bibliography container.
195    ///
196    /// The default implementation joins the entries with double newlines.
197    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
198        self.join(entries, "\n\n")
199    }
200
201    /// Render a single bibliography entry with its unique identifier and optional link.
202    ///
203    /// The default implementation just returns the content.
204    fn entry(
205        &self,
206        _id: &str,
207        content: Self::Output,
208        _url: Option<&str>,
209        _metadata: &ProcEntryMetadata,
210    ) -> Self::Output {
211        content
212    }
213}
214
215/// Metadata for a processed bibliography entry, used for interactivity.
216#[derive(Debug, Clone, Default, PartialEq)]
217pub struct ProcEntryMetadata {
218    /// Rendered primary author(s) string.
219    pub author: Option<String>,
220    /// Rendered year string.
221    pub year: Option<String>,
222    /// Rendered title string.
223    pub title: Option<String>,
224}
225
226#[cfg(test)]
227#[allow(
228    clippy::unwrap_used,
229    clippy::expect_used,
230    clippy::panic,
231    clippy::indexing_slicing,
232    clippy::todo,
233    clippy::unimplemented,
234    clippy::unreachable,
235    clippy::get_unwrap,
236    reason = "Panicking is acceptable and often desired in tests."
237)]
238mod tests {
239    use super::*;
240
241    #[derive(Default, Clone)]
242    struct DummyFormat;
243
244    impl OutputFormat for DummyFormat {
245        type Output = String;
246        fn text(&self, s: &str) -> Self::Output {
247            s.to_string()
248        }
249        fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
250            items.join(delimiter)
251        }
252        fn finish(&self, output: Self::Output) -> String {
253            output
254        }
255        fn emph(&self, content: Self::Output) -> Self::Output {
256            format!("emph({content})")
257        }
258        fn strong(&self, content: Self::Output) -> Self::Output {
259            format!("strong({content})")
260        }
261        fn small_caps(&self, content: Self::Output) -> Self::Output {
262            format!("sc({content})")
263        }
264        fn superscript(&self, content: Self::Output) -> Self::Output {
265            format!("sup({content})")
266        }
267        fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
268            format!("{prefix}{content}{suffix}")
269        }
270        fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
271            format!("{prefix}{content}{suffix}")
272        }
273        fn wrap_punctuation(&self, _wrap: &WrapPunctuation, content: Self::Output) -> Self::Output {
274            content
275        }
276        fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
277            format!("sem[{class}]({content})")
278        }
279        fn annotation(&self, content: Self::Output) -> Self::Output {
280            format!("annot({content})")
281        }
282        fn link(&self, url: &str, content: Self::Output) -> Self::Output {
283            format!("link[{url}]({content})")
284        }
285    }
286
287    #[test]
288    fn test_default_methods() {
289        let fmt = DummyFormat;
290        assert_eq!(
291            fmt.semantic_with_attributes("test", "content".to_string(), &[]),
292            "sem[test](content)"
293        );
294        assert_eq!(
295            fmt.citation(vec!["id1".to_string()], "content".to_string()),
296            "content"
297        );
298        assert_eq!(fmt.format_id("id1"), "id1");
299        assert_eq!(
300            fmt.bibliography(vec!["entry1".to_string(), "entry2".to_string()]),
301            "entry1\n\nentry2"
302        );
303        assert_eq!(
304            fmt.entry(
305                "id1",
306                "content".to_string(),
307                None,
308                &ProcEntryMetadata::default()
309            ),
310            "content"
311        );
312    }
313}