use crate::error::MarkdownError;
use crate::extensions::{
collect_headings, enhance_table_nodes, process_custom_block_nodes,
CustomBlockConfig, Heading,
};
use comrak::options::{Plugins, RenderPlugins};
use comrak::{Arena, Options};
use log::{debug, info, warn};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::io::Write;
use std::sync::LazyLock;
#[cfg(feature = "syntax_highlighting")]
use crate::highlight::SyntectAdapter;
pub const DEFAULT_MAX_INPUT_SIZE: usize = 1_048_576;
#[derive(Clone)]
pub struct MarkdownOptions<'a> {
pub comrak_options: Options<'a>,
pub enable_custom_blocks: bool,
pub enable_syntax_highlighting: bool,
pub enable_enhanced_tables: bool,
pub syntax_theme: Option<String>,
pub allow_unsafe_html: bool,
pub custom_block_config: CustomBlockConfig,
pub max_input_size: usize,
pub header_ids: Option<String>,
pub sanitizer_config: Option<SanitizerConfig>,
pub enable_diagrams: bool,
}
impl<'a> Default for MarkdownOptions<'a> {
fn default() -> Self {
let mut comrak_options = Options::default();
comrak_options.extension.table = true;
Self {
comrak_options,
enable_custom_blocks: true,
enable_syntax_highlighting: true,
enable_enhanced_tables: true,
syntax_theme: None,
allow_unsafe_html: true,
custom_block_config: CustomBlockConfig::default(),
max_input_size: DEFAULT_MAX_INPUT_SIZE,
header_ids: None,
sanitizer_config: None,
enable_diagrams: false,
}
}
}
impl<'a> MarkdownOptions<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn with_custom_blocks(mut self, enable: bool) -> Self {
self.enable_custom_blocks = enable;
self
}
pub fn with_syntax_highlighting(mut self, enable: bool) -> Self {
self.enable_syntax_highlighting = enable;
self
}
pub fn with_enhanced_tables(mut self, enable: bool) -> Self {
self.enable_enhanced_tables = enable;
self
}
pub fn with_custom_theme(mut self, theme: String) -> Self {
self.syntax_theme = Some(theme);
self
}
pub fn with_comrak_options(mut self, options: Options<'a>) -> Self {
self.allow_unsafe_html = options.render.r#unsafe;
self.comrak_options = options;
self
}
pub fn with_unsafe_html(mut self, enable: bool) -> Self {
self.allow_unsafe_html = enable;
self
}
pub fn with_custom_block_config(
mut self,
config: CustomBlockConfig,
) -> Self {
self.custom_block_config = config;
self
}
pub fn with_max_input_size(mut self, size: usize) -> Self {
self.max_input_size = size;
self
}
pub fn with_header_ids(
mut self,
prefix: impl Into<String>,
) -> Self {
self.header_ids = Some(prefix.into());
self
}
pub fn with_sanitizer_config(
mut self,
config: SanitizerConfig,
) -> Self {
self.sanitizer_config = Some(config);
self
}
pub fn with_diagrams(mut self, enable: bool) -> Self {
self.enable_diagrams = enable;
self
}
pub fn validate(
&self,
) -> Result<(), Vec<(String, crate::validation::ValidationError)>>
{
use crate::validation::{ValidationError, Validator};
let mut v = Validator::new();
v.check("enable_enhanced_tables", || {
if self.enable_enhanced_tables
&& !self.comrak_options.extension.table
{
Err(ValidationError::Custom(
"enhanced_tables = true requires comrak_options.extension.table = true"
.into(),
))
} else {
Ok(())
}
});
#[cfg(feature = "syntax_highlighting")]
v.check("syntax_theme", || {
if let Some(ref theme) = self.syntax_theme {
let available =
crate::highlight::SyntectAdapter::available_themes(
);
if !available.contains(&theme.as_str()) {
return Err(ValidationError::NotInSet {
allowed: available
.iter()
.map(|s| (*s).to_string())
.collect(),
});
}
}
Ok(())
});
v.check("syntax_theme", || {
if !self.enable_syntax_highlighting
&& self.syntax_theme.is_some()
{
Err(ValidationError::Custom(
"syntax_theme is set but enable_syntax_highlighting = false (theme would be ignored)"
.into(),
))
} else {
Ok(())
}
});
v.check("sanitizer_config", || {
if self.allow_unsafe_html && self.sanitizer_config.is_some()
{
Err(ValidationError::Custom(
"sanitizer_config is set but allow_unsafe_html = true (sanitizer is skipped)"
.into(),
))
} else {
Ok(())
}
});
v.check("header_ids", || {
if let Some(ref prefix) = self.header_ids {
if let Some(c) = prefix.chars().find(|c| {
c.is_whitespace()
|| matches!(
c,
'"' | '\''
| '<'
| '>'
| '&'
| '='
)
}) {
return Err(ValidationError::InvalidPattern {
pattern: format!(
"no whitespace or HTML-special chars (found {c:?})"
),
});
}
}
Ok(())
});
if let Some(ref cfg) = self.sanitizer_config {
check_tag_list(
&mut v,
"sanitizer_config.extra_tags",
&cfg.extra_tags,
);
check_tag_attr_map(
&mut v,
"sanitizer_config.extra_tag_attributes",
&cfg.extra_tag_attributes,
);
check_attr_list(
&mut v,
"sanitizer_config.extra_generic_attributes",
&cfg.extra_generic_attributes,
);
check_allowed_classes_map(
&mut v,
"sanitizer_config.extra_allowed_classes",
&cfg.extra_allowed_classes,
);
}
for (block_type, class) in
&self.custom_block_config.class_overrides
{
let field = format!(
"custom_block_config.class_overrides[{block_type:?}]"
);
let c = class.clone();
v.check(&field, move || {
if c.is_empty() {
return Err(ValidationError::Empty);
}
if let Some(ch) = c.chars().find(|c| {
c.is_whitespace() || matches!(c, '"' | '\'')
}) {
return Err(ValidationError::InvalidPattern {
pattern: format!(
"non-empty, no whitespace or quotes (found {ch:?})"
),
});
}
Ok(())
});
}
for (block_type, title) in
&self.custom_block_config.title_overrides
{
let field = format!(
"custom_block_config.title_overrides[{block_type:?}]"
);
let t = title.clone();
v.check(&field, move || {
if t.trim().is_empty() {
return Err(ValidationError::Empty);
}
Ok(())
});
}
v.finish()
}
}
fn is_html_name(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn check_tag_list(
v: &mut crate::validation::Validator,
field: &str,
tags: &[String],
) {
for (i, tag) in tags.iter().enumerate() {
let f = format!("{field}[{i}]");
let t = tag.clone();
v.check(&f, move || {
if !is_html_name(&t) {
Err(crate::validation::ValidationError::InvalidPattern {
pattern: format!(
"valid HTML tag name (got {t:?})"
),
})
} else {
Ok(())
}
});
}
}
fn check_attr_list(
v: &mut crate::validation::Validator,
field: &str,
attrs: &[String],
) {
for (i, attr) in attrs.iter().enumerate() {
let f = format!("{field}[{i}]");
let a = attr.clone();
v.check(&f, move || {
if !is_html_name(&a) {
Err(crate::validation::ValidationError::InvalidPattern {
pattern: format!(
"valid HTML attribute name (got {a:?})"
),
})
} else {
Ok(())
}
});
}
}
fn check_tag_attr_map(
v: &mut crate::validation::Validator,
field: &str,
map: &HashMap<String, Vec<String>>,
) {
for (tag, attrs) in map {
let f_tag = format!("{field}.{tag}");
let t = tag.clone();
v.check(&f_tag, move || {
if !is_html_name(&t) {
Err(crate::validation::ValidationError::InvalidPattern {
pattern: format!(
"valid HTML tag name (got {t:?})"
),
})
} else {
Ok(())
}
});
check_attr_list(v, &f_tag, attrs);
}
}
fn check_allowed_classes_map(
v: &mut crate::validation::Validator,
field: &str,
map: &HashMap<String, Vec<String>>,
) {
for (tag, classes) in map {
let f_tag = format!("{field}.{tag}");
let t = tag.clone();
v.check(&f_tag, move || {
if !is_html_name(&t) {
Err(crate::validation::ValidationError::InvalidPattern {
pattern: format!(
"valid HTML tag name (got {t:?})"
),
})
} else {
Ok(())
}
});
for (i, class) in classes.iter().enumerate() {
let f = format!("{f_tag}[{i}]");
let c = class.clone();
v.check(&f, move || {
if c.is_empty() {
return Err(
crate::validation::ValidationError::Empty,
);
}
if let Some(ch) = c.chars().find(|c| {
c.is_whitespace() || matches!(c, '"' | '\'')
}) {
return Err(
crate::validation::ValidationError::InvalidPattern {
pattern: format!(
"non-empty, no whitespace or quotes (got {ch:?})"
),
},
);
}
Ok(())
});
}
}
}
impl fmt::Debug for MarkdownOptions<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MarkdownOptions")
.field("enable_custom_blocks", &self.enable_custom_blocks)
.field(
"enable_syntax_highlighting",
&self.enable_syntax_highlighting,
)
.field(
"enable_enhanced_tables",
&self.enable_enhanced_tables,
)
.field("syntax_theme", &self.syntax_theme)
.field("allow_unsafe_html", &self.allow_unsafe_html)
.field("max_input_size", &self.max_input_size)
.field("header_ids", &self.header_ids)
.field("sanitizer_config", &self.sanitizer_config)
.field("enable_diagrams", &self.enable_diagrams)
.finish()
}
}
#[derive(Debug, Clone, Default)]
pub struct SanitizerConfig {
pub extra_tags: Vec<String>,
pub extra_tag_attributes: HashMap<String, Vec<String>>,
pub extra_generic_attributes: Vec<String>,
pub extra_allowed_classes: HashMap<String, Vec<String>>,
}
impl SanitizerConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.extra_tags.push(tag.into());
self
}
pub fn with_tag_attribute(
mut self,
tag: impl Into<String>,
attr: impl Into<String>,
) -> Self {
self.extra_tag_attributes
.entry(tag.into())
.or_default()
.push(attr.into());
self
}
pub fn with_generic_attribute(
mut self,
attr: impl Into<String>,
) -> Self {
self.extra_generic_attributes.push(attr.into());
self
}
pub fn with_allowed_class(
mut self,
tag: impl Into<String>,
class: impl Into<String>,
) -> Self {
self.extra_allowed_classes
.entry(tag.into())
.or_default()
.push(class.into());
self
}
}
pub fn default_markdown_options() -> MarkdownOptions<'static> {
MarkdownOptions::new()
.with_custom_blocks(true)
.with_syntax_highlighting(true)
.with_enhanced_tables(true)
.with_comrak_options({
let mut opts = Options::default();
opts.extension.table = true;
opts
})
.with_unsafe_html(true)
}
pub fn process_markdown(
content: &str,
options: &MarkdownOptions,
) -> Result<String, MarkdownError> {
let mut buf: Vec<u8> = Vec::new();
process_markdown_to_writer(content, &mut buf, options)?;
String::from_utf8(buf).map_err(|e| {
MarkdownError::RenderError(format!(
"non-UTF-8 output from pipeline: {e}"
))
})
}
pub fn process_markdown_to_writer<W: Write>(
content: &str,
writer: &mut W,
options: &MarkdownOptions,
) -> Result<(), MarkdownError> {
pipeline(content, writer, options, None)
}
pub fn process_markdown_with_toc(
content: &str,
options: &MarkdownOptions,
) -> Result<(String, Vec<Heading>), MarkdownError> {
let mut buf: Vec<u8> = Vec::new();
let mut toc = Vec::new();
pipeline(content, &mut buf, options, Some(&mut toc))?;
let html = String::from_utf8(buf).map_err(|e| {
MarkdownError::RenderError(format!(
"non-UTF-8 output from pipeline: {e}"
))
})?;
Ok((html, toc))
}
pub fn process_markdown_with_toc_to_writer<W: Write>(
content: &str,
writer: &mut W,
options: &MarkdownOptions,
) -> Result<Vec<Heading>, MarkdownError> {
let mut toc = Vec::new();
pipeline(content, writer, options, Some(&mut toc))?;
Ok(toc)
}
pub fn process_markdown_to_plain_text(
content: &str,
options: &MarkdownOptions,
) -> Result<String, MarkdownError> {
if options.max_input_size > 0
&& content.len() > options.max_input_size
{
return Err(MarkdownError::InputTooLarge {
size: content.len(),
limit: options.max_input_size,
});
}
let arena = Arena::new();
let root = comrak::parse_document(
&arena,
content,
&options.comrak_options,
);
Ok(crate::extensions::collect_all_text(root))
}
fn pipeline<W: Write>(
content: &str,
writer: &mut W,
options: &MarkdownOptions,
toc_out: Option<&mut Vec<Heading>>,
) -> Result<(), MarkdownError> {
info!("Starting markdown processing");
debug!("Markdown options: {:?}", options);
if options.max_input_size > 0
&& content.len() > options.max_input_size
{
return Err(MarkdownError::InputTooLarge {
size: content.len(),
limit: options.max_input_size,
});
}
if let Err(errors) = options.validate() {
for (field, err) in &errors {
warn!("Invalid MarkdownOptions.{field}: {err}");
}
return Err(MarkdownError::from(errors));
}
let mut comrak_opts = options.comrak_options.clone();
comrak_opts.render.r#unsafe = true;
if let Some(ref prefix) = options.header_ids {
comrak_opts.extension.header_id_prefix = Some(prefix.clone());
}
let arena = Arena::new();
let root = comrak::parse_document(&arena, content, &comrak_opts);
if options.enable_custom_blocks {
debug!("Processing custom blocks at AST level");
process_custom_block_nodes(root, &options.custom_block_config);
}
if options.enable_diagrams {
debug!("Rewriting diagram code blocks");
crate::diagrams::process_diagram_code_blocks(root);
}
if let Some(toc) = toc_out {
debug!("Collecting headings for table of contents");
*toc = collect_headings(root, options.header_ids.as_deref());
}
if options.enable_enhanced_tables {
debug!("Enhancing tables at AST level");
enhance_table_nodes(root, &arena, &comrak_opts);
}
debug!("Rendering AST to HTML");
#[cfg(feature = "syntax_highlighting")]
let adapter;
#[cfg(feature = "syntax_highlighting")]
let plugins = if options.enable_syntax_highlighting {
adapter = SyntectAdapter::new(options.syntax_theme.as_deref());
Plugins {
render: RenderPlugins {
codefence_syntax_highlighter: Some(&adapter),
..Default::default()
},
}
} else {
Plugins::default()
};
#[cfg(not(feature = "syntax_highlighting"))]
let plugins = Plugins::default();
let mut html = String::new();
comrak::format_html_with_plugins(
root,
&comrak_opts,
&mut html,
&plugins,
)
.map_err(|e| MarkdownError::RenderError(e.to_string()))?;
if options.allow_unsafe_html {
writer.write_all(html.as_bytes())?;
} else {
debug!("Sanitizing HTML output");
sanitize_html_to_writer(
&html,
writer,
options.sanitizer_config.as_ref(),
)?;
}
info!("Markdown processing completed successfully");
Ok(())
}
static CODE_LANG_CLASSES: LazyLock<HashSet<String>> =
LazyLock::new(|| {
[
"rust",
"python",
"javascript",
"typescript",
"java",
"c",
"cpp",
"csharp",
"go",
"ruby",
"swift",
"kotlin",
"php",
"html",
"css",
"sql",
"bash",
"shell",
"json",
"yaml",
"toml",
"xml",
"markdown",
"plaintext",
"text",
]
.iter()
.map(|lang| format!("language-{lang}"))
.collect()
});
fn configure_default_sanitizer<'a>(builder: &mut ammonia::Builder<'a>) {
let code_class_refs: HashSet<&'static str> =
CODE_LANG_CLASSES.iter().map(|s| s.as_str()).collect();
let mut allowed_classes: HashMap<
&'static str,
HashSet<&'static str>,
> = HashMap::new();
allowed_classes.insert(
"div",
[
"alert",
"alert-info",
"alert-warning",
"alert-success",
"alert-primary",
"alert-danger",
"alert-secondary",
"table-responsive",
]
.into_iter()
.collect(),
);
allowed_classes.insert("table", ["table"].into_iter().collect());
allowed_classes.insert(
"td",
["text-left", "text-center", "text-right"]
.into_iter()
.collect(),
);
allowed_classes.insert("code", code_class_refs);
allowed_classes.insert("pre", ["mermaid"].into_iter().collect());
builder
.add_tags(["div", "pre", "code", "span", "input"])
.add_tag_attributes("div", &["role", "id"])
.add_tag_attributes("td", &["align"])
.add_tag_attributes("th", &["align"])
.add_tag_attributes("input", &["type", "checked", "disabled"])
.add_tag_attributes("h1", &["id"])
.add_tag_attributes("h2", &["id"])
.add_tag_attributes("h3", &["id"])
.add_tag_attributes("h4", &["id"])
.add_tag_attributes("h5", &["id"])
.add_tag_attributes("h6", &["id"])
.add_tag_attributes("a", &["id"])
.allowed_classes(allowed_classes)
.add_tag_attributes("span", &["class", "data-math-style"]);
}
static SANITIZE_BUILDER: LazyLock<ammonia::Builder<'static>> =
LazyLock::new(|| {
let mut builder = ammonia::Builder::default();
configure_default_sanitizer(&mut builder);
builder
});
fn sanitize_html_to_writer<W: Write>(
html: &str,
writer: &mut W,
cfg: Option<&SanitizerConfig>,
) -> std::io::Result<()> {
match cfg {
None => SANITIZE_BUILDER.clean(html).write_to(writer),
Some(custom) => {
build_custom_sanitizer(custom).clean(html).write_to(writer)
}
}
}
fn build_custom_sanitizer(
cfg: &SanitizerConfig,
) -> ammonia::Builder<'_> {
let mut builder = ammonia::Builder::default();
configure_default_sanitizer(&mut builder);
for tag in cfg.extra_allowed_classes.keys() {
builder.rm_tag_attributes(tag.as_str(), &["class"]);
}
if !cfg.extra_tags.is_empty() {
builder.add_tags(cfg.extra_tags.iter().map(String::as_str));
}
for (tag, attrs) in &cfg.extra_tag_attributes {
builder.add_tag_attributes(
tag.as_str(),
attrs.iter().map(String::as_str),
);
}
if !cfg.extra_generic_attributes.is_empty() {
builder.add_generic_attributes(
cfg.extra_generic_attributes.iter().map(String::as_str),
);
}
for (tag, classes) in &cfg.extra_allowed_classes {
builder.add_allowed_classes(
tag.as_str(),
classes.iter().map(String::as_str),
);
}
builder
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CustomBlockType;
#[test]
fn test_process_markdown_with_all_features() {
let markdown = r#"
# Test Markdown
| Left | Center | Right |
|:-----|:------:|------:|
| 1 | 2 | 3 |
```rust
fn main() {
println!("Hello, world!");
}
```
<div class="note">This is a note.</div>
<div class="warning">This is a warning.</div>
<div class="tip">This is a tip.</div>
"#;
let options = default_markdown_options();
let result = process_markdown(markdown, &options);
assert!(result.is_ok(), "Failed: {:?}", result.err());
let html = result.unwrap();
assert!(html.contains("table-responsive"));
assert!(html.contains("language-rust"));
assert!(html.contains("alert alert-info"));
assert!(html.contains("alert alert-warning"));
assert!(html.contains("alert alert-success"));
}
#[test]
fn test_process_markdown_without_custom_blocks() {
let markdown = "# Test\n<div class=\"note\">Note.</div>";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_comrak_options({
let mut opts = Options::default();
opts.extension.table = true;
opts
})
.with_unsafe_html(true);
let html = process_markdown(markdown, &options).unwrap();
assert!(html.contains("<div class=\"note\">"));
assert!(!html.contains("alert"));
}
#[test]
fn test_process_markdown_without_enhanced_tables() {
let markdown = "| H1 | H2 |\n|---|---|\n| A | B |";
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_comrak_options({
let mut opts = Options::default();
opts.extension.table = true;
opts
});
let html = process_markdown(markdown, &options).unwrap();
assert!(!html.contains("table-responsive"));
assert!(html.contains("<table>"));
}
#[test]
fn test_validation_enhanced_tables_without_extension() {
let options = MarkdownOptions::new()
.with_enhanced_tables(true)
.with_custom_blocks(false)
.with_comrak_options({
let mut opts = Options::default();
opts.extension.table = false;
opts
});
let errors = options.validate().unwrap_err();
assert!(errors
.iter()
.any(|(f, _)| f == "enable_enhanced_tables"));
}
#[test]
fn test_validation_default_options_pass() {
let mut comrak = Options::default();
comrak.extension.table = true;
let options =
MarkdownOptions::new().with_comrak_options(comrak);
assert!(
options.validate().is_ok(),
"{:?}",
options.validate().unwrap_err()
);
}
#[test]
fn test_validation_unknown_syntax_theme() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_custom_theme("no-such-theme-exists".into());
let errors = options.validate().unwrap_err();
assert!(
errors.iter().any(|(f, _)| f == "syntax_theme"),
"expected syntax_theme failure, got {errors:?}"
);
}
#[test]
fn test_validation_theme_without_highlighter_disabled() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_syntax_highlighting(false)
.with_custom_theme("base16-ocean.dark".into());
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f == "syntax_theme"));
}
#[test]
fn test_validation_sanitizer_with_unsafe_html() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_unsafe_html(true)
.with_sanitizer_config(
SanitizerConfig::new().with_tag("main"),
);
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f == "sanitizer_config"));
}
#[test]
fn test_validation_header_ids_bad_chars() {
for bad in [
"user content ", "quo\"te-",
"ang<le-",
"amp&-",
] {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_header_ids(bad);
let errors = options.validate().unwrap_err();
assert!(
errors.iter().any(|(f, _)| f == "header_ids"),
"expected header_ids failure for {bad:?}, got {errors:?}"
);
}
}
#[test]
fn test_validation_header_ids_clean_prefix_ok() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_header_ids("user-content-");
assert!(options.validate().is_ok());
}
#[test]
fn test_validation_sanitizer_extra_tag_invalid() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new().with_tag("has space"),
);
let errors = options.validate().unwrap_err();
assert!(
errors
.iter()
.any(|(f, _)| f
.starts_with("sanitizer_config.extra_tags"))
);
}
#[test]
fn test_validation_sanitizer_extra_generic_attribute_invalid() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new().with_generic_attribute(""),
);
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f
.starts_with("sanitizer_config.extra_generic_attributes")));
}
#[test]
fn test_validation_sanitizer_allowed_class_with_whitespace() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new()
.with_allowed_class("span", "has space"),
);
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f
.starts_with("sanitizer_config.extra_allowed_classes")));
}
#[test]
fn test_validation_custom_block_class_override_empty() {
let cfg = CustomBlockConfig::new()
.with_class(CustomBlockType::Note, "");
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_block_config(cfg);
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f
.starts_with("custom_block_config.class_overrides")));
}
#[test]
fn test_validation_custom_block_title_override_blank() {
let cfg = CustomBlockConfig::new()
.with_title(CustomBlockType::Warning, " ");
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_block_config(cfg);
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f
.starts_with("custom_block_config.title_overrides")));
}
#[test]
fn test_sanitizer_config_applies_extra_tag_attribute() {
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new()
.with_tag("section")
.with_tag_attribute("section", "data-foo"),
);
let md = r#"<section data-foo="bar">hello</section>"#;
let html = process_markdown(md, &options).unwrap();
assert!(html.contains("<section"));
assert!(html.contains("data-foo=\"bar\""));
}
#[test]
fn test_sanitizer_config_applies_extra_generic_attribute() {
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new().with_generic_attribute("data-x"),
);
let md = r#"<p data-x="v">hi</p>"#;
let html = process_markdown(md, &options).unwrap();
assert!(html.contains("data-x=\"v\""));
}
#[test]
fn test_sanitizer_config_with_tag_attribute_direct() {
let cfg = SanitizerConfig::new()
.with_tag_attribute("div", "role")
.with_tag_attribute("div", "id");
let attrs = cfg
.extra_tag_attributes
.get("div")
.expect("div should exist");
assert_eq!(attrs, &vec!["role".to_string(), "id".to_string()]);
}
#[test]
fn test_validation_sanitizer_tag_attr_invalid_tag() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new()
.with_tag_attribute("has space", "id"),
);
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f
.starts_with("sanitizer_config.extra_tag_attributes")));
}
#[test]
fn test_validation_sanitizer_tag_attr_invalid_attr_name() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new().with_tag_attribute("div", ""),
);
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f
.starts_with("sanitizer_config.extra_tag_attributes")));
}
#[test]
fn test_validation_sanitizer_allowed_class_invalid_tag() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new()
.with_allowed_class("bad tag", "foo"),
);
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f
.starts_with("sanitizer_config.extra_allowed_classes")));
}
#[test]
fn test_validation_sanitizer_allowed_class_empty() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new().with_allowed_class("span", ""),
);
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f
.starts_with("sanitizer_config.extra_allowed_classes")));
}
#[test]
fn test_validation_custom_block_class_override_whitespace() {
let cfg = CustomBlockConfig::new()
.with_class(CustomBlockType::Note, "bad class");
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_block_config(cfg);
let errors = options.validate().unwrap_err();
assert!(errors.iter().any(|(f, _)| f
.starts_with("custom_block_config.class_overrides")));
}
#[test]
fn test_toc_extracts_image_title() {
let md = "# See  here\n";
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false);
let (_html, toc) =
process_markdown_with_toc(md, &options).unwrap();
assert_eq!(toc.len(), 1);
assert!(
toc[0].text.contains("Logo Title")
|| toc[0].text.contains("alt"),
"expected image title/alt in: {:?}",
toc[0].text
);
}
#[test]
fn test_plain_text_soft_break_inserts_space() {
let md = "one\ntwo\nthree\n";
let text = process_markdown_to_plain_text(
md,
&MarkdownOptions::default(),
)
.unwrap();
assert_eq!(text, "one two three");
}
#[test]
fn test_plain_text_image_title_included() {
let md = "Caption: \n";
let text = process_markdown_to_plain_text(
md,
&MarkdownOptions::default(),
)
.unwrap();
assert!(text.contains("Caption:"));
}
#[test]
fn test_validation_reports_all_failures_at_once() {
let cfg = CustomBlockConfig::new()
.with_class(CustomBlockType::Note, "");
let options = MarkdownOptions::new()
.with_enhanced_tables(true) .with_header_ids("a b") .with_custom_block_config(cfg) .with_comrak_options({
let mut opts = Options::default();
opts.extension.table = false;
opts
});
let errors = options.validate().unwrap_err();
assert!(
errors.len() >= 3,
"expected 3+ errors, got {errors:?}"
);
}
#[test]
fn test_empty_content() {
let options = MarkdownOptions::new()
.with_enhanced_tables(false)
.with_custom_blocks(false);
let html = process_markdown("", &options).unwrap();
assert!(html.trim().is_empty());
}
#[test]
fn test_no_features_enabled() {
let markdown = "# Title\n\nPlain text.";
let options = MarkdownOptions::new()
.with_syntax_highlighting(false)
.with_custom_blocks(false)
.with_enhanced_tables(false);
let html = process_markdown(markdown, &options).unwrap();
assert!(html.contains("<h1>Title</h1>"));
assert!(html.contains("Plain text."));
}
#[test]
fn test_sanitization_strips_script() {
let markdown = "<script>alert('xss')</script>";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false);
let html = process_markdown(markdown, &options).unwrap();
assert!(
!html.contains("<script>"),
"Script tags should be stripped"
);
}
#[test]
fn test_sanitization_preserves_alerts() {
let markdown = "<div class=\"note\">Important info.</div>";
let options = MarkdownOptions::new()
.with_custom_blocks(true)
.with_enhanced_tables(false)
.with_unsafe_html(false);
let html = process_markdown(markdown, &options).unwrap();
assert!(
html.contains("alert alert-info"),
"Alert divs should survive sanitization"
);
}
#[test]
fn test_input_too_large() {
let options = MarkdownOptions::new()
.with_max_input_size(10)
.with_custom_blocks(false)
.with_enhanced_tables(false);
let result =
process_markdown("a]".repeat(20).as_str(), &options);
assert!(matches!(
result,
Err(MarkdownError::InputTooLarge { .. })
));
}
#[test]
fn test_syntax_theme_customization() {
let markdown = "```rust\nfn main() {}\n```";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_custom_theme("InspiredGitHub".to_string());
let result = process_markdown(markdown, &options);
assert!(result.is_ok());
}
#[test]
fn test_custom_block_config() {
let markdown = "<div class=\"note\">Custom styled.</div>";
let config = CustomBlockConfig::new()
.with_class(
crate::extensions::CustomBlockType::Note,
"my-note",
)
.with_title(
crate::extensions::CustomBlockType::Note,
"Heads up",
);
let options = MarkdownOptions::new()
.with_custom_blocks(true)
.with_enhanced_tables(false)
.with_custom_block_config(config)
.with_unsafe_html(true);
let html = process_markdown(markdown, &options).unwrap();
assert!(html.contains("my-note"));
assert!(html.contains("Heads up:"));
}
#[test]
fn test_builder_order_comrak_then_unsafe() {
let options = MarkdownOptions::new()
.with_comrak_options(Options::default())
.with_unsafe_html(true);
assert!(options.allow_unsafe_html);
}
#[test]
fn test_comrak_options_syncs_unsafe() {
let mut opts = Options::default();
opts.render.r#unsafe = true;
let options = MarkdownOptions::new().with_comrak_options(opts);
assert!(options.allow_unsafe_html);
}
#[test]
fn test_markdown_options_debug_impl() {
let options = MarkdownOptions::new()
.with_custom_blocks(true)
.with_syntax_highlighting(false)
.with_enhanced_tables(true)
.with_custom_theme("InspiredGitHub".to_string())
.with_unsafe_html(false)
.with_max_input_size(2048);
let debug_output = format!("{:?}", options);
assert!(debug_output.contains("MarkdownOptions"));
assert!(debug_output.contains("enable_custom_blocks: true"));
assert!(
debug_output.contains("enable_syntax_highlighting: false")
);
assert!(debug_output.contains("enable_enhanced_tables: true"));
assert!(debug_output.contains("InspiredGitHub"));
assert!(debug_output.contains("allow_unsafe_html: false"));
assert!(debug_output.contains("max_input_size: 2048"));
}
#[test]
fn test_header_ids() {
let markdown = "# Hello World\n## Sub Section";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_header_ids("")
.with_unsafe_html(true);
let html = process_markdown(markdown, &options).unwrap();
assert!(
html.contains("id=\"hello-world\""),
"H1 should have id attribute: {html}"
);
assert!(
html.contains("id=\"sub-section\""),
"H2 should have id attribute: {html}"
);
}
#[test]
fn test_header_ids_with_prefix() {
let markdown = "# Title";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_header_ids("user-content-")
.with_unsafe_html(true);
let html = process_markdown(markdown, &options).unwrap();
assert!(
html.contains("id=\"user-content-title\""),
"Should have prefixed id: {html}"
);
}
#[test]
fn test_header_ids_survive_sanitization() {
let markdown = "# Hello World";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_header_ids("")
.with_unsafe_html(false);
let html = process_markdown(markdown, &options).unwrap();
assert!(
html.contains("id=\"hello-world\""),
"Header id should survive ammonia sanitization: {html}"
);
}
#[test]
fn test_ast_table_enhancement() {
let markdown =
"| H1 | H2 |\n|:---|---:|\n| L | R |\n\nParagraph\n\n| A | B |\n|---|---|\n| C | D |";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_comrak_options({
let mut opts = Options::default();
opts.extension.table = true;
opts
})
.with_unsafe_html(true);
let html = process_markdown(markdown, &options).unwrap();
assert_eq!(
html.matches("table-responsive").count(),
2,
"Both tables should get responsive wrapper: {html}"
);
assert!(
html.contains("text-right"),
"Right-aligned cells should have class"
);
}
#[test]
fn test_process_markdown_to_writer_matches_string_variant() {
let markdown = "# Title\n\nParagraph with *emphasis*.";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_syntax_highlighting(false);
let as_string = process_markdown(markdown, &options).unwrap();
let mut buf: Vec<u8> = Vec::new();
process_markdown_to_writer(markdown, &mut buf, &options)
.unwrap();
let as_bytes = String::from_utf8(buf).unwrap();
assert_eq!(
as_string, as_bytes,
"writer variant must produce byte-identical output"
);
}
#[test]
fn test_process_markdown_to_writer_sanitizes() {
let markdown = "<script>alert('xss')</script>\n\n# Safe";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false);
let mut buf: Vec<u8> = Vec::new();
process_markdown_to_writer(markdown, &mut buf, &options)
.unwrap();
let html = String::from_utf8(buf).unwrap();
assert!(!html.contains("<script>"));
assert!(html.contains("<h1>Safe</h1>"));
}
#[test]
fn test_process_markdown_to_writer_propagates_io_error() {
struct AlwaysFails;
impl Write for AlwaysFails {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"nope",
))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false);
let err = process_markdown_to_writer(
"# hi",
&mut AlwaysFails,
&options,
)
.unwrap_err();
assert!(matches!(err, MarkdownError::IoError(_)));
}
#[test]
fn test_sanitizer_config_allows_extra_tag() {
let markdown = "<main>wrapper</main>";
let strict = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false);
let stripped = process_markdown(markdown, &strict).unwrap();
assert!(
!stripped.contains("<main>"),
"default sanitizer drops <main>: {stripped}"
);
let extended = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new().with_tag("main"),
);
let kept = process_markdown(markdown, &extended).unwrap();
assert!(
kept.contains("<main>wrapper</main>"),
"extended sanitizer keeps <main>: {kept}"
);
}
#[test]
fn test_sanitizer_config_adds_allowed_class() {
let markdown =
"<span class=\"badge\">new</span> <span class=\"danger\">x</span>";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new()
.with_allowed_class("span", "badge"),
);
let html = process_markdown(markdown, &options).unwrap();
assert!(
html.contains("class=\"badge\""),
"whitelisted class survives: {html}"
);
assert!(
!html.contains("class=\"danger\""),
"non-whitelisted class dropped: {html}"
);
}
#[cfg(feature = "syntax_highlighting")]
#[test]
fn test_sanitized_output_keeps_syntect_span_classes() {
let markdown = "```rust\nfn main() {}\n```";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false);
let html = process_markdown(markdown, &options).unwrap();
assert!(
html.contains("<span class=\""),
"syntect classes were stripped by sanitizer: {html}"
);
}
#[test]
fn test_sanitizer_config_restricts_span_class() {
let markdown = "<span class=\"badge\">a</span> <span class=\"danger\">b</span>";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false)
.with_sanitizer_config(
SanitizerConfig::new()
.with_allowed_class("span", "badge"),
);
let html = process_markdown(markdown, &options).unwrap();
assert!(
html.contains("class=\"badge\""),
"whitelisted class survives: {html}"
);
assert!(
!html.contains("class=\"danger\""),
"non-whitelisted class dropped: {html}"
);
}
#[test]
fn test_sanitizer_strips_style_attribute() {
let markdown =
"<div style=\"position:fixed;top:0;left:0;width:100%;height:100%;z-index:9999;\">overlay</div>";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false);
let html = process_markdown(markdown, &options).unwrap();
assert!(
!html.contains("style="),
"style attribute must be stripped: {html}"
);
assert!(html.contains("<div"), "div tag dropped: {html}");
}
#[test]
fn test_toc_collects_headings_in_document_order() {
let markdown = "\
# First
Some text.
## Second-A
More text.
## Second-B
# Third\n";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false);
let (_html, toc) =
process_markdown_with_toc(markdown, &options).unwrap();
let levels: Vec<u8> = toc.iter().map(|h| h.level).collect();
let texts: Vec<&str> =
toc.iter().map(|h| h.text.as_str()).collect();
assert_eq!(levels, vec![1, 2, 2, 1]);
assert_eq!(
texts,
vec!["First", "Second-A", "Second-B", "Third"]
);
}
#[test]
fn test_toc_ids_match_rendered_html() {
let markdown = "# Hello World\n\n## A Second Heading\n";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_header_ids("");
let (html, toc) =
process_markdown_with_toc(markdown, &options).unwrap();
assert_eq!(toc.len(), 2);
for h in &toc {
let needle = format!("id=\"{}\"", h.id);
assert!(
html.contains(&needle),
"ToC id {:?} not found in HTML: {}",
h.id,
html
);
}
}
#[test]
fn test_toc_prefix_propagates() {
let markdown = "# Intro\n";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_header_ids("user-content-");
let (html, toc) =
process_markdown_with_toc(markdown, &options).unwrap();
assert_eq!(toc.len(), 1);
assert_eq!(toc[0].id, "user-content-intro");
assert!(html.contains("id=\"user-content-intro\""));
}
#[test]
fn test_toc_dedup_with_repeated_headings() {
let markdown = "# Notes\n## Notes\n### Notes\n";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_header_ids("");
let (_html, toc) =
process_markdown_with_toc(markdown, &options).unwrap();
let ids: Vec<&str> =
toc.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids, vec!["notes", "notes-1", "notes-2"]);
}
#[test]
fn test_toc_empty_document() {
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false);
let (html, toc) =
process_markdown_with_toc("", &options).unwrap();
assert!(toc.is_empty());
assert!(html.trim().is_empty());
}
#[test]
fn test_toc_writer_variant_writes_html_and_returns_toc() {
let markdown = "# Title\n\n## Sub\n";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false);
let mut buf: Vec<u8> = Vec::new();
let toc = process_markdown_with_toc_to_writer(
markdown, &mut buf, &options,
)
.unwrap();
let html = String::from_utf8(buf).unwrap();
assert!(html.contains("<h1>Title</h1>"));
assert!(html.contains("<h2>Sub</h2>"));
assert_eq!(toc.len(), 2);
}
#[test]
fn test_toc_extracts_inline_code_text() {
let markdown = "# Using `&str` types\n";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false);
let (_html, toc) =
process_markdown_with_toc(markdown, &options).unwrap();
assert_eq!(toc.len(), 1);
assert_eq!(toc[0].text, "Using &str types");
}
#[test]
fn test_sanitizer_config_default_path_unchanged() {
let markdown = "<script>x</script>\n<div class=\"alert alert-info\">safe</div>";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_unsafe_html(false);
let html = process_markdown(markdown, &options).unwrap();
assert!(!html.contains("<script>"));
assert!(html.contains("alert alert-info"));
}
#[test]
fn test_diagrams_off_by_default_leaves_code_block() {
let md = "```mermaid\ngraph TD\nA-->B\n```\n";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false);
let html = process_markdown(md, &options).unwrap();
assert!(html.contains("<code class=\"language-mermaid\">"));
assert!(!html.contains("class=\"mermaid\""));
}
#[test]
fn test_diagrams_mermaid_survives_sanitizer() {
let md = "```mermaid\ngraph TD\nA-->B\n```\n";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_syntax_highlighting(false)
.with_diagrams(true)
.with_unsafe_html(false);
let html = process_markdown(md, &options).unwrap();
assert!(
html.contains("<pre class=\"mermaid\">"),
"mermaid container stripped: {html}"
);
assert!(html.contains("graph TD"));
}
#[test]
fn test_diagrams_non_matching_lang_still_highlighted() {
let md = "```python\nprint('hi')\n```\n";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_diagrams(true);
let html = process_markdown(md, &options).unwrap();
assert!(html.contains("<code class=\"language-python\">"));
assert!(!html.contains("class=\"mermaid\""));
}
#[test]
fn test_diagrams_formerly_supported_langs_highlight_as_usual() {
let md = "```geojson\n{\"type\":\"Feature\"}\n```\n\n```stl\nsolid x\nendsolid x\n```\n";
let options = MarkdownOptions::new()
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_diagrams(true);
let html = process_markdown(md, &options).unwrap();
assert!(!html.contains("class=\"mermaid\""));
assert!(!html.contains("mdx-diagram"));
}
#[test]
fn test_plain_text_basic() {
let md = "# Hello World\n\nA **bold** paragraph.";
let text = process_markdown_to_plain_text(
md,
&MarkdownOptions::default(),
)
.unwrap();
assert_eq!(text, "Hello World A bold paragraph.");
}
#[test]
fn test_plain_text_lists_and_code() {
let md =
"# Title\n\nDesc.\n\n- one\n- two\n\n```\nfn main() {}\n```";
let text = process_markdown_to_plain_text(
md,
&MarkdownOptions::default(),
)
.unwrap();
assert!(text.contains("Title"));
assert!(text.contains("Desc."));
assert!(text.contains("one"));
assert!(text.contains("two"));
assert!(text.contains("fn main() {}"));
assert!(!text.contains("Titleone"));
assert!(!text.contains("onetwo"));
}
#[test]
fn test_plain_text_strips_html() {
let md = "A <strong>bold</strong> word";
let text = process_markdown_to_plain_text(
md,
&MarkdownOptions::default(),
)
.unwrap();
assert!(!text.contains("<strong>"));
assert!(!text.contains("</strong>"));
assert!(text.contains("bold"));
}
#[test]
fn test_plain_text_respects_input_cap() {
let options = MarkdownOptions::new().with_max_input_size(8);
let err =
process_markdown_to_plain_text(&"a".repeat(64), &options)
.unwrap_err();
assert!(matches!(err, MarkdownError::InputTooLarge { .. }));
}
#[test]
fn test_plain_text_includes_inline_code() {
let md = "Use `println!` to print, then `drop`.";
let text = process_markdown_to_plain_text(
md,
&MarkdownOptions::default(),
)
.unwrap();
assert!(
text.contains("println!"),
"expected inline code literal, got: {text:?}"
);
assert!(
text.contains("drop"),
"expected second inline code literal, got: {text:?}"
);
}
#[test]
fn test_math_dollars_survives_sanitizer() {
let mut comrak = Options::default();
comrak.extension.math_dollars = true;
let options = MarkdownOptions::new()
.with_comrak_options(comrak)
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_syntax_highlighting(false)
.with_unsafe_html(false);
let html =
process_markdown("Inline $a^2 + b^2$ math.", &options)
.unwrap();
assert!(
html.contains("data-math-style"),
"data-math-style attribute stripped by sanitizer: {html}"
);
}
#[test]
fn test_footnote_link_survives_sanitizer() {
let mut comrak = Options::default();
comrak.extension.footnotes = true;
let options = MarkdownOptions::new()
.with_comrak_options(comrak)
.with_custom_blocks(false)
.with_enhanced_tables(false)
.with_syntax_highlighting(false)
.with_unsafe_html(false);
let md = "Claim[^1].\n\n[^1]: Reason.\n";
let html = process_markdown(md, &options).unwrap();
assert!(html.contains("<sup"), "missing <sup>: {html}");
assert!(
html.contains("href=\"#fn-1\"")
|| html.contains("href=\"#fn1\""),
"missing forward link: {html}"
);
assert!(
html.contains("href=\"#fnref-1\"")
|| html.contains("href=\"#fnref1\""),
"missing back-reference link: {html}"
);
}
}