use crate::{
RenderOptions,
gemtext::{GemtextContentBlock, HeadingLevel},
};
use alloc::string::ToString;
use maud::{Markup, PreEscaped, html};
impl GemtextContentBlock {
pub(crate) fn as_markup(&self, options: MarkupOptions) -> Markup {
match self {
#[rustfmt::skip]
Self::Heading { level: HeadingLevel::One, content } => html! { h1 { (content) } },
#[rustfmt::skip]
Self::Heading { level: HeadingLevel::Two, content } => html! { h2 { (content) } },
#[rustfmt::skip]
Self::Heading { level: HeadingLevel::Three, content } => html! { h3 { (content) } },
Self::Link { target, label }
if [".webp", ".png", ".apng", ".jpg", ".jpeg"]
.iter()
.any(|s| target.to_ascii_lowercase().ends_with(s)) =>
{
html! {
p {
details class="image" {
summary title=(target) { p { (label.as_ref().unwrap_or(target)) } }
img src=(target) alt="" loading="lazy";
}
}
}
}
Self::Link { target, label } => match url::Url::parse(target) {
Err(_) => html! { p { a href=(target) { (label.as_ref().unwrap_or(target)) } } }, Ok(url) => {
html!( p { a rel="external noopener noreferrer nofollow" href=(url) { (label.as_ref().unwrap_or(&url.to_string())) } } ) }
},
Self::List { items } => html! {
ul {
@for item in items {
li { (item) }
}
}
},
#[rustfmt::skip]
Self::Pre { alt_text: Some(alt), content } if alt.contains(" ") => html! {
figure aria-label=(alt) {
pre alt=(alt) { (content) }
}
},
#[rustfmt::skip]
Self::Pre { alt_text: Some(language_id), content } => match options.copy_button_style {
CopyButtonStyle::None => html! {
pre alt=(language_id) { (content) }
},
CopyButtonStyle::Forgejo => html! {
pre class="code-block" alt=(language_id) {
aside style="position:sticky; top:0; left:100%; height:0; width:0; overflow:visible" {
button class="code-copy ui button" style="top: -8px; right: -10px" data-clipboard-text=(content) {
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg octicon-copy" width="16" height="16" aria-hidden="true" {
path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" {}
path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" {}
}
}
}
code style="white-space: pre" { (content) }
}
},
},
#[rustfmt::skip]
Self::Pre { alt_text: None, content } => html! { pre { (content) } },
Self::Quote { content } => html! { blockquote { (content) } },
Self::Text { content } if content.trim().is_empty() => match options.empty_line_tag {
EmptyLineTag::Br => html! { br; },
EmptyLineTag::P => html! { p {} },
},
Self::Ignored { content } => {
static COMMENT_START: PreEscaped<&str> = PreEscaped("<!-- ");
static COMMENT_END: PreEscaped<&str> = PreEscaped(" -->");
html! { (COMMENT_START)(content)(COMMENT_END) }
}
Self::Text { content } => html! { p { (content) } },
}
}
}
#[derive(Clone, Copy, Default)]
pub(crate) struct MarkupOptions {
pub copy_button_style: CopyButtonStyle,
pub empty_line_tag: EmptyLineTag,
}
impl From<RenderOptions> for MarkupOptions {
fn from(value: RenderOptions) -> Self {
Self {
copy_button_style: value.copy_button_style,
empty_line_tag: value.empty_line_tag,
}
}
}
#[derive(Clone, Copy, Default)]
#[cfg_attr(feature = "std", derive(clap::ValueEnum), value(rename_all = "lower"))]
pub enum CopyButtonStyle {
#[default]
None,
Forgejo,
}
#[derive(Clone, Copy, Default)]
#[cfg_attr(feature = "std", derive(clap::ValueEnum), value(rename_all = "lower"))]
pub enum EmptyLineTag {
#[default]
Br,
P,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gemtext::Document;
use pretty_assertions::assert_eq;
use test_case::test_case;
#[test]
fn test_renders_preformatted_single_line() {
let pre = GemtextContentBlock::Pre {
alt_text: None,
content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.".into(),
};
let expected = r#"<pre>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</pre>"#;
assert_eq!(pre.as_markup(Default::default()).into_string(), expected);
}
#[test]
fn test_renders_preformatted_multi_line() {
let pre = GemtextContentBlock::Pre {
alt_text: None,
content: r#"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."#.into(),
};
let expected = r#"<pre>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</pre>"#;
assert_eq!(pre.as_markup(Default::default()).into_string(), expected);
}
#[test_case("#Heading", "<h1>Heading</h1>" ; "L1 without space")]
#[test_case("# Heading", "<h1>Heading</h1>" ; "L1 with space")]
#[test_case("# Heading", "<h1>Heading</h1>" ; "L1 with tab before")]
#[test_case("# Heading ", "<h1>Heading</h1>" ; "L1 with tab before+after")]
#[test_case("# Heading ", "<h1>Heading</h1>" ; "L1 with spaces after")]
#[test_case("##Heading", "<h2>Heading</h2>" ; "L2 without space")]
#[test_case("## Heading", "<h2>Heading</h2>" ; "L2 with space")]
#[test_case("## Heading", "<h2>Heading</h2>" ; "L2 with tab before")]
#[test_case("###Heading", "<h3>Heading</h3>" ; "L3 without space")]
#[test_case("### Heading", "<h3>Heading</h3>" ; "L3 with space")]
#[test_case("### Heading", "<h3>Heading</h3>" ; "L3 with tab before")]
fn test_renders_headings(test: &str, expected: &str) {
let document = Document::parse_from_gemtext(test);
let lines = document.contents;
assert_eq!(lines.len(), 1);
let heading = lines.first().expect("single-line document");
let result = heading.as_markup(Default::default()).into_string();
assert_eq!(result, expected);
}
#[test_case("=> test", r#"<p><a href="test">test</a></p>"#)]
#[test_case("=> test link", r#"<p><a href="test">link</a></p>"#)]
#[test_case("=> /foo", r#"<p><a href="/foo">/foo</a></p>"#)]
#[test_case(
"=> foo://bar",
r#"<p><a rel="external noopener noreferrer nofollow" href="foo://bar">foo://bar</a></p>"#
)]
#[test_case(
"=> foo://bar ext",
r#"<p><a rel="external noopener noreferrer nofollow" href="foo://bar">ext</a></p>"#
)]
#[test_case(
"=> foo://bar foo://baz",
r#"<p><a rel="external noopener noreferrer nofollow" href="foo://bar">foo://baz</a></p>"#
)] fn test_renders_links(test: &str, expected: &str) {
let document = Document::parse_from_gemtext(test);
let lines = document.contents;
assert_eq!(lines.len(), 1);
let heading = lines.first().expect("single-line document");
let result = heading.as_markup(Default::default()).into_string();
assert_eq!(result, expected);
}
#[test_case("```\n```foo", "<!-- foo -->")]
#[test_case("```\n```<!--bar-->", "<!-- <!--bar--> -->")]
#[test_case("```\n```baz-->", "<!-- baz--> -->")]
fn test_renders_comments(case: &str, expected: &str) {
let document = Document::parse_from_gemtext(case);
let lines = document.contents;
assert_eq!(lines.len(), 1);
let ignored = lines.first().expect("single-line document");
let result = ignored.as_markup(Default::default()).into_string();
assert_eq!(result, expected);
}
#[test_case("```\n```" ; "empty")]
#[test_case("```\n``` " ; "only space")]
fn test_does_not_render_empty_comments(case: &str) {
let document = Document::parse_from_gemtext(case);
assert!(document.contents.is_empty(), "{:?}", document.contents);
}
}