diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-01-04 23:04:42 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-01-04 23:04:42 +0100 |
| commit | 8beb2bfa1af988442550c9e6999d924dd14b5236 (patch) | |
| tree | e24c5ca4b5d6da02895094f2341231236091132c /src | |
Add the library
Diffstat (limited to 'src')
| -rw-r--r-- | src/gemini_parser.rs | 331 | ||||
| -rw-r--r-- | src/html_renderer.rs | 274 | ||||
| -rw-r--r-- | src/lib.rs | 16 |
3 files changed, 621 insertions, 0 deletions
diff --git a/src/gemini_parser.rs b/src/gemini_parser.rs new file mode 100644 index 0000000..6d19092 --- /dev/null +++ b/src/gemini_parser.rs @@ -0,0 +1,331 @@ +#[derive(PartialEq, Eq, Debug)] + +/// Represents one of the six line types in gemtext. +/// +/// # Examples +/// ``` +/// use gema_texto::gemini_parser::GeminiLine; +/// use gema_texto::gemini_parser::parse; +/// +/// let gemini_source = "\ +/// # Hello +/// This is some gemini text! +/// => https://test Go to the test page. +/// +/// This text actually has more linkes +/// => https://test2 Go to the second test page. +/// => https://test3 Go to the third test page. +/// "; +/// +/// let gemini_lines = parse(&gemini_source); +/// // Count the number of links +/// let link_count = gemini_lines.iter() +/// .filter(|line| matches!(line, GeminiLine::Link(_, _))) +/// .count(); +/// assert_eq!(link_count, 3); +/// ``` +pub enum GeminiLine { + /// Text line, with a bool that shows whether it's preformatted. + Text(String, bool), + + /// Preformatted toggle line with alt text. + /// + /// The boolean indicates whether it turned preformatted mode on or off. + /// The string is the optional alt-text. + PreformattedToggle(bool, String), + + /// Heading with a number between 1 and 3 inclusive indicating level. + Heading(u8, String), + + /// Link with URL and text. + Link(String, String), + + /// Quote + Quote(String), + + /// List item + ListItem(String), +} + +/// Parses gemtext source code into a vector of `GeminiLine` elements. +/// +/// # Arguments +/// * `source` - A string slice that contains the gemtext +/// +/// # Returns +/// A `Vec<GeminiLine>` containing the rendered HTML. +/// +/// # Examples +/// +/// ``` +/// use std::fs::read_to_string; +/// use gema_texto::gemini_parser::parse; +/// let gemini_source = "\ +/// # Hello +/// This is some gemini text! +/// => https://test Go to the test page. +/// "; +/// +/// let gemini_lines = parse(&gemini_source); +/// +/// // Prints the different lines in the array. +/// for line in gemini_lines { +/// println!("{:#?}", line); +/// } +/// ``` +#[must_use] +pub fn parse(source: &str) -> Vec<GeminiLine> { + source + .lines() + .fold((Vec::new(), false), |(mut lines, is_preformatted), line| { + let parsed = if is_preformatted { + parse_preformatted_line(line) + } else { + parse_line(line) + }; + + let new_is_preformatted = match parsed { + GeminiLine::PreformattedToggle(x, _) => x, + _ => is_preformatted, + }; + + lines.push(parsed); + (lines, new_is_preformatted) + }) + .0 +} + +fn parse_preformatted_line(line: &str) -> GeminiLine { + match line { + s if s.starts_with("```") => GeminiLine::PreformattedToggle(false, String::new()), + _ => GeminiLine::Text(line.to_string(), true), + } +} + +fn parse_line(line: &str) -> GeminiLine { + match line { + s if s.starts_with("###") => GeminiLine::Heading(3, s[3..].to_string()), + s if s.starts_with("##") => GeminiLine::Heading(2, s[2..].to_string()), + s if s.starts_with('#') => GeminiLine::Heading(1, s[1..].to_string()), + s if s.starts_with("=>") => { + let content = s[2..].trim(); + match content.split_once(char::is_whitespace) { + Some((url, text)) => { + GeminiLine::Link(url.trim().to_string(), text.trim().to_string()) + } + None => GeminiLine::Link(content.trim().to_string(), String::new()), + } + } + s if s.starts_with("* ") => GeminiLine::ListItem(s[2..].to_string()), + s if s.starts_with('>') => GeminiLine::Quote(s[1..].to_string()), + s if s.starts_with("```") => GeminiLine::PreformattedToggle(true, s[3..].to_string()), + _ => GeminiLine::Text(line.to_string(), false), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_headings() { + assert_eq!( + parse_line("### Heading"), + GeminiLine::Heading(3, " Heading".to_string()) + ); + assert_eq!( + parse_line("## Heading"), + GeminiLine::Heading(2, " Heading".to_string()) + ); + assert_eq!( + parse_line("# Heading"), + GeminiLine::Heading(1, " Heading".to_string()) + ); + assert_eq!(parse_line("###"), GeminiLine::Heading(3, String::new())); + assert_eq!( + parse_line("#####"), + GeminiLine::Heading(3, "##".to_string()) + ); + assert_eq!(parse_line("# "), GeminiLine::Heading(1, " ".to_string())); + + assert_eq!( + parse_preformatted_line("### Heading"), + GeminiLine::Text("### Heading".to_string(), true) + ); + assert_eq!( + parse_preformatted_line("## Heading"), + GeminiLine::Text("## Heading".to_string(), true) + ); + assert_eq!( + parse_preformatted_line("# Heading"), + GeminiLine::Text("# Heading".to_string(), true) + ); + } + + #[test] + fn test_links() { + assert_eq!( + parse_line("=> https://example.com Link text"), + GeminiLine::Link("https://example.com".to_string(), "Link text".to_string()) + ); + assert_eq!( + parse_line("=> /local/path"), + GeminiLine::Link("/local/path".to_string(), String::new()) + ); + + assert_eq!( + parse_line("=>"), + GeminiLine::Link(String::new(), String::new()) + ); + assert_eq!( + parse_line("=> "), + GeminiLine::Link(String::new(), String::new()) + ); + assert_eq!( + parse_line("=> multiple spaces in text"), + GeminiLine::Link("multiple".to_string(), "spaces in text".to_string()) + ); + + assert_eq!( + parse_preformatted_line("=> https://example.com Link text"), + GeminiLine::Text("=> https://example.com Link text".to_string(), true) + ); + } + + #[test] + fn test_list_items() { + assert_eq!( + parse_line("* List item"), + GeminiLine::ListItem("List item".to_string()) + ); + + assert_eq!(parse_line("* "), GeminiLine::ListItem(String::new())); + assert_eq!(parse_line("*"), GeminiLine::Text("*".to_string(), false)); + assert_eq!( + parse_line("*WithText"), + GeminiLine::Text("*WithText".to_string(), false) + ); + assert_eq!( + parse_line("* Multiple spaces"), + GeminiLine::ListItem(" Multiple spaces".to_string()) + ); + } + + #[test] + fn test_quotes() { + assert_eq!( + parse_line(">Quote text"), + GeminiLine::Quote("Quote text".to_string()) + ); + + assert_eq!(parse_line(">"), GeminiLine::Quote(String::new())); + assert_eq!(parse_line("> "), GeminiLine::Quote(" ".to_string())); + assert_eq!( + parse_line(">>Nested"), + GeminiLine::Quote(">Nested".to_string()) + ); + } + + #[test] + fn test_preformatted() { + assert_eq!( + parse_line("```alt-text"), + GeminiLine::PreformattedToggle(true, "alt-text".to_string()) + ); + + assert_eq!( + parse_line("```"), + GeminiLine::PreformattedToggle(true, String::new()) + ); + assert_eq!( + parse_line("``` "), + GeminiLine::PreformattedToggle(true, " ".to_string()) + ); + assert_eq!( + parse_line("````"), + GeminiLine::PreformattedToggle(true, "`".to_string()) + ); + + assert_eq!( + parse_preformatted_line("```alt-text"), + GeminiLine::PreformattedToggle(false, String::new()) + ); + assert_eq!( + parse_preformatted_line("```"), + GeminiLine::PreformattedToggle(false, String::new()) + ); + } + + #[test] + fn test_text() { + // Normal case + assert_eq!( + parse_line("Regular text"), + GeminiLine::Text("Regular text".to_string(), false) + ); + + // Edge cases + assert_eq!(parse_line(""), GeminiLine::Text(String::new(), false)); + assert_eq!(parse_line(" "), GeminiLine::Text(" ".to_string(), false)); + assert_eq!(parse_line(" "), GeminiLine::Text(" ".to_string(), false)); + } + + #[test] + fn test_malformed_input() { + assert_eq!( + parse_line("= >Not a link"), + GeminiLine::Text("= >Not a link".to_string(), false) + ); + assert_eq!( + parse_line("``Not preformatted"), + GeminiLine::Text("``Not preformatted".to_string(), false) + ); + assert_eq!( + parse_line("** Not a list"), + GeminiLine::Text("** Not a list".to_string(), false) + ); + } + + #[test] + fn test_full_document() { + let input = "\ +# Heading 1 +## Heading 2 +### Heading 3 +Regular text +=> https://example.com Link text +* List item +>Quote +```alt +code +# Heading 1 +## Heading 2 +### Heading 3 +=> https://example.com Link text +* List item +>Quote +```trailing alt"; + let result = parse(input); + assert_eq!( + result, + vec![ + GeminiLine::Heading(1, " Heading 1".to_string()), + GeminiLine::Heading(2, " Heading 2".to_string()), + GeminiLine::Heading(3, " Heading 3".to_string()), + GeminiLine::Text("Regular text".to_string(), false), + GeminiLine::Link("https://example.com".to_string(), "Link text".to_string()), + GeminiLine::ListItem("List item".to_string()), + GeminiLine::Quote("Quote".to_string()), + GeminiLine::PreformattedToggle(true, "alt".to_string()), + GeminiLine::Text("code".to_string(), true), + GeminiLine::Text("# Heading 1".to_string(), true), + GeminiLine::Text("## Heading 2".to_string(), true), + GeminiLine::Text("### Heading 3".to_string(), true), + GeminiLine::Text("=> https://example.com Link text".to_string(), true), + GeminiLine::Text("* List item".to_string(), true), + GeminiLine::Text(">Quote".to_string(), true), + GeminiLine::PreformattedToggle(false, String::new()), + ] + ); + } +} diff --git a/src/html_renderer.rs b/src/html_renderer.rs new file mode 100644 index 0000000..e513533 --- /dev/null +++ b/src/html_renderer.rs @@ -0,0 +1,274 @@ +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. +/// +/// # Examples +/// ``` +/// use std::fs::read_to_string; +/// use gema_texto::gemini_parser::parse; +/// use gema_texto::html_renderer::render_html; +/// +/// let gemini_source = "\ +/// # Hello +/// This is some gemini text! +/// => https://test Go to the test page. +/// "; +/// +/// let gemini_lines = parse(&gemini_source); +/// let html_source = render_html(&gemini_lines); +/// println!("{html_source}"); +/// ``` +#[must_use] +pub fn render_html(lines: &[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{level}\">\n")); + } + 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>{content}</p>"), + GeminiLine::Link(url, text) => { + let display = if text.is_empty() { url } else { text }; + format!("<p class=\"a\"><a href=\"{url}\">{display}</a></p>") + } + GeminiLine::Heading(level, content) => format!("<h{level}>{content}</h{level}>"), + GeminiLine::ListItem(content) => format!("<li>{content}</li>"), + GeminiLine::PreformattedToggle(true, alt_text) => { + format!("<pre aria-label=\"{alt_text}\">") + } + GeminiLine::PreformattedToggle(false, _) => "</pre>".to_string(), + GeminiLine::Text(content, true) | GeminiLine::Quote(content) => content.to_string(), + } +} + +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(), String::new()), + ]; + 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), ""); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..53acfcf --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,16 @@ +//! `gema_texto` parses gemtext and renders html. +//! +//! This library powers [blog](https://r.bdr.sh/blog.html) and +//! [page](https://r.bdr.sh/page.html), so it makes some decisions on +//! for those projects. Here's how each line is handled: +//! +//! - Headings and the content under them is wrapped in a `<section>` tag. +//! - All text lines are `<p>`, even empty ones. +//! - URLs are wrapped in a `<p>` +//! - Alt Text is supported for preformatted-toggles, and is written as +//! the aria-label of the `<pre>` tag. +//! - Consecutive list items are wrapped in a `<ul>` and rendered as `<li>` tags. +//! - Quotes are wrapped in a single `<blockquote>` +//! +pub mod gemini_parser; +pub mod html_renderer; |