]>
Commit | Line | Data |
---|---|---|
260e8ec6 RBR |
1 | use crate::gemini_parser::GeminiLine; |
2 | ||
3 | /// Renders HTML from a vector of GeminiLine elements. | |
5732d284 | 4 | /// |
260e8ec6 RBR |
5 | /// # Arguments |
6 | /// * `lines` - Vector of GeminiLine elements to render | |
5732d284 | 7 | /// |
260e8ec6 RBR |
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>, | |
5732d284 | 29 | heading_stack: &mut Vec<u8>, |
260e8ec6 RBR |
30 | ) -> String { |
31 | let mut html = String::new(); | |
32 | ||
33 | if let Some(last_line) = last_line { | |
34 | match last_line { | |
5732d284 RBR |
35 | GeminiLine::ListItem(_) => match line { |
36 | GeminiLine::ListItem(_) => {} | |
37 | _ => html.push_str("</ul>\n"), | |
260e8ec6 | 38 | }, |
5732d284 RBR |
39 | GeminiLine::Quote(_) => match line { |
40 | GeminiLine::Quote(_) => {} | |
41 | _ => html.push_str("</blockquote>\n"), | |
260e8ec6 RBR |
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{}\">\n", level)); | |
5732d284 RBR |
65 | } |
66 | GeminiLine::ListItem(_) => match last_line { | |
67 | Some(GeminiLine::ListItem(_)) => {} | |
68 | _ => html.push_str("<ul>\n"), | |
260e8ec6 | 69 | }, |
5732d284 RBR |
70 | GeminiLine::Quote(_) => match last_line { |
71 | Some(GeminiLine::Quote(_)) => {} | |
72 | _ => html.push_str("<blockquote>\n"), | |
260e8ec6 RBR |
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>{}</p>", content), | |
83 | GeminiLine::Link(url, text) => { | |
84 | let display = if text.is_empty() { url } else { text }; | |
85 | format!("<p class=\"a\"><a href=\"{}\">{}</a></p>", url, display) | |
5732d284 | 86 | } |
260e8ec6 RBR |
87 | GeminiLine::Heading(level, content) => format!("<h{}>{}</h{}>", level, content, level), |
88 | GeminiLine::ListItem(content) => format!("<li>{}</li>", content), | |
5732d284 RBR |
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(), | |
260e8ec6 RBR |
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() { | |
5732d284 RBR |
119 | let input = vec![GeminiLine::Text("Hello world".to_string(), false)]; |
120 | assert_eq!(render_html(input), "<p>Hello world</p>\n"); | |
260e8ec6 RBR |
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 | } |