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
4*/
5
6//! Output format trait for pluggable renderers.
7
8use citum_schema::template::WrapPunctuation;
9
10/// Extra attributes applied to semantic wrappers when a renderer supports them.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct SemanticAttribute {
13    /// The attribute name.
14    pub name: &'static str,
15    /// The attribute value.
16    pub value: String,
17}
18
19/// Trait for defining how to render template components into a specific format.
20///
21/// Implementations of this trait define how various formatting instructions
22/// (emphasis, quotes, links, etc.) are translated into specific markup or text.
23pub trait OutputFormat: Default + Clone {
24    /// The type used for intermediate rendered content.
25    ///
26    /// For simple text formats, this is usually `String`. More complex formats
27    /// might use an AST or a specialized builder type.
28    type Output;
29
30    /// Convert a raw string into the format's output type.
31    ///
32    /// The implementation should handle any necessary character escaping
33    /// required by the target format.
34    fn text(&self, s: &str) -> Self::Output;
35
36    /// Join multiple outputs into a single output using a delimiter.
37    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output;
38
39    /// Convert the intermediate output into the final result string.
40    ///
41    /// This is called exactly once at the end of the rendering process
42    /// for a top-level component (citation or bibliography entry).
43    fn finish(&self, output: Self::Output) -> String;
44
45    /// Render content with emphasis (typically italics).
46    fn emph(&self, content: Self::Output) -> Self::Output;
47
48    /// Render content with strong emphasis (typically bold).
49    fn strong(&self, content: Self::Output) -> Self::Output;
50
51    /// Render content in small capitals.
52    fn small_caps(&self, content: Self::Output) -> Self::Output;
53
54    /// Render content as superscript text.
55    fn superscript(&self, content: Self::Output) -> Self::Output;
56
57    /// Render content enclosed in quotation marks.
58    fn quote(&self, content: Self::Output) -> Self::Output;
59
60    /// Apply outer prefix and suffix strings to the content.
61    ///
62    /// These are typically the "prefix" and "suffix" fields from the Citum style.
63    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
64
65    /// Apply inner prefix and suffix strings to the content.
66    ///
67    /// These are applied inside any wrapping punctuation.
68    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
69
70    /// Wrap the content in specific punctuation (parentheses, brackets, or quotes).
71    fn wrap_punctuation(&self, wrap: &WrapPunctuation, content: Self::Output) -> Self::Output;
72
73    /// Apply a semantic identifier (class) to the content.
74    ///
75    /// This is used for machine readability or fine-grained CSS styling.
76    /// Examples include "citum-title", "citum-author", "citum-doi".
77    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output;
78
79    /// Render an annotation block.
80    ///
81    /// This is typically called at the end of a bibliography entry to render
82    /// reader-supplied notes.
83    fn annotation(&self, content: Self::Output) -> Self::Output;
84
85    /// Apply a semantic identifier plus optional attributes to the content.
86    ///
87    /// Formats that do not support extra attributes can ignore them and reuse
88    /// [`Self::semantic`].
89    fn semantic_with_attributes(
90        &self,
91        class: &str,
92        content: Self::Output,
93        _attributes: &[SemanticAttribute],
94    ) -> Self::Output {
95        self.semantic(class, content)
96    }
97
98    /// Render a full citation container with one or more reference IDs.
99    fn citation(&self, _ids: Vec<String>, content: Self::Output) -> Self::Output {
100        content
101    }
102
103    /// Hyperlink the content to a URL.
104    fn link(&self, url: &str, content: Self::Output) -> Self::Output;
105
106    /// Format a reference ID for use as a target or link (e.g. adding a prefix).
107    fn format_id(&self, id: &str) -> String {
108        id.to_string()
109    }
110
111    /// Render a full bibliography container.
112    ///
113    /// The default implementation joins the entries with double newlines.
114    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
115        self.join(entries, "\n\n")
116    }
117
118    /// Render a single bibliography entry with its unique identifier and optional link.
119    ///
120    /// The default implementation just returns the content.
121    fn entry(
122        &self,
123        _id: &str,
124        content: Self::Output,
125        _url: Option<&str>,
126        _metadata: &ProcEntryMetadata,
127    ) -> Self::Output {
128        content
129    }
130}
131
132/// Metadata for a processed bibliography entry, used for interactivity.
133#[derive(Debug, Clone, Default, PartialEq)]
134pub struct ProcEntryMetadata {
135    /// Rendered primary author(s) string.
136    pub author: Option<String>,
137    /// Rendered year string.
138    pub year: Option<String>,
139    /// Rendered title string.
140    pub title: Option<String>,
141}
142
143#[cfg(test)]
144#[allow(
145    clippy::unwrap_used,
146    clippy::expect_used,
147    clippy::panic,
148    clippy::indexing_slicing,
149    clippy::todo,
150    clippy::unimplemented,
151    clippy::unreachable,
152    clippy::get_unwrap,
153    reason = "Panicking is acceptable and often desired in tests."
154)]
155mod tests {
156    use super::*;
157
158    #[derive(Default, Clone)]
159    struct DummyFormat;
160
161    impl OutputFormat for DummyFormat {
162        type Output = String;
163        fn text(&self, s: &str) -> Self::Output {
164            s.to_string()
165        }
166        fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
167            items.join(delimiter)
168        }
169        fn finish(&self, output: Self::Output) -> String {
170            output
171        }
172        fn emph(&self, content: Self::Output) -> Self::Output {
173            format!("emph({content})")
174        }
175        fn strong(&self, content: Self::Output) -> Self::Output {
176            format!("strong({content})")
177        }
178        fn small_caps(&self, content: Self::Output) -> Self::Output {
179            format!("sc({content})")
180        }
181        fn superscript(&self, content: Self::Output) -> Self::Output {
182            format!("sup({content})")
183        }
184        fn quote(&self, content: Self::Output) -> Self::Output {
185            format!("quote({content})")
186        }
187        fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
188            format!("{prefix}{content}{suffix}")
189        }
190        fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
191            format!("{prefix}{content}{suffix}")
192        }
193        fn wrap_punctuation(&self, _wrap: &WrapPunctuation, content: Self::Output) -> Self::Output {
194            content
195        }
196        fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
197            format!("sem[{class}]({content})")
198        }
199        fn annotation(&self, content: Self::Output) -> Self::Output {
200            format!("annot({content})")
201        }
202        fn link(&self, url: &str, content: Self::Output) -> Self::Output {
203            format!("link[{url}]({content})")
204        }
205    }
206
207    #[test]
208    fn test_default_methods() {
209        let fmt = DummyFormat;
210        assert_eq!(
211            fmt.semantic_with_attributes("test", "content".to_string(), &[]),
212            "sem[test](content)"
213        );
214        assert_eq!(
215            fmt.citation(vec!["id1".to_string()], "content".to_string()),
216            "content"
217        );
218        assert_eq!(fmt.format_id("id1"), "id1");
219        assert_eq!(
220            fmt.bibliography(vec!["entry1".to_string(), "entry2".to_string()]),
221            "entry1\n\nentry2"
222        );
223        assert_eq!(
224            fmt.entry(
225                "id1",
226                "content".to_string(),
227                None,
228                &ProcEntryMetadata::default()
229            ),
230            "content"
231        );
232    }
233}