use std::collections::HashSet;
use url::{ParseError, Url};
static IMAGE_EXTENSIONS: &[&str] = &[".jpg", "jpeg", ".png", ".gif", ".ico", ".svg", "webp"];
pub struct GeminiConverter<'a> {
proxy_url: Option<Url>,
input_text: &'a str,
inline_images: bool,
}
impl<'a> GeminiConverter<'a> {
pub fn new(gmi_text: &'a str) -> Self {
Self {
proxy_url: None,
input_text: gmi_text,
inline_images: false,
}
}
pub fn proxy_url(&mut self, proxy_url: &'a str) -> &mut Self {
self.proxy_url = Some(Url::parse(proxy_url).unwrap());
self
}
pub fn inline_images(&mut self, option: bool) -> &mut Self {
self.inline_images = option;
self
}
pub fn to_html(&self) -> String {
let mut output = String::new();
let mut is_pre = false;
let mut is_list = false;
for line in self.input_text.lines() {
if line.starts_with("```") {
is_pre = !is_pre;
if is_pre {
if line.len() > 3 {
output.push_str("<pre alt=\"");
xml_safe(&mut output, &line[3..]);
output.push_str("\">\n");
} else {
output.push_str("<pre>\n");
}
} else {
output.push_str("</pre>\n")
}
continue;
}
if is_pre {
xml_safe(&mut output, line);
output.push('\n');
continue;
}
if line.starts_with("* ") {
if !is_list {
output.push_str("<ul>\n");
is_list = true;
}
output.push_str("<li>");
xml_safe(&mut output, &line[2..].trim());
output.push_str("</li>\n");
continue;
} else {
if is_list {
output.push_str("</ul>\n");
}
is_list = false;
}
if line.starts_with("#") {
let mut count = 0;
for ch in line.chars() {
if ch == '#' {
count += 1;
if count == 3 {
break;
}
}
}
output.push_str(&format!("<h{}>", count));
xml_safe(&mut output, &line[count..].trim());
output.push_str(&format!("</h{}>\n", count));
} else if line.starts_with(">") {
output.push_str("<q>");
xml_safe(&mut output, &line[1..]);
output.push_str("</q><br>\n");
} else if line.starts_with("=>") {
let mut i = line[2..].split_whitespace();
let first: &str = i.next().unwrap_or("");
let second: String = i.collect::<Vec<&str>>().join(" ");
let parsed = Url::parse(first);
let mut is_image = false;
if parsed == Err(ParseError::RelativeUrlWithoutBase) {
let extension: &str = &first[first.len() - 4..first.len()].to_ascii_lowercase();
if self.inline_images && IMAGE_EXTENSIONS.contains(&extension) {
output.push_str("<img src=\"");
is_image = true;
} else {
output.push_str("<a href=\"");
}
let relative_url = String::new();
xml_safe(&mut output, first);
output.push_str(&relative_url);
} else {
output.push_str("<a href=\"");
}
if let Ok(p) = parsed {
if p.scheme() == "gemini" {
if let Some(s) = &self.proxy_url {
let join =
|a: &Url, b: Url| -> Result<String, Box<dyn std::error::Error>> {
Ok(a.join(b.host_str().ok_or("err")?)?
.join(b.path())?
.as_str()
.to_string())
};
let proxied = join(s, p).unwrap_or("".to_string()); output.push_str(&proxied);
} else {
output.push_str(p.as_str());
}
} else {
output.push_str(p.as_str());
}
}
let link_text = match second.as_str() {
"" => first,
t => t,
};
if !is_image {
output.push_str("\">");
xml_safe(&mut output, link_text);
output.push_str("</a>");
} else {
output.push_str("\" alt=\"");
xml_safe(&mut output, link_text);
output.push_str("\">");
}
output.push_str("<br>\n");
} else {
xml_safe(&mut output, line);
output.push_str("<br>\n");
}
}
if is_list {
output.push_str("</ul>");
}
if is_pre {
output.push_str("</pre>")
}
return output;
}
}
pub fn xml_safe(dest: &mut String, text: &str) {
for c in text.chars() {
match c {
'&' => dest.push_str("&"),
'<' => dest.push_str("<"),
'>' => dest.push_str(">"),
'"' => dest.push_str("""),
'\'' => dest.push_str("'"),
_ => dest.push(c),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic() {
assert_eq!(
GeminiConverter::new("hello world").to_html(),
"hello world<br>\n"
)
}
#[test]
fn test_unsafe_html() {
assert_eq!(
GeminiConverter::new("<b>hacked</b>").to_html(),
"<b>hacked</b><br>\n"
);
}
#[test]
fn test_whitespace() {
assert_eq!(
GeminiConverter::new("\n\n\n").to_html(),
"<br>\n<br>\n<br>\n"
)
}
#[test]
fn test_list() {
assert_eq!(
GeminiConverter::new("hi\n* cool\n* vibes\nok").to_html(),
"hi<br>\n<ul>\n<li>cool</li>\n<li>vibes</li>\n</ul>\nok<br>\n"
)
}
#[test]
fn test_quote() {
assert_eq!(
GeminiConverter::new("> stay cool\n-coolguy").to_html(),
"<q> stay cool</q><br>\n-coolguy<br>\n"
)
}
#[test]
fn test_headers() {
assert_eq!(
GeminiConverter::new("#header").to_html(),
"<h1>header</h1>\n"
);
assert_eq!(
GeminiConverter::new("##header").to_html(),
"<h2>header</h2>\n"
);
assert_eq!(
GeminiConverter::new("### header").to_html(),
"<h3>header</h3>\n"
);
assert_eq!(
GeminiConverter::new("####header").to_html(),
"<h3>#header</h3>\n"
);
}
#[test]
fn test_pre() {
assert_eq!(
GeminiConverter::new("```\nhello world\n```").to_html(),
"<pre>\nhello world\n</pre>\n"
);
}
#[test]
fn test_pre_alt() {
assert_eq!(
GeminiConverter::new("```alt\"\nhello world\n```").to_html(),
"<pre alt=\"alt"\">\nhello world\n</pre>\n"
);
}
#[test]
fn test_hyperlink() {
assert_eq!(
GeminiConverter::new("=> https://google.com").to_html(),
"<a href=\"https://google.com/\">https://google.com</a><br>\n"
)
}
#[test]
fn test_replace_image() {
assert_eq!(
GeminiConverter::new("=> something.jpg cool pic")
.inline_images(true)
.to_html(),
"<img src=\"something.jpg\" alt=\"cool pic\"><br>\n"
)
}
#[test]
fn test_proxy() {
assert_eq!(
GeminiConverter::new("=> gemini://alexwrites.xyz")
.proxy_url("https://flounder.online/proxy/")
.to_html(),
"<a href=\"https://flounder.online/proxy/alexwrites.xyz\">gemini://alexwrites.xyz</a><br>\n"
)
}
}