]> git.r.bdr.sh - rbdr/gema_texto/blob - src/html_renderer.rs
Fix regression where .gmi wasn't being replaced to .html
[rbdr/gema_texto] / 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 ///
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 }