]> git.r.bdr.sh - rbdr/gema_texto/blame - src/html_renderer.rs
Remove .env
[rbdr/gema_texto] / src / html_renderer.rs
CommitLineData
8beb2bfa
RBR
1use 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]
28pub 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
44fn 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
98fn line_content(line: &GeminiLine) -> String {
99 match line {
100 GeminiLine::Text(content, false) => format!("<p>{content}</p>"),
101 GeminiLine::Link(url, text) => {
102 let display = if text.is_empty() { url } else { text };
103 format!("<p class=\"a\"><a href=\"{url}\">{display}</a></p>")
104 }
105 GeminiLine::Heading(level, content) => format!("<h{level}>{content}</h{level}>"),
106 GeminiLine::ListItem(content) => format!("<li>{content}</li>"),
107 GeminiLine::PreformattedToggle(true, alt_text) => {
108 format!("<pre aria-label=\"{alt_text}\">")
109 }
110 GeminiLine::PreformattedToggle(false, _) => "</pre>".to_string(),
111 GeminiLine::Text(content, true) | GeminiLine::Quote(content) => content.to_string(),
112 }
113}
114
115fn finalize_html(heading_stack: &[u8], last_line: Option<&GeminiLine>) -> String {
116 let mut html = String::new();
117
118 match last_line {
119 Some(GeminiLine::ListItem(_)) => html.push_str("</ul>\n"),
120 Some(GeminiLine::Quote(_)) => html.push_str("</blockquote>\n"),
121 _ => {}
122 }
123
124 for _ in heading_stack {
125 html.push_str("</section>\n");
126 }
127
128 html
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn test_simple_text() {
137 let input = vec![GeminiLine::Text("Hello world".to_string(), false)];
138 assert_eq!(render_html(&input), "<p>Hello world</p>\n");
139 }
140
141 #[test]
142 fn test_heading_nesting() {
143 let input = vec![
144 GeminiLine::Heading(1, "Top".to_string()),
145 GeminiLine::Heading(2, "Sub".to_string()),
146 GeminiLine::Heading(3, "SubSub".to_string()),
147 GeminiLine::Heading(2, "Another Sub".to_string()),
148 ];
149 assert_eq!(
150 render_html(&input),
151 "<section class=\"h1\">\n\
152 <h1>Top</h1>\n\
153 <section class=\"h2\">\n\
154 <h2>Sub</h2>\n\
155 <section class=\"h3\">\n\
156 <h3>SubSub</h3>\n\
157 </section>\n\
158 </section>\n\
159 <section class=\"h2\">\n\
160 <h2>Another Sub</h2>\n\
161 </section>\n\
162 </section>\n"
163 );
164 }
165
166 #[test]
167 fn test_list_transitions() {
168 let input = vec![
169 GeminiLine::ListItem("First".to_string()),
170 GeminiLine::ListItem("Second".to_string()),
171 GeminiLine::Text("Break".to_string(), false),
172 GeminiLine::ListItem("New list".to_string()),
173 ];
174 assert_eq!(
175 render_html(&input),
176 "<ul>\n\
177 <li>First</li>\n\
178 <li>Second</li>\n\
179 </ul>\n\
180 <p>Break</p>\n\
181 <ul>\n\
182 <li>New list</li>\n\
183 </ul>\n"
184 );
185 }
186
187 #[test]
188 fn test_quote_transitions() {
189 let input = vec![
190 GeminiLine::Quote("First quote".to_string()),
191 GeminiLine::Quote("Still quoting".to_string()),
192 GeminiLine::Text("Normal text".to_string(), false),
193 GeminiLine::Quote("New quote".to_string()),
194 ];
195 assert_eq!(
196 render_html(&input),
197 "<blockquote>\n\
198 First quote\n\
199 Still quoting\n\
200 </blockquote>\n\
201 <p>Normal text</p>\n\
202 <blockquote>\n\
203 New quote\n\
204 </blockquote>\n"
205 );
206 }
207
208 #[test]
209 fn test_preformatted() {
210 let input = vec![
211 GeminiLine::PreformattedToggle(true, "code".to_string()),
212 GeminiLine::Text("let x = 42;".to_string(), true),
213 GeminiLine::PreformattedToggle(false, String::new()),
214 ];
215 assert_eq!(
216 render_html(&input),
217 "<pre aria-label=\"code\">\n\
218 let x = 42;\n\
219 </pre>\n"
220 );
221 }
222
223 #[test]
224 fn test_links() {
225 let input = vec![
226 GeminiLine::Link("https://example.com".to_string(), "Example".to_string()),
227 GeminiLine::Link("https://rust-lang.org".to_string(), String::new()),
228 ];
229 assert_eq!(
230 render_html(&input),
231 "<p class=\"a\"><a href=\"https://example.com\">Example</a></p>\n\
232 <p class=\"a\"><a href=\"https://rust-lang.org\">https://rust-lang.org</a></p>\n"
233 );
234 }
235
236 #[test]
237 fn test_complex_nesting() {
238 let input = vec![
239 GeminiLine::Heading(1, "Title".to_string()),
240 GeminiLine::Text("Intro".to_string(), false),
241 GeminiLine::Heading(2, "Section".to_string()),
242 GeminiLine::ListItem("Point 1".to_string()),
243 GeminiLine::ListItem("Point 2".to_string()),
244 GeminiLine::Quote("Important quote".to_string()),
245 GeminiLine::Heading(2, "Another Section".to_string()),
246 ];
247 assert_eq!(
248 render_html(&input),
249 "<section class=\"h1\">\n\
250 <h1>Title</h1>\n\
251 <p>Intro</p>\n\
252 <section class=\"h2\">\n\
253 <h2>Section</h2>\n\
254 <ul>\n\
255 <li>Point 1</li>\n\
256 <li>Point 2</li>\n\
257 </ul>\n\
258 <blockquote>\n\
259 Important quote\n\
260 </blockquote>\n\
261 </section>\n\
262 <section class=\"h2\">\n\
263 <h2>Another Section</h2>\n\
264 </section>\n\
265 </section>\n"
266 );
267 }
268
269 #[test]
270 fn test_empty_input() {
271 let input = Vec::new();
272 assert_eq!(render_html(&input), "");
273 }
274}