aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-01-04 23:04:42 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-01-04 23:04:42 +0100
commit8beb2bfa1af988442550c9e6999d924dd14b5236 (patch)
treee24c5ca4b5d6da02895094f2341231236091132c /src
Add the library
Diffstat (limited to 'src')
-rw-r--r--src/gemini_parser.rs331
-rw-r--r--src/html_renderer.rs274
-rw-r--r--src/lib.rs16
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;