gemrendr 0.3.1

Turns Gemtext into idiomatic HTML
Documentation
//! gemrendr   Turns Gemtext into idiomatic HTML.
//! Copyright (C) 2025  AverageHelper
//!
//! This program is free software: you can redistribute it and/or modify
//! it under the terms of the GNU General Public License as published by
//! the Free Software Foundation, either version 3 of the License, or
//! (at your option) any later version.
//!
//! This program is distributed in the hope that it will be useful,
//! but WITHOUT ANY WARRANTY; without even the implied warranty of
//! MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//! GNU General Public License for more details.
//!
//! You should have received a copy of the GNU General Public License
//! along with this program.  If not, see <https://www.gnu.org/licenses/>.

use crate::{
	RenderOptions,
	gemtext::{GemtextContentBlock, HeadingLevel},
};
use alloc::string::ToString;
use maud::{Markup, html};

impl GemtextContentBlock {
	/// Creates HTML markup for the Gemtext line.
	pub(crate) fn as_markup(&self, options: MarkupOptions) -> Markup {
		match self {
			// Spec: "The text of a heading text should be presented to the user, and clients MAY use special formatting, e.g. a larger and/or heavier font or a different colour to indicate its status as a header."
			#[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)) =>
			{
				// Spec: "Clients can present links to users in whatever fashion the client author wishes, however clients MUST NOT automatically make any network connections as part of displaying links."
				// So inline images need loading="lazy" set to prevent this.
				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)) } } }, // host-local
				Ok(url) => {
					html!( p { a rel="external noopener noreferrer nofollow" href=(url) { (label.as_ref().unwrap_or(&url.to_string())) } } ) // external
				}
			},

			// Spec: "[The List] line type exists purely for stylistic reasons. The * may be replaced in advanced clients by a bullet symbol. Any text after the "* " should be presented to the user as if it were a text line, i.e. wrapped to fit the viewport and formatted "nicely". Advanced clients can take the space of the bullet symbol into account when wrapping long list items to ensure that all lines of text corresponding to the item are offset an equal distance from the edge of the screen."
			// Most browsers do this with <ul> automatically!
			Self::List { items } => html! {
				ul {
					@for item in items {
						li { (item) }
					}
				}
			},

			// Spec: "[Preformatting toggle] lines should NOT be included in the rendered output shown to the user. Instead, they switch the parser out of "normal mode" and into "pre-formatted" mode, [...] [or,] they switch the parser out of "pre-formatted mode" and into "normal mode"."
			#[rustfmt::skip]
			Self::Pre { alt_text: Some(alt), content } if alt.contains(" ") => html! {
				// Spec: "Alt text is recommended for ASCII art or similar non-textual content which, for example, cannot be meaningfully understood when rendered through a screen reader or usefully indexed by a search engine."
				// Spec: "In displaying preformatted text lines, clients should keep in mind applications like ASCII art and computer source code."
				// So it is common to use preformatted blocks as figures drawn in ASCII art.
				figure aria-label=(alt) {
					// Spec: "In pre-formatted mode, text lines should be presented to the user in a "neutral", monowidth font without any alteration to whitespace or stylistic enhancements."
					// TODO: Spec: "Graphical clients should use scrolling mechanisms to present preformatted text lines which are longer than the client viewport, in preference to wrapping them." (Should we have some default or inline CSS for this? Or let the downstream client deal with that?)
					pre alt=(alt) { (content) }
				}
			},
			#[rustfmt::skip]
			Self::Pre { alt_text: Some(language_id), content } => match options.copy_button_style {
				// Spec: "Alt text may also be used for computer source code to identify the programming language which advanced clients may use for syntax highlighting."
				// Spec: "source code in languages with significant whitespace (e.g. Python) should be able to be copied and pasted from the client into a file and interpreted/compiled without any problems arising from the client's manner of displaying them."

				CopyButtonStyle::None => html! {
					pre alt=(language_id) { (content) }
				},

				CopyButtonStyle::Forgejo => html! {
					// TODO: syntax highlighting; this check should go first: if the (trimmed) alt_text is a known source code language name, we should treat it as a code block; otherwise, a figure. Also, the alt text should be the actual language name, not the ID.
					pre class="code-block" alt=(language_id) {
						// Forgejo deals with hiding this button when appropriate.
						// "Toolbar"-style sticky, with inspiration from https://www.horuskol.net/blog/2022-04-13/relative-and-absolute-scrolling-blues/
						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) } },

			// Spec: "[The Quote] line type exists so that advanced clients may use distinct styling to convey to readers the important semantic information that certain text is being quoted from an external source."
			Self::Quote { content } => html! { blockquote { (content) } },

			// Spec: "Empty lines, i.e. lines consisting exclusively of CRLF, are valid instances of text lines and have no special meaning. They should be rendered individually as vertical blank space each time they occur. Multiple consecutive empty lines should NOT be collapsed into fewer empty lines and should be rendered as a quantity of vertical blank space proportional to the number of lines."
			Self::Text { content } if content.trim().is_empty() => match options.empty_line_tag {
				EmptyLineTag::Br => html! { br; },
				EmptyLineTag::P => html! { p {} },
			},

			// Spec: "Text lines have no special semantics and should be presented to the user in a visually pleasing manner for general reading."
			Self::Text { content } => html! { p { (content) } },
		}
	}
}

