]>
Commit | Line | Data |
---|---|---|
260e8ec6 RBR |
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 | } |