diff options
Diffstat (limited to 'src/html_renderer.rs')
| -rw-r--r-- | src/html_renderer.rs | 267 |
1 files changed, 267 insertions, 0 deletions
diff --git a/src/html_renderer.rs b/src/html_renderer.rs new file mode 100644 index 0000000..47d36bb --- /dev/null +++ b/src/html_renderer.rs @@ -0,0 +1,267 @@ +use crate::gemini_parser::GeminiLine; + +/// Renders HTML from a vector of GeminiLine elements. +/// +/// # Arguments +/// * `lines` - Vector of GeminiLine elements to render +/// +/// # Returns +/// A String containing the rendered HTML. +pub fn render_html(lines: Vec<GeminiLine>) -> String { + let mut heading_stack = Vec::new(); + let mut last_line: Option<&GeminiLine> = None; + let mut result = String::new(); + + for line in &lines { + result.push_str(&line_preamble(line, last_line, &mut heading_stack)); + result.push_str(&line_content(line)); + result.push('\n'); + last_line = Some(line); + } + + result.push_str(&finalize_html(&heading_stack, last_line)); + result +} + +fn line_preamble( + line: &GeminiLine, + last_line: Option<&GeminiLine>, + heading_stack: &mut Vec<u8> +) -> String { + let mut html = String::new(); + + if let Some(last_line) = last_line { + match last_line { + GeminiLine::ListItem(_) => { + match line { + GeminiLine::ListItem(_) => {}, + _ => html.push_str("</ul>\n"), + } + }, + GeminiLine::Quote(_) => { + match line { + GeminiLine::Quote(_) => {}, + _ => html.push_str("</blockquote>\n"), + } + }, + _ => {} + } + } + + match line { + GeminiLine::Heading(level, _) => { + while let Some(open_heading) = heading_stack.pop() { + // You just encountered a more important heading. + // Put it back. Desist. + if open_heading < *level { + heading_stack.push(open_heading); + break; + } + + html.push_str("</section>\n"); + + if open_heading == *level { + break; + } + } + heading_stack.push(*level); + html.push_str(&format!("<section class=\"h{}\">\n", level)); + }, + GeminiLine::ListItem(_) => { + match last_line { + Some(GeminiLine::ListItem(_)) => {}, + _ => html.push_str("<ul>\n"), + } + }, + GeminiLine::Quote(_) => { + match last_line { + Some(GeminiLine::Quote(_)) => {}, + _ => html.push_str("<blockquote>\n"), + } + }, + _ => {} + } + + html +} + +fn line_content(line: &GeminiLine) -> String { + match line { + GeminiLine::Text(content, false) => format!("<p>{}</p>", content), + GeminiLine::Link(url, text) => { + let display = if text.is_empty() { url } else { text }; + format!("<p class=\"a\"><a href=\"{}\">{}</a></p>", url, display) + }, + GeminiLine::Heading(level, content) => format!("<h{}>{}</h{}>", level, content, level), + GeminiLine::ListItem(content) => format!("<li>{}</li>", content), + GeminiLine::PreformattedToggle(true, alt_text) => { format!("<pre aria-label=\"{}\">", alt_text) } + GeminiLine::PreformattedToggle(false, _) => { "</pre>".to_string() } + GeminiLine::Text(content, true) | GeminiLine::Quote(content) => format!("{}", content) + } +} + +fn finalize_html(heading_stack: &[u8], last_line: Option<&GeminiLine>) -> String { + let mut html = String::new(); + + match last_line { + Some(GeminiLine::ListItem(_)) => html.push_str("</ul>\n"), + Some(GeminiLine::Quote(_)) => html.push_str("</blockquote>\n"), + _ => {} + } + + for _ in heading_stack { + html.push_str("</section>\n"); + } + + html +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_text() { + let input = vec![ + GeminiLine::Text("Hello world".to_string(), false), + ]; + assert_eq!( + render_html(input), + "<p>Hello world</p>\n" + ); + } + + #[test] + fn test_heading_nesting() { + let input = vec![ + GeminiLine::Heading(1, "Top".to_string()), + GeminiLine::Heading(2, "Sub".to_string()), + GeminiLine::Heading(3, "SubSub".to_string()), + GeminiLine::Heading(2, "Another Sub".to_string()), + ]; + assert_eq!( + render_html(input), + "<section class=\"h1\">\n\ + <h1>Top</h1>\n\ + <section class=\"h2\">\n\ + <h2>Sub</h2>\n\ + <section class=\"h3\">\n\ + <h3>SubSub</h3>\n\ + </section>\n\ + </section>\n\ + <section class=\"h2\">\n\ + <h2>Another Sub</h2>\n\ + </section>\n\ + </section>\n" + ); + } + + #[test] + fn test_list_transitions() { + let input = vec![ + GeminiLine::ListItem("First".to_string()), + GeminiLine::ListItem("Second".to_string()), + GeminiLine::Text("Break".to_string(), false), + GeminiLine::ListItem("New list".to_string()), + ]; + assert_eq!( + render_html(input), + "<ul>\n\ + <li>First</li>\n\ + <li>Second</li>\n\ + </ul>\n\ + <p>Break</p>\n\ + <ul>\n\ + <li>New list</li>\n\ + </ul>\n" + ); + } + + #[test] + fn test_quote_transitions() { + let input = vec![ + GeminiLine::Quote("First quote".to_string()), + GeminiLine::Quote("Still quoting".to_string()), + GeminiLine::Text("Normal text".to_string(), false), + GeminiLine::Quote("New quote".to_string()), + ]; + assert_eq!( + render_html(input), + "<blockquote>\n\ + First quote\n\ + Still quoting\n\ + </blockquote>\n\ + <p>Normal text</p>\n\ + <blockquote>\n\ + New quote\n\ + </blockquote>\n" + ); + } + + #[test] + fn test_preformatted() { + let input = vec![ + GeminiLine::PreformattedToggle(true, "code".to_string()), + GeminiLine::Text("let x = 42;".to_string(), true), + GeminiLine::PreformattedToggle(false, String::new()), + ]; + assert_eq!( + render_html(input), + "<pre aria-label=\"code\">\n\ + let x = 42;\n\ + </pre>\n" + ); + } + + #[test] + fn test_links() { + let input = vec![ + GeminiLine::Link("https://example.com".to_string(), "Example".to_string()), + GeminiLine::Link("https://rust-lang.org".to_string(), "".to_string()), + ]; + assert_eq!( + render_html(input), + "<p class=\"a\"><a href=\"https://example.com\">Example</a></p>\n\ + <p class=\"a\"><a href=\"https://rust-lang.org\">https://rust-lang.org</a></p>\n" + ); + } + + #[test] + fn test_complex_nesting() { + let input = vec![ + GeminiLine::Heading(1, "Title".to_string()), + GeminiLine::Text("Intro".to_string(), false), + GeminiLine::Heading(2, "Section".to_string()), + GeminiLine::ListItem("Point 1".to_string()), + GeminiLine::ListItem("Point 2".to_string()), + GeminiLine::Quote("Important quote".to_string()), + GeminiLine::Heading(2, "Another Section".to_string()), + ]; + assert_eq!( + render_html(input), + "<section class=\"h1\">\n\ + <h1>Title</h1>\n\ + <p>Intro</p>\n\ + <section class=\"h2\">\n\ + <h2>Section</h2>\n\ + <ul>\n\ + <li>Point 1</li>\n\ + <li>Point 2</li>\n\ + </ul>\n\ + <blockquote>\n\ + Important quote\n\ + </blockquote>\n\ + </section>\n\ + <section class=\"h2\">\n\ + <h2>Another Section</h2>\n\ + </section>\n\ + </section>\n" + ); + } + + #[test] + fn test_empty_input() { + let input = Vec::new(); + assert_eq!(render_html(input), ""); + } +} |