/// Options for how markup should be generated.
#[derive(Clone, Copy, Default)]
pub(crate) struct MarkupOptions {
	/// Whether and how a Copy Text button should be generated in preformatted blocks.
	pub copy_button_style: CopyButtonStyle,

	/// The tag that should be used to represent empty Text lines.
	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,
		}
	}
}

/// Whether and how a Copy Text button should be generated in preformatted blocks.
#[derive(Clone, Copy, Default)]
#[cfg_attr(feature = "std", derive(clap::ValueEnum), value(rename_all = "lower"))]
pub enum CopyButtonStyle {
	/// No Copy Text button should be generated.
	#[default]
	None,

	/// A Copy Text button with [Forgejo](https://forgejo.org/)-style
	/// formatting and attributes should be generated.
	///
	/// This button should work well on at least Forgejo version 13.
	/// The button disappears when the browser has Javascript disabled.
	Forgejo,
}

/// The tag that should be used to represent empty Text lines.
#[derive(Clone, Copy, Default)]
#[cfg_attr(feature = "std", derive(clap::ValueEnum), value(rename_all = "lower"))]
pub enum EmptyLineTag {
	/// Empty Text lines consist of a single line break tag (e.g. `<br>`)
	#[default]
	Br,

	/// Empty Text lines consist of an empty paragraph tag (e.g. `<p></p>`)
	P,
}

// MARK: - Tests

#[cfg(test)]
mod tests {
	use super::*;
	use crate::gemtext::Document;
	use pretty_assertions::assert_eq;

	#[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]
	fn test_renders_headings() {
		let cases = [
			("#Heading", "<h1>Heading</h1>"),
			("# Heading", "<h1>Heading</h1>"),
			("#     	Heading", "<h1>Heading</h1>"),
			("#     	Heading     ", "<h1>Heading</h1>"),
			("# Heading     ", "<h1>Heading</h1>"),
			("##Heading", "<h2>Heading</h2>"),
			("## Heading", "<h2>Heading</h2>"),
			("##    	 Heading", "<h2>Heading</h2>"),
			("###Heading", "<h3>Heading</h3>"),
			("### Heading", "<h3>Heading</h3>"),
			("###   	   Heading", "<h3>Heading</h3>"),
			("###   	   Heading", "<h3>Heading</h3>"),
		];
		for (test, expected) in cases {
			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]
	fn test_renders_links() {
		#[rustfmt::skip]
		let cases = [
			("=> test", r#"<p><a href="test">test</a></p>"#),
			("=> test link", r#"<p><a href="test">link</a></p>"#),
			("=> /foo", r#"<p><a href="/foo">/foo</a></p>"#),
			("=> foo://bar", r#"<p><a rel="external noopener noreferrer nofollow" href="foo://bar">foo://bar</a></p>"#),
			("=> foo://bar ext", r#"<p><a rel="external noopener noreferrer nofollow" href="foo://bar">ext</a></p>"#),
			("=> foo://bar foo://baz", r#"<p><a rel="external noopener noreferrer nofollow" href="foo://bar">foo://baz</a></p>"#), // TODO: Should we somehow handle misleading cases like these? Maybe put the real URL after the label in smaller text?
		];
		for (test, expected) in cases {
			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);
		}
	}
}