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