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