]> git.r.bdr.sh - rbdr/gema_texto/blame - src/html_renderer.rs
Address clippy
[rbdr/gema_texto] / src / html_renderer.rs
CommitLineData
8beb2bfa 1use crate::gemini_parser::GeminiLine;
3a544e1f 2use std::path::Path;
8beb2bfa
RBR
3
4/// Renders HTML from a vector of `GeminiLine` elements.
5///
6/// # Arguments
7/// * `lines` - Vector of `GeminiLine` elements to render
8///
9/// # Returns
10/// A String containing the rendered HTML.
11///
12/// # Examples
13/// ```
14/// use std::fs::read_to_string;
15/// use gema_texto::gemini_parser::parse;
16/// use gema_texto::html_renderer::render_html;
17///
18/// let gemini_source = "\
19/// # Hello
20/// This is some gemini text!
21/// => https://test Go to the test page.
22/// ";
23///
24/// let gemini_lines = parse(&gemini_source);
25/// let html_source = render_html(&gemini_lines);
26/// println!("{html_source}");
27/// ```
28#[must_use]
29pub fn render_html(lines: &[GeminiLine]) -> String {
30 let mut heading_stack = Vec::new();
31 let mut last_line: Option<&GeminiLine> = None;
32 let mut result = String::new();
33
34 for line in lines {
35 result.push_str(&line_preamble(line, last_line, &mut heading_stack));
36 result.push_str(&line_content(line));
37 result.push('\n');
38 last_line = Some(line);
39 }
40
41 result.push_str(&finalize_html(&heading_stack, last_line));
42 result
43}
44
45fn line_preamble(
46 line: &GeminiLine,
47 last_line: Option<&GeminiLine>,
48 heading_stack: &mut Vec<u8>,
49) -> String {
50 let mut html = String::new();
51
52 if let Some(last_line) = last_line {
53 match last_line {
54 GeminiLine::ListItem(_) => match line {
55 GeminiLine::ListItem(_) => {}
56 _ => html.push_str("</ul>\n"),
57 },
58 GeminiLine::Quote(_) => match line {
59 GeminiLine::Quote(_) => {}
60 _ => html.push_str("</blockquote>\n"),
61 },
62 _ => {}
63 }
64 }
65
66 match line {
67 GeminiLine::Heading(level, _) => {
68 while let Some(open_heading) = heading_stack.pop() {
69 // You just encountered a more important heading.
70 // Put it back. Desist.
71 if open_heading < *level {
72 heading_stack.push(open_heading);
73 break;
74 }
75
76 html.push_str("</section>\n");
77
78 if open_heading == *level {
79 break;
80 }
81 }
82 heading_stack.push(*level);
83 html.push_str(&format!("<section class=\"h{level}\">\n"));
84 }
85 GeminiLine::ListItem(_) => match last_line {
86 Some(GeminiLine::ListItem(_)) => {}
87 _ => html.push_str("<ul>\n"),
88 },
89 GeminiLine::Quote(_) => match last_line {
90 Some(GeminiLine::Quote(_)) => {}
91 _ => html.push_str("<blockquote>\n"),
92 },
93 _ => {}
94 }
95
96 html
97}
98
99fn line_content(line: &GeminiLine) -> String {
100 match line {
101 GeminiLine::Text(content, false) => format!("<p>{content}</p>"),
102 GeminiLine::Link(url, text) => {
3a544e1f
RBR
103 let path = Path::new(url);
104 let processed_url = if !url.starts_with("gemini:")
105 && path
106 .extension()
107 .map_or(false, |ext| ext.eq_ignore_ascii_case("gmi"))
108 {
109 url.replace(".gmi", ".html")
5f7e1e31 110 } else {
3a544e1f
RBR
111 url.to_string()
112 };
113
9d687f8c
RBR
114 let display = if text.is_empty() {
115 &processed_url
116 } else {
117 text
118 };
5f7e1e31 119 format!("<p class=\"a\"><a href=\"{processed_url}\">{display}</a></p>")
8beb2bfa
RBR
120 }
121 GeminiLine::Heading(level, content) => format!("<h{level}>{content}</h{level}>"),
122 GeminiLine::ListItem(content) => format!("<li>{content}</li>"),
123 GeminiLine::PreformattedToggle(true, alt_text) => {
124 format!("<pre aria-label=\"{alt_text}\">")
125 }
126 GeminiLine::PreformattedToggle(false, _) => "</pre>".to_string(),
127 GeminiLine::Text(content, true) | GeminiLine::Quote(content) => content.to_string(),
128 }
129}
130
131fn finalize_html(heading_stack: &[u8], last_line: Option<&GeminiLine>) -> String {
132 let mut html = String::new();
133
134 match last_line {
135 Some(GeminiLine::ListItem(_)) => html.push_str("</ul>\n"),
136 Some(GeminiLine::Quote(_)) => html.push_str("</blockquote>\n"),
137 _ => {}
138 }
139
140 for _ in heading_stack {
141 html.push_str("</section>\n");
142 }
143
144 html
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn test_simple_text() {
153 let input = vec![GeminiLine::Text("Hello world".to_string(), false)];
154 assert_eq!(render_html(&input), "<p>Hello world</p>\n");
155 }
156
157 #[test]
158 fn test_heading_nesting() {
159 let input = vec![
160 GeminiLine::Heading(1, "Top".to_string()),
161 GeminiLine::Heading(2, "Sub".to_string()),
162 GeminiLine::Heading(3, "SubSub".to_string()),
163 GeminiLine::Heading(2, "Another Sub".to_string()),
164 ];
165 assert_eq!(
166 render_html(&input),
167 "<section class=\"h1\">\n\
168 <h1>Top</h1>\n\
169 <section class=\"h2\">\n\
170 <h2>Sub</h2>\n\
171 <section class=\"h3\">\n\
172 <h3>SubSub</h3>\n\
173 </section>\n\
174 </section>\n\
175 <section class=\"h2\">\n\
176 <h2>Another Sub</h2>\n\
177 </section>\n\
178 </section>\n"
179 );
180 }
181
182 #[test]
183 fn test_list_transitions() {
184 let input = vec![
185 GeminiLine::ListItem("First".to_string()),
186 GeminiLine::ListItem("Second".to_string()),
187 GeminiLine::Text("Break".to_string(), false),
188 GeminiLine::ListItem("New list".to_string()),
189 ];
190 assert_eq!(
191 render_html(&input),
192 "<ul>\n\
193 <li>First</li>\n\
194 <li>Second</li>\n\
195 </ul>\n\
196 <p>Break</p>\n\
197 <ul>\n\
198 <li>New list</li>\n\
199 </ul>\n"
200 );
201 }
202
203 #[test]
204 fn test_quote_transitions() {
205 let input = vec![
206 GeminiLine::Quote("First quote".to_string()),
207 GeminiLine::Quote("Still quoting".to_string()),
208 GeminiLine::Text("Normal text".to_string(), false),
209 GeminiLine::Quote("New quote".to_string()),
210 ];
211 assert_eq!(
212 render_html(&input),
213 "<blockquote>\n\
214 First quote\n\
215 Still quoting\n\
216 </blockquote>\n\
217 <p>Normal text</p>\n\
218 <blockquote>\n\
219 New quote\n\
220 </blockquote>\n"
221 );
222 }
223
224 #[test]
225 fn test_preformatted() {
226 let input = vec![
227 GeminiLine::PreformattedToggle(true, "code".to_string()),
228 GeminiLine::Text("let x = 42;".to_string(), true),
229 GeminiLine::PreformattedToggle(false, String::new()),
230 ];
231 assert_eq!(
232 render_html(&input),
233 "<pre aria-label=\"code\">\n\
234 let x = 42;\n\
235 </pre>\n"
236 );
237 }
238
239 #[test]
240 fn test_links() {
241 let input = vec![
5f7e1e31
RBR
242 GeminiLine::Link("gemini://hi.gmi".to_string(), "Example".to_string()),
243 GeminiLine::Link("/hi.gmi/kidding".to_string(), "Example".to_string()),
244 GeminiLine::Link("/hi.gmi".to_string(), "Example".to_string()),
8beb2bfa
RBR
245 GeminiLine::Link("https://example.com".to_string(), "Example".to_string()),
246 GeminiLine::Link("https://rust-lang.org".to_string(), String::new()),
247 ];
248 assert_eq!(
249 render_html(&input),
5f7e1e31
RBR
250 "<p class=\"a\"><a href=\"gemini://hi.gmi\">Example</a></p>\n\
251 <p class=\"a\"><a href=\"/hi.gmi/kidding\">Example</a></p>\n\
252 <p class=\"a\"><a href=\"/hi.html\">Example</a></p>\n\
253 <p class=\"a\"><a href=\"https://example.com\">Example</a></p>\n\
8beb2bfa
RBR
254 <p class=\"a\"><a href=\"https://rust-lang.org\">https://rust-lang.org</a></p>\n"
255 );
256 }
257
258 #[test]
259 fn test_complex_nesting() {
260 let input = vec![
261 GeminiLine::Heading(1, "Title".to_string()),
262 GeminiLine::Text("Intro".to_string(), false),
263 GeminiLine::Heading(2, "Section".to_string()),
264 GeminiLine::ListItem("Point 1".to_string()),
265 GeminiLine::ListItem("Point 2".to_string()),
266 GeminiLine::Quote("Important quote".to_string()),
267 GeminiLine::Heading(2, "Another Section".to_string()),
268 ];
269 assert_eq!(
270 render_html(&input),
271 "<section class=\"h1\">\n\
272 <h1>Title</h1>\n\
273 <p>Intro</p>\n\
274 <section class=\"h2\">\n\
275 <h2>Section</h2>\n\
276 <ul>\n\
277 <li>Point 1</li>\n\
278 <li>Point 2</li>\n\
279 </ul>\n\
280 <blockquote>\n\
281 Important quote\n\
282 </blockquote>\n\
283 </section>\n\
284 <section class=\"h2\">\n\
285 <h2>Another Section</h2>\n\
286 </section>\n\
287 </section>\n"
288 );
289 }
290
291 #[test]
292 fn test_empty_input() {
293 let input = Vec::new();
294 assert_eq!(render_html(&input), "");
295 }
296}