]>
Commit | Line | Data |
---|---|---|
1 | use crate::gemini_parser::GeminiLine; | |
2 | use std::path::Path; | |
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] | |
29 | pub 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 | ||
45 | fn 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 | ||
99 | fn line_content(line: &GeminiLine) -> String { | |
100 | match line { | |
101 | GeminiLine::Text(content, false) => format!("<p>{content}</p>"), | |
102 | GeminiLine::Link(url, text) => { | |
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") | |
110 | } else { | |
111 | url.to_string() | |
112 | }; | |
113 | ||
114 | let display = if text.is_empty() { | |
115 | &processed_url | |
116 | } else { | |
117 | text | |
118 | }; | |
119 | format!("<p class=\"a\"><a href=\"{processed_url}\">{display}</a></p>") | |
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 | ||
131 | fn 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)] | |
148 | mod 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![ | |
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()), | |
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), | |
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\ | |
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 | } |