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