1 use crate::gemini_parser::GeminiLine;
4 /// Renders HTML from a vector of `GeminiLine` elements.
7 /// * `lines` - Vector of `GeminiLine` elements to render
10 /// A String containing the rendered HTML.
14 /// use std::fs::read_to_string;
15 /// use gema_texto::gemini_parser::parse;
16 /// use gema_texto::html_renderer::render_html;
18 /// let gemini_source = "\
20 /// This is some gemini text!
21 /// => https://test Go to the test page.
24 /// let gemini_lines = parse(&gemini_source);
25 /// let html_source = render_html(&gemini_lines);
26 /// println!("{html_source}");
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();
35 result.push_str(&line_preamble(line, last_line, &mut heading_stack));
36 result.push_str(&line_content(line));
38 last_line = Some(line);
41 result.push_str(&finalize_html(&heading_stack, last_line));
47 last_line: Option<&GeminiLine>,
48 heading_stack: &mut Vec<u8>,
50 let mut html = String::new();
52 if let Some(last_line) = last_line {
54 GeminiLine::ListItem(_) => match line {
55 GeminiLine::ListItem(_) => {}
56 _ => html.push_str("</ul>\n"),
58 GeminiLine::Quote(_) => match line {
59 GeminiLine::Quote(_) => {}
60 _ => html.push_str("</blockquote>\n"),
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);
76 html.push_str("</section>\n");
78 if open_heading == *level {
82 heading_stack.push(*level);
83 html.push_str(&format!("<section class=\"h{level}\">\n"));
85 GeminiLine::ListItem(_) => match last_line {
86 Some(GeminiLine::ListItem(_)) => {}
87 _ => html.push_str("<ul>\n"),
89 GeminiLine::Quote(_) => match last_line {
90 Some(GeminiLine::Quote(_)) => {}
91 _ => html.push_str("<blockquote>\n"),
99 fn line_content(line: &GeminiLine) -> String {
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:")
107 .map_or(false, |ext| ext.eq_ignore_ascii_case("gmi"))
109 url.replace(".gmi", ".html")
114 let display = if text.is_empty() {
119 format!("<p class=\"a\"><a href=\"{processed_url}\">{display}</a></p>")
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}\">")
126 GeminiLine::PreformattedToggle(false, _) => "</pre>".to_string(),
127 GeminiLine::Text(content, true) | GeminiLine::Quote(content) => content
128 .replace('<', "<")
129 .replace('>', ">")
134 fn finalize_html(heading_stack: &[u8], last_line: Option<&GeminiLine>) -> String {
135 let mut html = String::new();
138 Some(GeminiLine::ListItem(_)) => html.push_str("</ul>\n"),
139 Some(GeminiLine::Quote(_)) => html.push_str("</blockquote>\n"),
143 for _ in heading_stack {
144 html.push_str("</section>\n");
155 fn test_simple_text() {
156 let input = vec![GeminiLine::Text("Hello world".to_string(), false)];
157 assert_eq!(render_html(&input), "<p>Hello world</p>\n");
161 fn test_heading_nesting() {
163 GeminiLine::Heading(1, "Top".to_string()),
164 GeminiLine::Heading(2, "Sub".to_string()),
165 GeminiLine::Heading(3, "SubSub".to_string()),
166 GeminiLine::Heading(2, "Another Sub".to_string()),
170 "<section class=\"h1\">\n\
172 <section class=\"h2\">\n\
174 <section class=\"h3\">\n\
178 <section class=\"h2\">\n\
179 <h2>Another Sub</h2>\n\
186 fn test_list_transitions() {
188 GeminiLine::ListItem("First".to_string()),
189 GeminiLine::ListItem("Second".to_string()),
190 GeminiLine::Text("Break".to_string(), false),
191 GeminiLine::ListItem("New list".to_string()),
207 fn test_quote_transitions() {
209 GeminiLine::Quote("First quote".to_string()),
210 GeminiLine::Quote("Still quoting".to_string()),
211 GeminiLine::Text("Normal text".to_string(), false),
212 GeminiLine::Quote("New quote".to_string()),
220 <p>Normal text</p>\n\
228 fn test_preformatted() {
230 GeminiLine::PreformattedToggle(true, "code".to_string()),
231 GeminiLine::Text("let x = 42;".to_string(), true),
232 GeminiLine::PreformattedToggle(false, String::new()),
236 "<pre aria-label=\"code\">\n\
245 GeminiLine::Link("gemini://hi.gmi".to_string(), "Example".to_string()),
246 GeminiLine::Link("/hi.gmi/kidding".to_string(), "Example".to_string()),
247 GeminiLine::Link("/hi.gmi".to_string(), "Example".to_string()),
248 GeminiLine::Link("https://example.com".to_string(), "Example".to_string()),
249 GeminiLine::Link("https://rust-lang.org".to_string(), String::new()),
253 "<p class=\"a\"><a href=\"gemini://hi.gmi\">Example</a></p>\n\
254 <p class=\"a\"><a href=\"/hi.gmi/kidding\">Example</a></p>\n\
255 <p class=\"a\"><a href=\"/hi.html\">Example</a></p>\n\
256 <p class=\"a\"><a href=\"https://example.com\">Example</a></p>\n\
257 <p class=\"a\"><a href=\"https://rust-lang.org\">https://rust-lang.org</a></p>\n"
262 fn test_complex_nesting() {
264 GeminiLine::Heading(1, "Title".to_string()),
265 GeminiLine::Text("Intro".to_string(), false),
266 GeminiLine::Heading(2, "Section".to_string()),
267 GeminiLine::ListItem("Point 1".to_string()),
268 GeminiLine::ListItem("Point 2".to_string()),
269 GeminiLine::Quote("Important quote".to_string()),
270 GeminiLine::Heading(2, "Another Section".to_string()),
274 "<section class=\"h1\">\n\
277 <section class=\"h2\">\n\
287 <section class=\"h2\">\n\
288 <h2>Another Section</h2>\n\
295 fn test_empty_input() {
296 let input = Vec::new();
297 assert_eq!(render_html(&input), "");