use std::collections::HashMap;
use crate::attrs::ParsedAttrs;
use pulldown_cmark_escape::IoWriter;
use pulldown_cmark_escape::{FmtWriter, StrWrite, escape_href, escape_html, escape_html_body_text};
use pulldown_cmark::{
Alignment, BlockQuoteKind, CodeBlockKind, CowStr,
Event::{self, *},
LinkType, Tag, TagEnd,
};
#[derive(Debug, Default, Clone)]
pub struct HtmlConfig {
pub enable_sections: bool,
pub enable_mermaid: bool,
pub section_attrs: HashMap<usize, ParsedAttrs>,
}
impl HtmlConfig {
pub fn mbr_defaults() -> Self {
Self {
enable_sections: true,
enable_mermaid: true,
section_attrs: HashMap::new(),
}
}
pub fn mbr_with_section_attrs(section_attrs: HashMap<usize, ParsedAttrs>) -> Self {
Self {
enable_sections: true,
enable_mermaid: true,
section_attrs,
}
}
}
enum TableState {
Head,
Body,
}
struct HtmlWriter<'a, I, W> {
iter: I,
writer: W,
end_newline: bool,
in_non_writing_block: bool,
codeblock_state: Option<CowStr<'a>>,
table_state: TableState,
table_alignments: Vec<Alignment>,
table_cell_index: usize,
numbers: HashMap<CowStr<'a>, usize>,
config: HtmlConfig,
section_started: bool,
current_section: usize,
container_depth: usize,
}
fn is_block_container(tag: &Tag<'_>) -> bool {
matches!(
tag,
Tag::BlockQuote(_)
| Tag::List(_)
| Tag::Item
| Tag::TableCell
| Tag::FootnoteDefinition(_)
| Tag::DefinitionList
| Tag::DefinitionListDefinition
)
}
fn is_block_container_end(tag: &TagEnd) -> bool {
matches!(
tag,
TagEnd::BlockQuote(_)
| TagEnd::List(_)
| TagEnd::Item
| TagEnd::TableCell
| TagEnd::FootnoteDefinition
| TagEnd::DefinitionList
| TagEnd::DefinitionListDefinition
)
}
const BLOCKED_DESTINATION: &str = "about:blank#blocked";
const MAX_BLOCKED_SCHEME_LEN: usize = 10;
fn is_blocked_destination(dest: &str) -> bool {
let mut scheme = [0u8; MAX_BLOCKED_SCHEME_LEN];
let mut len = 0usize;
let mut found_colon = false;
for c in dest.trim_start_matches(|c: char| c <= ' ').chars() {
match c {
'\t' | '\n' | '\r' => continue,
':' => {
found_colon = true;
break;
}
_ if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') => {
if len == MAX_BLOCKED_SCHEME_LEN {
return false;
}
scheme[len] = c.to_ascii_lowercase() as u8;
len += 1;
}
_ => return false,
}
}
if !found_colon {
return false;
}
match &scheme[..len] {
b"javascript" | b"vbscript" => true,
b"data" => !is_inline_image_data_url(dest),
_ => false,
}
}
fn is_inline_image_data_url(dest: &str) -> bool {
let Some((_, payload)) = dest.split_once(':') else {
return false;
};
let mut actual = payload
.chars()
.filter(|c| !matches!(c, '\t' | '\n' | '\r'))
.map(|c| c.to_ascii_lowercase());
"image/"
.chars()
.all(|expected| actual.next() == Some(expected))
}
impl<'a, I, W> HtmlWriter<'a, I, W>
where
I: Iterator<Item = Event<'a>>,
W: StrWrite,
{
fn new(iter: I, writer: W) -> Self {
Self::new_with_config(iter, writer, HtmlConfig::default())
}
fn new_with_config(iter: I, writer: W, config: HtmlConfig) -> Self {
Self {
iter,
writer,
end_newline: true,
in_non_writing_block: false,
codeblock_state: None,
table_state: TableState::Head,
table_alignments: vec![],
table_cell_index: 0,
numbers: HashMap::new(),
config,
section_started: false,
current_section: 0,
container_depth: 0,
}
}
#[inline]
fn write_newline(&mut self) -> Result<(), W::Error> {
self.end_newline = true;
self.writer.write_str("\n")
}
#[inline]
fn write(&mut self, s: &str) -> Result<(), W::Error> {
self.writer.write_str(s)?;
if !s.is_empty() {
self.end_newline = s.ends_with('\n');
}
Ok(())
}
#[inline]
fn write_destination(&mut self, dest: &str) -> Result<(), W::Error> {
if is_blocked_destination(dest) {
self.write(BLOCKED_DESTINATION)
} else {
escape_href(&mut self.writer, dest)
}
}
fn run(mut self) -> Result<(), W::Error> {
if self.config.enable_sections {
let attrs_str = self
.config
.section_attrs
.get(&self.current_section)
.map(|a| a.to_html_attr_string())
.unwrap_or_default();
self.write(&format!("<section{}>\n", attrs_str))?;
self.section_started = true;
}
while let Some(event) = self.iter.next() {
match event {
Start(tag) => {
self.start_tag(tag)?;
}
End(tag) => {
self.end_tag(tag)?;
}
Text(text) => {
if !self.in_non_writing_block {
escape_html_body_text(&mut self.writer, &text)?;
self.end_newline = text.ends_with('\n');
}
}
Code(text) => {
self.write("<code>")?;
escape_html_body_text(&mut self.writer, &text)?;
self.write("</code>")?;
}
InlineMath(text) => {
self.write(r#"<span class="math math-inline">"#)?;
escape_html(&mut self.writer, &text)?;
self.write("</span>")?;
}
DisplayMath(text) => {
self.write(r#"<span class="math math-display">"#)?;
escape_html(&mut self.writer, &text)?;
self.write("</span>")?;
}
Html(html) | InlineHtml(html) => {
self.write(&html)?;
}
SoftBreak => {
self.write_newline()?;
}
HardBreak => {
self.write("<br />\n")?;
}
Rule => {
if self.config.enable_sections {
self.current_section += 1;
}
if self.config.enable_sections && self.container_depth == 0 {
let attrs_str = self
.config
.section_attrs
.get(&self.current_section)
.map(|a| a.to_html_attr_string())
.unwrap_or_default();
self.write(&format!("</section>\n<hr />\n<section{}>\n", attrs_str))?;
} else {
if self.end_newline {
self.write("<hr />\n")?;
} else {
self.write("\n<hr />\n")?;
}
}
}
FootnoteReference(name) => {
let len = self.numbers.len() + 1;
self.write("<sup class=\"footnote-reference\"><a href=\"#")?;
escape_html(&mut self.writer, &name)?;
self.write("\">")?;
let number = *self.numbers.entry(name).or_insert(len);
write!(&mut self.writer, "{}", number)?;
self.write("</a></sup>")?;
}
TaskListMarker(true) => {
self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>\n")?;
}
TaskListMarker(false) => {
self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?;
}
}
}
if self.config.enable_sections && self.section_started {
self.write("</section>\n")?;
}
Ok(())
}
fn start_tag(&mut self, tag: Tag<'a>) -> Result<(), W::Error> {
if is_block_container(&tag) {
self.container_depth += 1;
}
match tag {
Tag::HtmlBlock => Ok(()),
Tag::Paragraph => {
if self.end_newline {
self.write("<p>")
} else {
self.write("\n<p>")
}
}
Tag::Heading {
level,
id,
classes,
attrs,
} => {
if self.end_newline {
self.write("<")?;
} else {
self.write("\n<")?;
}
write!(&mut self.writer, "{}", level)?;
if let Some(id) = id {
self.write(" id=\"")?;
escape_html(&mut self.writer, &id)?;
self.write("\"")?;
}
let mut classes = classes.iter();
if let Some(class) = classes.next() {
self.write(" class=\"")?;
escape_html(&mut self.writer, class)?;
for class in classes {
self.write(" ")?;
escape_html(&mut self.writer, class)?;
}
self.write("\"")?;
}
for (attr, value) in attrs {
self.write(" ")?;
escape_html(&mut self.writer, &attr)?;
if let Some(val) = value {
self.write("=\"")?;
escape_html(&mut self.writer, &val)?;
self.write("\"")?;
} else {
self.write("=\"\"")?;
}
}
self.write(">")
}
Tag::Table(alignments) => {
self.table_alignments = alignments;
self.write("<table>")
}
Tag::TableHead => {
self.table_state = TableState::Head;
self.table_cell_index = 0;
self.write("<thead><tr>")
}
Tag::TableRow => {
self.table_cell_index = 0;
self.write("<tr>")
}
Tag::TableCell => {
match self.table_state {
TableState::Head => {
self.write("<th")?;
}
TableState::Body => {
self.write("<td")?;
}
}
match self.table_alignments.get(self.table_cell_index) {
Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"),
Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"),
Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"),
_ => self.write(">"),
}
}
Tag::BlockQuote(kind) => {
let class_str = match kind {
None => "",
Some(kind) => match kind {
BlockQuoteKind::Note => " class=\"markdown-alert-note\"",
BlockQuoteKind::Tip => " class=\"markdown-alert-tip\"",
BlockQuoteKind::Important => " class=\"markdown-alert-important\"",
BlockQuoteKind::Warning => " class=\"markdown-alert-warning\"",
BlockQuoteKind::Caution => " class=\"markdown-alert-caution\"",
},
};
if self.end_newline {
self.write(&format!("<blockquote{}>\n", class_str))
} else {
self.write(&format!("\n<blockquote{}>\n", class_str))
}
}
Tag::CodeBlock(info) => {
if !self.end_newline {
self.write_newline()?;
}
self.codeblock_state = Some("</code></pre>".into());
match info {
CodeBlockKind::Fenced(info) => {
let lang = info.split(' ').next().unwrap_or_default();
if lang.is_empty() {
self.write("<pre><code>")
} else if self.config.enable_mermaid && lang == "mermaid" {
self.codeblock_state = Some("</pre>".into());
self.write("<pre class=\"mermaid\">")
} else {
self.write("<pre><code class=\"language-")?;
escape_html(&mut self.writer, lang)?;
self.write("\">")
}
}
CodeBlockKind::Indented => self.write("<pre><code>"),
}
}
Tag::List(Some(1)) => {
if self.end_newline {
self.write("<ol>\n")
} else {
self.write("\n<ol>\n")
}
}
Tag::List(Some(start)) => {
if self.end_newline {
self.write("<ol start=\"")?;
} else {
self.write("\n<ol start=\"")?;
}
write!(&mut self.writer, "{}", start)?;
self.write("\">\n")
}
Tag::List(None) => {
if self.end_newline {
self.write("<ul>\n")
} else {
self.write("\n<ul>\n")
}
}
Tag::Item => {
if self.end_newline {
self.write("<li>")
} else {
self.write("\n<li>")
}
}
Tag::DefinitionList => {
if self.end_newline {
self.write("<dl>\n")
} else {
self.write("\n<dl>\n")
}
}
Tag::DefinitionListTitle => {
if self.end_newline {
self.write("<dt>")
} else {
self.write("\n<dt>")
}
}
Tag::DefinitionListDefinition => {
if self.end_newline {
self.write("<dd>")
} else {
self.write("\n<dd>")
}
}
Tag::Subscript => self.write("<sub>"),
Tag::Superscript => self.write("<sup>"),
Tag::Emphasis => self.write("<em>"),
Tag::Strong => self.write("<strong>"),
Tag::Strikethrough => self.write("<del>"),
Tag::Link {
link_type: LinkType::Email,
dest_url,
title,
id: _,
} => {
self.write("<a href=\"mailto:")?;
escape_href(&mut self.writer, &dest_url)?;
if !title.is_empty() {
self.write("\" title=\"")?;
escape_html(&mut self.writer, &title)?;
}
self.write("\">")
}
Tag::Link {
link_type: _,
dest_url,
title,
id: _,
} => {
self.write("<a href=\"")?;
self.write_destination(&dest_url)?;
if !title.is_empty() {
self.write("\" title=\"")?;
escape_html(&mut self.writer, &title)?;
}
self.write("\">")
}
Tag::Image {
link_type: _,
dest_url,
title,
id: _,
} => {
self.write("<img src=\"")?;
self.write_destination(&dest_url)?;
self.write("\" alt=\"")?;
self.raw_text()?;
if !title.is_empty() {
self.write("\" title=\"")?;
escape_html(&mut self.writer, &title)?;
}
self.write("\" loading=\"lazy\" decoding=\"async\" />")
}
Tag::FootnoteDefinition(name) => {
if self.end_newline {
self.write("<div class=\"footnote-definition\" id=\"")?;
} else {
self.write("\n<div class=\"footnote-definition\" id=\"")?;
}
escape_html(&mut self.writer, &name)?;
self.write("\"><sup class=\"footnote-definition-label\">")?;
let len = self.numbers.len() + 1;
let number = *self.numbers.entry(name).or_insert(len);
write!(&mut self.writer, "{}", number)?;
self.write("</sup>")
}
Tag::MetadataBlock(_) => {
self.in_non_writing_block = true;
Ok(())
}
}
}
fn end_tag(&mut self, tag: TagEnd) -> Result<(), W::Error> {
if is_block_container_end(&tag) {
self.container_depth = self.container_depth.saturating_sub(1);
}
match tag {
TagEnd::HtmlBlock => {}
TagEnd::Paragraph => {
self.write("</p>\n")?;
}
TagEnd::Heading(level) => {
self.write("</")?;
write!(&mut self.writer, "{}", level)?;
self.write(">\n")?;
}
TagEnd::Table => {
self.write("</tbody></table>\n")?;
}
TagEnd::TableHead => {
self.write("</tr></thead><tbody>\n")?;
self.table_state = TableState::Body;
}
TagEnd::TableRow => {
self.write("</tr>\n")?;
}
TagEnd::TableCell => {
match self.table_state {
TableState::Head => {
self.write("</th>")?;
}
TableState::Body => {
self.write("</td>")?;
}
}
self.table_cell_index += 1;
}
TagEnd::BlockQuote(_) => {
self.write("</blockquote>\n")?;
}
TagEnd::CodeBlock => {
match self.codeblock_state.take() {
Some(closing) => self.write(closing.as_ref())?,
None => self.write("</code></pre>")?,
}
self.write("\n")?;
}
TagEnd::List(true) => {
self.write("</ol>\n")?;
}
TagEnd::List(false) => {
self.write("</ul>\n")?;
}
TagEnd::Item => {
self.write("</li>\n")?;
}
TagEnd::DefinitionList => {
self.write("</dl>\n")?;
}
TagEnd::DefinitionListTitle => {
self.write("</dt>\n")?;
}
TagEnd::DefinitionListDefinition => {
self.write("</dd>\n")?;
}
TagEnd::Emphasis => {
self.write("</em>")?;
}
TagEnd::Superscript => {
self.write("</sup>")?;
}
TagEnd::Subscript => {
self.write("</sub>")?;
}
TagEnd::Strong => {
self.write("</strong>")?;
}
TagEnd::Strikethrough => {
self.write("</del>")?;
}
TagEnd::Link => {
self.write("</a>")?;
}
TagEnd::Image => (), TagEnd::FootnoteDefinition => {
self.write("</div>\n")?;
}
TagEnd::MetadataBlock(_) => {
self.in_non_writing_block = false;
}
}
Ok(())
}
fn raw_text(&mut self) -> Result<(), W::Error> {
let mut nest = 0;
while let Some(event) = self.iter.next() {
match event {
Start(_) => nest += 1,
End(_) => {
if nest == 0 {
break;
}
nest -= 1;
}
Html(_) => {}
InlineHtml(text) | Code(text) | Text(text) => {
escape_html(&mut self.writer, &text)?;
self.end_newline = text.ends_with('\n');
}
InlineMath(text) => {
self.write("$")?;
escape_html(&mut self.writer, &text)?;
self.write("$")?;
}
DisplayMath(text) => {
self.write("$$")?;
escape_html(&mut self.writer, &text)?;
self.write("$$")?;
}
SoftBreak | HardBreak | Rule => {
self.write(" ")?;
}
FootnoteReference(name) => {
let len = self.numbers.len() + 1;
let number = *self.numbers.entry(name).or_insert(len);
write!(&mut self.writer, "[{}]", number)?;
}
TaskListMarker(true) => self.write("[x]")?,
TaskListMarker(false) => self.write("[ ]")?,
}
}
Ok(())
}
}
pub fn push_html<'a, I>(s: &mut String, iter: I)
where
I: Iterator<Item = Event<'a>>,
{
write_html_fmt(s, iter).expect("writing to a String cannot fail")
}
pub fn write_html_io<'a, I, W>(writer: W, iter: I) -> std::io::Result<()>
where
I: Iterator<Item = Event<'a>>,
W: std::io::Write,
{
HtmlWriter::new(iter, IoWriter(writer)).run()
}
pub fn write_html_fmt<'a, I, W>(writer: W, iter: I) -> core::fmt::Result
where
I: Iterator<Item = Event<'a>>,
W: core::fmt::Write,
{
HtmlWriter::new(iter, FmtWriter(writer)).run()
}
pub fn push_html_mbr<'a, I>(s: &mut String, iter: I)
where
I: Iterator<Item = Event<'a>>,
{
write_html_fmt_with_config(s, iter, HtmlConfig::mbr_defaults())
.expect("writing to a String cannot fail")
}
pub fn push_html_mbr_with_attrs<'a, I>(
s: &mut String,
iter: I,
section_attrs: HashMap<usize, ParsedAttrs>,
) where
I: Iterator<Item = Event<'a>>,
{
write_html_fmt_with_config(s, iter, HtmlConfig::mbr_with_section_attrs(section_attrs))
.expect("writing to a String cannot fail")
}
pub fn push_html_with_config<'a, I>(s: &mut String, iter: I, config: HtmlConfig)
where
I: Iterator<Item = Event<'a>>,
{
write_html_fmt_with_config(s, iter, config).expect("writing to a String cannot fail")
}
fn write_html_fmt_with_config<'a, I, W>(writer: W, iter: I, config: HtmlConfig) -> core::fmt::Result
where
I: Iterator<Item = Event<'a>>,
W: core::fmt::Write,
{
HtmlWriter::new_with_config(iter, FmtWriter(writer), config).run()
}
#[cfg(test)]
mod tests {
use super::*;
use pulldown_cmark::{Options, Parser};
fn render_with_config(markdown: &str, config: HtmlConfig) -> String {
let parser = Parser::new(markdown);
let mut html = String::new();
push_html_with_config(&mut html, parser, config);
html
}
fn render_mbr(markdown: &str) -> String {
let parser = Parser::new_ext(markdown, Options::all());
let mut html = String::new();
push_html_mbr(&mut html, parser);
html
}
fn between<'a>(html: &'a str, open: &str, close: &str) -> &'a str {
let start = html
.find(open)
.unwrap_or_else(|| panic!("expected {open} in output:\n{html}"))
+ open.len();
let end = html[start..]
.find(close)
.unwrap_or_else(|| panic!("expected {close} after {open} in output:\n{html}"))
+ start;
&html[start..end]
}
#[test]
fn test_sections_disabled() {
let config = HtmlConfig {
enable_sections: false,
enable_mermaid: false,
section_attrs: HashMap::new(),
};
let html = render_with_config("Hello\n\n---\n\nWorld", config);
assert!(
!html.contains("<section"),
"Sections disabled should not produce section tags. Got: {}",
html
);
assert!(
html.contains("<hr />"),
"Should still have hr divider. Got: {}",
html
);
}
#[test]
fn test_sections_enabled_default() {
let config = HtmlConfig::mbr_defaults();
let html = render_with_config("Hello\n\n---\n\nWorld", config);
assert!(
html.contains("<section>"),
"Sections enabled should produce section tags. Got: {}",
html
);
}
#[test]
fn test_sections_with_attrs() {
let mut section_attrs = HashMap::new();
section_attrs.insert(
1,
ParsedAttrs {
id: Some("second".to_string()),
classes: vec!["highlight".to_string()],
attrs: vec![],
},
);
let config = HtmlConfig::mbr_with_section_attrs(section_attrs);
let html = render_with_config("First\n\n---\n\nSecond", config);
assert!(
html.contains(r#"id="second""#),
"Section should have id. Got: {}",
html
);
assert!(
html.contains(r#"class="highlight""#),
"Section should have class. Got: {}",
html
);
}
#[test]
fn test_mermaid_disabled() {
let config = HtmlConfig {
enable_sections: false,
enable_mermaid: false,
section_attrs: HashMap::new(),
};
let html = render_with_config("```mermaid\ngraph TD\n```", config);
assert!(
html.contains("<pre><code"),
"Mermaid disabled should use standard code. Got: {}",
html
);
}
#[test]
fn test_mermaid_enabled() {
let config = HtmlConfig {
enable_sections: false,
enable_mermaid: true,
section_attrs: HashMap::new(),
};
let html = render_with_config("```mermaid\ngraph TD\n```", config);
assert!(
html.contains(r#"<pre class="mermaid">"#),
"Mermaid enabled should use mermaid class. Got: {}",
html
);
assert!(
!html.contains("<code"),
"Mermaid should not have code wrapper. Got: {}",
html
);
}
#[test]
fn test_rule_inside_blockquote_does_not_close_section() {
let html = render_mbr("> before\n>\n> ---\n>\n> after");
let inside = between(&html, "<blockquote>", "</blockquote>");
assert!(
!inside.contains("</section>"),
"A rule inside a blockquote must not close the surrounding section \
(the browser would hoist everything after it out of the quote). Got: {html}"
);
assert!(
!inside.contains("<section"),
"A rule inside a blockquote must not open a section. Got: {html}"
);
assert!(
inside.contains("<hr />"),
"The rule should still render as a plain <hr />. Got: {html}"
);
assert!(
inside.contains("after"),
"Content after the rule must stay inside the blockquote. Got: {html}"
);
}
#[test]
fn test_rule_inside_list_item_does_not_close_section() {
let html = render_mbr("- a\n\n ---\n\n- b");
let inside = between(&html, "<li>", "</li>");
assert!(
!inside.contains("</section>"),
"A rule inside a list item must not close the surrounding section. Got: {html}"
);
assert!(
!inside.contains("<section"),
"A rule inside a list item must not open a section. Got: {html}"
);
assert!(
inside.contains("<hr />"),
"The rule should still render as a plain <hr />. Got: {html}"
);
let list = between(&html, "<ul>", "</ul>");
assert!(
!list.contains("section"),
"The list must not be split by a section boundary. Got: {html}"
);
}
#[test]
fn test_rule_inside_definition_list_does_not_close_section() {
let html = render_mbr("Term\n\n: def\n\n ---\n\n more\n");
let inside = between(&html, "<dd>", "</dd>");
assert!(
!inside.contains("</section>"),
"A rule inside a definition must not close the surrounding section. Got: {html}"
);
assert!(
inside.contains("more"),
"Content after the rule must stay inside the definition. Got: {html}"
);
}
#[test]
fn test_top_level_rule_still_splits_sections() {
let html = render_mbr("Hello\n\n---\n\nWorld");
assert!(
html.contains("</section>\n<hr />\n<section>"),
"A top-level rule must still split sections. Got: {html}"
);
}
#[test]
fn test_nested_rule_does_not_shift_section_attr_indices() {
let mut section_attrs = HashMap::new();
section_attrs.insert(
2,
ParsedAttrs {
id: Some("third".to_string()),
classes: vec![],
attrs: vec![],
},
);
let config = HtmlConfig::mbr_with_section_attrs(section_attrs);
let parser = Parser::new_ext("> ---\n\n---\n\ntail", Options::all());
let mut html = String::new();
push_html_with_config(&mut html, parser, config);
assert!(
html.contains(r#"<section id="third">"#),
"Section index must count the nested rule. Got: {html}"
);
}
#[test]
fn test_javascript_link_destination_is_neutralized() {
let html = render_mbr("[click me](javascript:alert(1))");
assert!(
!html.contains("javascript:"),
"javascript: href must not survive. Got: {html}"
);
assert!(
html.contains(r#"<a href="about:blank#blocked">"#),
"Blocked link should get an inert href. Got: {html}"
);
assert!(
html.contains("click me"),
"Link text must be preserved. Got: {html}"
);
}
#[test]
fn test_javascript_scheme_obfuscation_is_neutralized() {
for dest in [
"JaVaScRiPt:alert(1)",
" javascript:alert(1)",
"java\tscript:alert(1)",
"\u{1}javascript:alert(1)",
] {
let html = render_mbr(&format!("[x](<{dest}>)"));
assert!(
html.contains(r#"href="about:blank#blocked""#),
"{dest:?} should have been neutralized. Got: {html}"
);
}
}
#[test]
fn test_vbscript_link_destination_is_neutralized() {
let html = render_mbr("[x](vbscript:msgbox(1))");
assert!(
html.contains(r#"href="about:blank#blocked""#),
"vbscript: href must be neutralized. Got: {html}"
);
}
#[test]
fn test_non_image_data_url_is_neutralized() {
let html = render_mbr("[x](data:text/html;base64,PHNjcmlwdD4=)");
assert!(
html.contains(r#"href="about:blank#blocked""#),
"data:text/html href must be neutralized. Got: {html}"
);
}
#[test]
fn test_inline_image_data_url_is_preserved() {
let html = render_mbr("");
assert!(
html.contains(r#"src="data:image/png;base64,iVBORw0KGgo=""#),
"data:image/* src must be preserved. Got: {html}"
);
let html = render_mbr("[x](data:image/png;base64,iVBORw0KGgo=)");
assert!(
html.contains(r#"href="data:image/png;base64,iVBORw0KGgo=""#),
"data:image/* href must be preserved. Got: {html}"
);
}
#[test]
fn test_javascript_image_destination_is_neutralized() {
let html = render_mbr(")");
assert!(
!html.contains("javascript:"),
"javascript: src must not survive. Got: {html}"
);
assert!(
html.contains(r#"<img src="about:blank#blocked""#),
"Blocked image should get an inert src. Got: {html}"
);
}
#[test]
fn test_ordinary_destinations_are_untouched() {
let cases = [
(
"[a](https://example.com/p?q=1)",
"https://example.com/p?q=1",
),
("[a](http://example.com/)", "http://example.com/"),
("[a](../notes/other/)", "../notes/other/"),
("[a](/images/pic.png)", "/images/pic.png"),
("[a](#anchor)", "#anchor"),
("[a](mailto:me@example.com)", "mailto:me@example.com"),
("[a](tel:+15551234)", "tel:+15551234"),
("[a](javascript)", "javascript"),
];
for (markdown, expected_href) in cases {
let html = render_mbr(markdown);
assert!(
html.contains(&format!(r#"href="{expected_href}""#)),
"{markdown} should render href={expected_href:?}. Got: {html}"
);
}
}
#[test]
fn test_autolink_email_is_untouched() {
let html = render_mbr("<me@example.com>");
assert!(
html.contains(r#"href="mailto:me@example.com""#),
"Email autolinks must keep working. Got: {html}"
);
}
#[test]
fn test_images_get_lazy_loading_and_async_decoding() {
let html = render_mbr("");
assert!(
html.contains(r#"loading="lazy""#),
"Images should be lazily loaded. Got: {html}"
);
assert!(
html.contains(r#"decoding="async""#),
"Images should decode asynchronously. Got: {html}"
);
}
#[test]
fn test_image_with_title_keeps_loading_hints() {
let html = render_mbr(r#""#);
assert!(
html.contains(r#"title="A title" loading="lazy" decoding="async" />"#),
"Title and loading hints should both be emitted. Got: {html}"
);
}
#[test]
fn test_is_blocked_destination() {
for dest in [
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
" \u{c}javascript:alert(1)",
"v\rbscript:x", "vbscript:x",
"data:text/html,<script>",
"data:,plain",
"data:image", "data:imag", "javascript:", "jAvAsCrIpT\n:x", ] {
assert!(is_blocked_destination(dest), "{dest:?} should be blocked");
}
for dest in [
"",
":",
"https://example.com",
"HTTPS://example.com",
"mailto:me@example.com",
"../a/b",
"/a/b",
"#frag",
"?q=1",
"javascript", "a javascript:alert(1)", "not-javascript:x",
"data:image/png;base64,AAAA",
"DATA:IMAGE/PNG;base64,AAAA",
"data:image/svg+xml,%3Csvg%3E",
"blob:https://example.com/uuid",
"verylongschemename:x",
] {
assert!(
!is_blocked_destination(dest),
"{dest:?} should not be blocked"
);
}
}
}