use base64::{Engine as _, engine::general_purpose};
use once_cell::sync::Lazy;
use regex::{Captures, Regex};
use std::collections::HashSet;
use super::{escape_html_text, render_args_as_data, render_math_html, render_popover_html};
use crate::extensions::decoration_values::{map_color_value_with_options, map_font_size_value};
fn html_entities() -> HashSet<&'static str> {
[
"lt", "gt", "amp", "nbsp", "quot", "apos", "ndash", "mdash", "hellip", "copy", "reg",
"trade", "times", "divide", "plusmn", "le", "ge", "ne", "asymp", "equiv", "forall",
"exist", "empty", "nabla", "isin", "notin", "ni", "prod", "sum", "minus", "lowast",
"radic", "prop", "infin", "ang", "and", "or", "cap", "cup", "int", "there4", "sim", "cong",
"sub", "sup", "nsub", "sube", "supe", "oplus", "otimes", "perp", "sdot", "lceil", "rceil",
"lfloor", "rfloor", "lang", "rang", "loz", "spades", "clubs", "hearts", "diams", "alpha",
"beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa", "lambda",
"mu", "nu", "xi", "omicron", "pi", "rho", "sigma", "tau", "upsilon", "phi", "chi", "psi",
"omega", "Iuml", "iuml", "Uuml", "uuml", "Auml", "auml", "Ouml", "ouml", "Euml", "euml",
"Aring", "aring", "AElig", "aelig", "Ccedil", "ccedil", "Eth", "eth", "Ntilde", "ntilde",
"Oslash", "oslash", "Thorn", "thorn", "szlig", "yuml", "Agrave", "agrave", "Aacute",
"aacute", "Acirc", "acirc", "Atilde", "atilde", "Egrave", "egrave", "Eacute", "eacute",
"Ecirc", "ecirc", "Igrave", "igrave", "Iacute", "iacute", "Icirc", "icirc", "Ograve",
"ograve", "Oacute", "oacute", "Ocirc", "ocirc", "Otilde", "otilde", "Ugrave", "ugrave",
"Uacute", "uacute", "Ucirc", "ucirc", "Yacute", "yacute", "cent", "pound", "curren", "yen",
"brvbar", "sect", "uml", "ordf", "laquo", "not", "shy", "macr", "deg", "sup2", "sup3",
"acute", "micro", "para", "middot", "cedil", "sup1", "ordm", "raquo", "frac14", "frac12",
"frac34", "iquest", "ensp", "emsp", "thinsp", "zwnj", "zwj", "lrm", "rlm",
]
.iter()
.copied()
.collect()
}
fn parse_inline_plugin_at(
chars: &[char],
start: usize,
entities: &HashSet<&'static str>,
) -> Option<(usize, String)> {
debug_assert_eq!(chars[start], '&');
let mut i = start + 1;
let name_start = i;
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
i += 1;
}
if i == name_start {
return None;
}
let function: String = chars[name_start..i].iter().collect();
match chars.get(i) {
Some('(') => {
let paren_close = super::scan_balanced(chars, i, '(', ')')?;
let args: String = chars[i + 1..paren_close].iter().collect();
match chars.get(paren_close + 1) {
Some('{') => {
let content_close = super::scan_balanced(chars, paren_close + 1, '{', '}')?;
if chars.get(content_close + 1) != Some(&';') {
return None;
}
let content: String = chars[paren_close + 2..content_close].iter().collect();
let encoded_content = general_purpose::STANDARD.encode(content.as_bytes());
let marker = format!(
"{{{{INLINE_PLUGIN:{}:{}:{}:INLINE_PLUGIN}}}}",
function, args, encoded_content
);
Some((content_close + 2, marker))
}
Some(';') => {
let marker = format!(
"{{{{INLINE_PLUGIN_ARGSONLY:{}:{}:INLINE_PLUGIN_ARGSONLY}}}}",
function, args
);
Some((paren_close + 2, marker))
}
_ => None,
}
}
Some('{') => {
let content_close = super::scan_balanced(chars, i, '{', '}')?;
if chars.get(content_close + 1) != Some(&';') {
return None;
}
let content: String = chars[i + 1..content_close].iter().collect();
let encoded_content = general_purpose::STANDARD.encode(content.as_bytes());
let marker = format!(
"{{{{INLINE_PLUGIN:{}::{}:INLINE_PLUGIN}}}}",
function, encoded_content
);
Some((content_close + 2, marker))
}
Some(';') => {
if !function
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
{
return None;
}
if entities.contains(function.as_str()) {
return None;
}
let marker = format!(
"{{{{INLINE_PLUGIN_NOARGS:{}:INLINE_PLUGIN_NOARGS}}}}",
function
);
Some((i + 1, marker))
}
_ => None,
}
}
pub fn protect_inline_plugins(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
let entities = html_entities();
let mut result = String::with_capacity(input.len());
let mut i = 0;
while i < chars.len() {
if chars[i] == '&' {
if let Some((next_i, marker)) = parse_inline_plugin_at(&chars, i, &entities) {
result.push_str(&marker);
i = next_i;
continue;
}
}
result.push(chars[i]);
i += 1;
}
result
}
fn convert_standard_inline_plugin_to_html(
function: &str,
args: &str,
content: &str,
allow_hex_colors: bool,
allow_custom_font_size: bool,
) -> Option<String> {
match function {
"dfn" => Some(format!("<dfn>{}</dfn>", content)),
"kbd" => Some(format!("<kbd>{}</kbd>", content)),
"samp" => Some(format!("<samp>{}</samp>", content)),
"var" => Some(format!("<var>{}</var>", content)),
"cite" => Some(format!("<cite>{}</cite>", content)),
"q" => Some(format!("<q>{}</q>", content)),
"small" => Some(format!("<small>{}</small>", content)),
"u" => Some(format!("<u>{}</u>", content)),
"bdi" => Some(format!("<bdi>{}</bdi>", content)),
"spoiler" => {
Some(format!(
r#"<span class="umd-spoiler" role="button" tabindex="0" aria-expanded="false">{}</span>"#,
content
))
}
"ruby" => {
Some(format!(
"<ruby>{}<rp>(</rp><rt>{}</rt><rp>)</rp></ruby>",
content, args
))
}
"time" => {
Some(format!("<time datetime=\"{}\">{}</time>", args, content))
}
"data" => {
Some(format!("<data value=\"{}\">{}</data>", args, content))
}
"bdo" => {
Some(format!("<bdo dir=\"{}\">{}</bdo>", args, content))
}
"lang" => {
Some(format!("<span lang=\"{}\">{}</span>", args, content))
}
"abbr" => {
Some(format!("<abbr title=\"{}\">{}</abbr>", content, args))
}
"sup" => {
Some(format!("<sup>{}</sup>", args))
}
"sub" => {
Some(format!("<sub>{}</sub>", args))
}
"color" => {
let parts: Vec<&str> = args.split(',').collect();
let fg = parts.get(0).map_or("", |m| m.trim());
let bg = parts.get(1).map_or("", |m| m.trim());
let mut classes = Vec::new();
let mut styles = Vec::new();
if !fg.is_empty() && fg != "inherit" {
if let Some((is_class, value)) =
map_color_value_with_options(fg, false, allow_hex_colors)
{
if is_class {
classes.push(value);
} else {
styles.push(format!("color: {}", value));
}
}
}
if !bg.is_empty() && bg != "inherit" {
if let Some((is_class, value)) =
map_color_value_with_options(bg, true, allow_hex_colors)
{
if is_class {
classes.push(value);
} else {
styles.push(format!("background-color: {}", value));
}
}
}
if classes.is_empty() && styles.is_empty() {
Some(content.to_string())
} else {
let mut attrs = Vec::new();
if !classes.is_empty() {
attrs.push(format!("class=\"{}\"", classes.join(" ")));
}
if !styles.is_empty() {
attrs.push(format!("style=\"{}\"", styles.join("; ")));
}
Some(format!("<span {}>{}</span>", attrs.join(" "), content))
}
}
"size" => {
match map_font_size_value(args, allow_custom_font_size) {
Some((true, class)) => {
Some(format!("<span class=\"{}\">{}</span>", class, content))
}
Some((false, value)) => Some(format!(
"<span style=\"font-size: {}\">{}</span>",
value, content
)),
None => Some(content.to_string()),
}
}
_ => None,
}
}
fn convert_standard_inline_plugin_argsonly_to_html(function: &str, args: &str) -> Option<String> {
match function {
"sup" => Some(format!("<sup>{}</sup>", args)),
"sub" => Some(format!("<sub>{}</sub>", args)),
"math" => render_math_html(args, false),
"spoiler" => {
Some(format!(
r#"<span class="umd-spoiler" role="button" tabindex="0" aria-expanded="false">{}</span>"#,
args
))
}
_ => None,
}
}
fn convert_standard_inline_plugin_noargs_to_html(function: &str) -> Option<String> {
match function {
"wbr" => Some("<wbr />".to_string()),
"br" => Some("<br />".to_string()),
_ => None,
}
}
pub(crate) fn restore_markers(
html: &str,
allow_hex_colors: bool,
allow_custom_font_size: bool,
) -> String {
let mut result = html.to_string();
let inline_plugin_marker =
Regex::new(r"\{\{INLINE_PLUGIN:(\w+):([\s\S]*?):([\s\S]*?):INLINE_PLUGIN\}\}").unwrap();
result = inline_plugin_marker
.replace_all(&result, |caps: &Captures| {
let function = &caps[1];
let args = &caps[2];
let encoded_content = &caps[3];
let content = general_purpose::STANDARD
.decode(encoded_content.as_bytes())
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.unwrap_or_else(|| encoded_content.to_string());
if function == "math" {
let formula = if content.trim().is_empty() {
args
} else {
&content
};
if let Some(mathml) = render_math_html(formula, false) {
return mathml;
}
}
if function == "popover" {
return render_popover_html(args, &content);
}
if let Some(html) = convert_standard_inline_plugin_to_html(
function,
args,
&content,
allow_hex_colors,
allow_custom_font_size,
) {
return html;
}
let args_html = render_args_as_data(args);
let escaped_content = escape_html_text(&content);
if escaped_content.is_empty() {
format!(
"<template class=\"umd-plugin umd-plugin-inline umd-plugin-{}\">{}</template>",
function, args_html
)
} else {
format!(
"<template class=\"umd-plugin umd-plugin-inline umd-plugin-{}\">{}{}</template>",
function, args_html, escaped_content
)
}
})
.to_string();
let inline_plugin_argsonly_marker =
Regex::new(r"\{\{INLINE_PLUGIN_ARGSONLY:(\w+):([\s\S]*?):INLINE_PLUGIN_ARGSONLY\}\}")
.unwrap();
result = inline_plugin_argsonly_marker
.replace_all(&result, |caps: &Captures| {
let function = &caps[1];
let args = &caps[2];
if let Some(html) = convert_standard_inline_plugin_argsonly_to_html(function, args) {
return html;
}
let args_html = render_args_as_data(args);
format!(
"<template class=\"umd-plugin umd-plugin-inline umd-plugin-{}\">{}</template>",
function, args_html
)
})
.to_string();
let inline_plugin_noargs_marker =
Regex::new(r"\{\{INLINE_PLUGIN_NOARGS:(\w+):INLINE_PLUGIN_NOARGS\}\}").unwrap();
result = inline_plugin_noargs_marker
.replace_all(&result, |caps: &Captures| {
let function = &caps[1];
if let Some(html) = convert_standard_inline_plugin_noargs_to_html(function) {
return html;
}
format!(
"<template class=\"umd-plugin umd-plugin-inline umd-plugin-{}\"></template>",
function
)
})
.to_string();
result
}
static INLINE_COLOR: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&color\(([^,)]*?)(?:,([^)]*?))?\)\{([^}]+?)\};").unwrap());
static INLINE_SIZE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&size\(([^)]+?)\)\{([^}]+?)\};").unwrap());
static INLINE_SUP: Lazy<Regex> = Lazy::new(|| Regex::new(r"&sup\(([^)]+?)\);").unwrap());
static INLINE_SUB: Lazy<Regex> = Lazy::new(|| Regex::new(r"&sub\(([^)]+?)\);").unwrap());
static INLINE_LANG: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&lang\(([^)]+?)\)\{([^}]+?)\};").unwrap());
static INLINE_ABBR: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&abbr\(([^)]+?)\)\{([^}]+?)\};").unwrap());
static INLINE_RUBY: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&ruby\(([^)]+?)\)\{([^}]+?)\};").unwrap());
static INLINE_DFN: Lazy<Regex> = Lazy::new(|| Regex::new(r"&dfn\(([^)]+?)\);").unwrap());
static INLINE_KBD: Lazy<Regex> = Lazy::new(|| Regex::new(r"&kbd\(([^)]+?)\);").unwrap());
static INLINE_SAMP: Lazy<Regex> = Lazy::new(|| Regex::new(r"&samp\(([^)]+?)\);").unwrap());
static INLINE_VAR: Lazy<Regex> = Lazy::new(|| Regex::new(r"&var\(([^)]+?)\);").unwrap());
static INLINE_CITE: Lazy<Regex> = Lazy::new(|| Regex::new(r"&cite\(([^)]+?)\);").unwrap());
static INLINE_Q: Lazy<Regex> = Lazy::new(|| Regex::new(r"&q\(([^)]+?)\);").unwrap());
static INLINE_SMALL: Lazy<Regex> = Lazy::new(|| Regex::new(r"&small\(([^)]+?)\);").unwrap());
static INLINE_TIME: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&time\(([^)]+?)\)\{([^}]+?)\};").unwrap());
static INLINE_DATA: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&data\(([^)]+?)\)\{([^}]+?)\};").unwrap());
static INLINE_BDI: Lazy<Regex> = Lazy::new(|| Regex::new(r"&bdi\(([^)]+?)\);").unwrap());
static INLINE_BDO: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&bdo\(([^)]+?)\)\{([^}]+?)\};").unwrap());
static INLINE_WBR: Lazy<Regex> = Lazy::new(|| Regex::new(r"&wbr;").unwrap());
static INLINE_BR: Lazy<Regex> = Lazy::new(|| Regex::new(r"&br;").unwrap());
static INLINE_SPOILER: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&spoiler(?:\(([^)]+?)\)|\{([^}]+?)\});").unwrap());
pub(crate) fn prepare(html: &str, max_inline_nesting: Option<usize>) -> String {
let mut result = html.to_string();
result = result.replace("&color(", "&color(");
result = result.replace("&size(", "&size(");
result = result.replace("&sup(", "&sup(");
result = result.replace("&sub(", "&sub(");
result = result.replace("&lang(", "&lang(");
result = result.replace("&abbr(", "&abbr(");
result = result.replace("&ruby(", "&ruby(");
result = result.replace("&spoiler(", "&spoiler(");
result = result.replace("&spoiler{", "&spoiler{");
result = result.replace("&dfn(", "&dfn(");
result = result.replace("&kbd(", "&kbd(");
result = result.replace("&samp(", "&samp(");
result = result.replace("&var(", "&var(");
result = result.replace("&cite(", "&cite(");
result = result.replace("&q(", "&q(");
result = result.replace("&small(", "&small(");
result = result.replace("&time(", "&time(");
result = result.replace("&data(", "&data(");
result = result.replace("&bdi(", "&bdi(");
result = result.replace("&bdo(", "&bdo(");
result = result.replace("&wbr", "&wbr");
result = result.replace("&br", "&br");
if let Some(limit) = max_inline_nesting.filter(|limit| *limit > 0) {
if inline_decoration_nesting_depth(&result) > limit {
result = neutralize_over_limit_inline_decorations(&result, limit);
}
}
result
}
pub(crate) fn expand(html: &str, allow_hex_colors: bool, allow_custom_font_size: bool) -> String {
let mut result = html.to_string();
result = INLINE_SPOILER
.replace_all(&result, |caps: ®ex::Captures| {
let text = caps.get(1).or_else(|| caps.get(2)).map_or("", |m| m.as_str());
format!(r#"<span class="umd-spoiler" role="button" tabindex="0" aria-expanded="false">{}</span>"#, text)
})
.to_string();
result = INLINE_COLOR
.replace_all(&result, |caps: ®ex::Captures| {
let fg = caps.get(1).map_or("", |m| m.as_str().trim());
let bg = caps.get(2).map_or("", |m| m.as_str().trim());
let text = caps.get(3).map_or("", |m| m.as_str());
let mut classes = Vec::new();
let mut styles = Vec::new();
if !fg.is_empty() && fg != "inherit" {
if let Some((is_class, value)) =
map_color_value_with_options(fg, false, allow_hex_colors)
{
if is_class {
classes.push(value);
} else {
styles.push(format!("color: {}", value));
}
}
}
if !bg.is_empty() && bg != "inherit" {
if let Some((is_class, value)) =
map_color_value_with_options(bg, true, allow_hex_colors)
{
if is_class {
classes.push(value);
} else {
styles.push(format!("background-color: {}", value));
}
}
}
if classes.is_empty() && styles.is_empty() {
text.to_string()
} else {
let mut attrs = Vec::new();
if !classes.is_empty() {
attrs.push(format!("class=\"{}\"", classes.join(" ")));
}
if !styles.is_empty() {
attrs.push(format!("style=\"{}\"", styles.join("; ")));
}
format!("<span {}>{}</span>", attrs.join(" "), text)
}
})
.to_string();
result = INLINE_SIZE
.replace_all(&result, |caps: ®ex::Captures| {
let size = caps.get(1).map_or("", |m| m.as_str());
let text = caps.get(2).map_or("", |m| m.as_str());
match map_font_size_value(size, allow_custom_font_size) {
Some((true, class)) => format!("<span class=\"{}\">{}</span>", class, text),
Some((false, value)) => {
format!("<span style=\"font-size: {}\">{}</span>", value, text)
}
None => text.to_string(),
}
})
.to_string();
result = INLINE_SUP
.replace_all(&result, "<sup>$1</sup>;")
.to_string();
result = INLINE_SUB
.replace_all(&result, "<sub>$1</sub>;")
.to_string();
result = INLINE_LANG
.replace_all(&result, "<span lang=\"$1\">$2</span>;")
.to_string();
result = INLINE_ABBR
.replace_all(&result, "<abbr title=\"$2\">$1</abbr>;")
.to_string();
result = INLINE_RUBY
.replace_all(&result, "<ruby>$2<rp>(</rp><rt>$1</rt><rp>)</rp></ruby>;")
.to_string();
result = INLINE_DFN
.replace_all(&result, "<dfn>$1</dfn>;")
.to_string();
result = INLINE_KBD
.replace_all(&result, "<kbd>$1</kbd>;")
.to_string();
result = INLINE_SAMP
.replace_all(&result, "<samp>$1</samp>;")
.to_string();
result = INLINE_VAR
.replace_all(&result, "<var>$1</var>;")
.to_string();
result = INLINE_CITE
.replace_all(&result, "<cite>$1</cite>;")
.to_string();
result = INLINE_Q.replace_all(&result, "<q>$1</q>;").to_string();
result = INLINE_SMALL
.replace_all(&result, "<small>$1</small>;")
.to_string();
result = INLINE_TIME
.replace_all(&result, "<time datetime=\"$1\">$2</time>;")
.to_string();
result = INLINE_DATA
.replace_all(&result, "<data value=\"$1\">$2</data>;")
.to_string();
result = INLINE_BDI
.replace_all(&result, "<bdi>$1</bdi>;")
.to_string();
result = INLINE_BDO
.replace_all(&result, "<bdo dir=\"$1\">$2</bdo>;")
.to_string();
result = INLINE_WBR.replace_all(&result, "<wbr />").to_string();
result = INLINE_BR.replace_all(&result, "<br />").to_string();
result
}
fn inline_decoration_nesting_depth(input: &str) -> usize {
let bytes = input.as_bytes();
let mut i = 0usize;
let mut stack: Vec<bool> = Vec::new();
let mut limited_depth = 0usize;
let mut max_limited_depth = 0usize;
while i < bytes.len() {
if let Some((name, next_index)) = parse_inline_block_start(input, i) {
let limited = is_limited_inline_name(name);
stack.push(limited);
if limited {
limited_depth += 1;
max_limited_depth = max_limited_depth.max(limited_depth);
}
i = next_index;
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'}' && bytes[i + 1] == b';' && !stack.is_empty() {
if stack.pop().unwrap_or(false) {
limited_depth = limited_depth.saturating_sub(1);
}
i += 2;
continue;
}
i += 1;
}
max_limited_depth
}
fn neutralize_over_limit_inline_decorations(input: &str, max_inline_nesting: usize) -> String {
let bytes = input.as_bytes();
let mut i = 0usize;
let mut stack: Vec<bool> = Vec::new();
let mut limited_depth = 0usize;
let mut output = String::with_capacity(input.len() + 16);
while i < bytes.len() {
if let Some((name, next_index)) = parse_inline_block_start(input, i) {
let limited = is_limited_inline_name(name);
let over_limit = limited && limited_depth >= max_inline_nesting;
if over_limit {
if let Some(end_index) = find_inline_block_end(input, i) {
let escaped = html_escape::encode_safe(&input[i..end_index])
.replace('{', "{")
.replace('}', "}");
output.push_str(r#"<span class="umd-error-deep-recursive">"#);
output.push_str(&escaped);
output.push_str("</span>");
i = end_index;
continue;
}
let escaped = html_escape::encode_safe("&");
output.push_str(escaped.as_ref());
i += 1;
continue;
}
output.push_str(&input[i..next_index]);
stack.push(limited);
if limited {
limited_depth += 1;
}
i = next_index;
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'}' && bytes[i + 1] == b';' && !stack.is_empty() {
if stack.pop().unwrap_or(false) {
limited_depth = limited_depth.saturating_sub(1);
}
}
let ch = input[i..]
.chars()
.next()
.expect("valid UTF-8 char boundary");
output.push(ch);
i += ch.len_utf8();
}
output
}
fn find_inline_block_end(input: &str, start: usize) -> Option<usize> {
let mut i = parse_inline_block_start(input, start)?.1;
let bytes = input.as_bytes();
let mut depth = 1usize;
while i < bytes.len() {
if parse_inline_block_start(input, i).is_some() {
depth += 1;
i = parse_inline_block_start(input, i)?.1;
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'}' && bytes[i + 1] == b';' {
depth -= 1;
i += 2;
if depth == 0 {
return Some(i);
}
continue;
}
let ch = input[i..].chars().next()?;
i += ch.len_utf8();
}
None
}
fn parse_inline_block_start(input: &str, start: usize) -> Option<(&str, usize)> {
let bytes = input.as_bytes();
if bytes.get(start).copied() != Some(b'&') {
return None;
}
let mut j = start + 1;
while j < bytes.len() && bytes[j].is_ascii_alphabetic() {
j += 1;
}
if j <= start + 1 {
return None;
}
let name = &input[start + 1..j];
if bytes.get(j).copied() == Some(b'{') {
return Some((name, j + 1));
}
if bytes.get(j).copied() != Some(b'(') {
return None;
}
let mut k = j + 1;
while k < bytes.len() && bytes[k] != b')' {
k += 1;
}
if k < bytes.len() && bytes.get(k + 1).copied() == Some(b'{') {
return Some((name, k + 2));
}
None
}
fn is_limited_inline_name(name: &str) -> bool {
matches!(
name,
"color" | "size" | "lang" | "abbr" | "ruby" | "time" | "data" | "bdo" | "spoiler"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn apply_second_pass(
html: &str,
max_inline_nesting: Option<usize>,
allow_hex_colors: bool,
allow_custom_font_size: bool,
) -> String {
let prepared = prepare(html, max_inline_nesting);
expand(&prepared, allow_hex_colors, allow_custom_font_size)
}
fn apply(html: &str) -> String {
apply_second_pass(html, Some(5), false, false)
}
#[test]
fn test_protect_inline_plugin_with_content() {
let input = "&test{content};";
let output = protect_inline_plugins(input);
assert!(output.contains("INLINE_PLUGIN:test::"));
assert!(!output.contains("&test"));
}
#[test]
fn test_protect_inline_plugin_with_args_and_content() {
let input = "&test(arg1,arg2){content};";
let output = protect_inline_plugins(input);
assert!(output.contains("INLINE_PLUGIN:test:arg1,arg2:"));
}
#[test]
fn test_skip_html_entities() {
let input = "< > &";
let output = protect_inline_plugins(input);
assert_eq!(input, output); }
#[test]
fn test_protect_inline_plugin_deeply_nested_content() {
let input = "&color(blue){OUTER &abbr(text){MIDDLE &size(lg){INNER};};};";
let output = protect_inline_plugins(input);
assert!(output.starts_with("{{INLINE_PLUGIN:color:blue:"));
assert!(output.ends_with(":INLINE_PLUGIN}}"));
assert_eq!(output.matches("INLINE_PLUGIN:").count(), 1);
let encoded = output
.trim_start_matches("{{INLINE_PLUGIN:color:blue:")
.trim_end_matches(":INLINE_PLUGIN}}");
let decoded = String::from_utf8(
general_purpose::STANDARD.decode(encoded).unwrap(),
)
.unwrap();
assert_eq!(decoded, "OUTER &abbr(text){MIDDLE &size(lg){INNER};};");
}
#[test]
fn test_protect_inline_plugin_args_with_markdown_link() {
let input = "&abbr([HTML](exp.html)){Hyper Text Markup Language};";
let output = protect_inline_plugins(input);
assert!(
output.contains("INLINE_PLUGIN:abbr:[HTML](exp.html):"),
"expected full args with the nested link parens preserved, got: {}",
output
);
}
#[test]
fn test_protect_inline_plugin_argsonly_with_nested_plugin_in_args() {
let input =
"&small(The quick &color(brown){brown}; fox *jumps* over the lazy __dog__);";
let output = protect_inline_plugins(input);
assert_eq!(
output,
"{{INLINE_PLUGIN_ARGSONLY:small:The quick &color(brown){brown}; fox *jumps* over the lazy __dog__:INLINE_PLUGIN_ARGSONLY}}"
);
}
#[test]
fn test_protect_inline_plugin_inside_markdown_link_text() {
let input = "[&color(red){Link};](url)";
let output = protect_inline_plugins(input);
assert!(output.starts_with('['));
assert!(output.ends_with("](url)"));
assert!(output.contains("INLINE_PLUGIN:color:red:"));
}
#[test]
fn test_protect_inline_plugin_unbalanced_is_left_literal() {
let input = "&color(red){unterminated";
let output = protect_inline_plugins(input);
assert_eq!(output, input);
}
#[test]
fn test_inline_color_foreground() {
let input = "This is &color(red){red text};";
let output = apply(input);
assert!(output.contains(r#"<span class="umd-color-red">red text</span>"#));
}
#[test]
fn test_inline_color_background() {
let input = "&color(,yellow){yellow bg};";
let output = apply(input);
assert!(output.contains(r#"<span class="umd-bg-yellow">yellow bg</span>"#));
}
#[test]
fn test_inline_color_both() {
let input = "&color(cyan,yellow){cyan on yellow};";
let output = apply(input);
assert!(
output.contains(r#"class="umd-color-cyan umd-bg-yellow""#),
"Expected both colors as classes, got: {}",
output
);
}
#[test]
fn test_inline_color_invalid() {
let input = "&color(white,black){white on black};";
let output = apply(input);
assert_eq!(
output, "white on black",
"Invalid colors should be ignored, got: {}",
output
);
}
#[test]
fn test_inline_color_hex() {
let input = "&color(#FF5733){Custom hex color};";
let output = apply_second_pass(input, Some(5), true, false);
assert!(
output.contains(r#"style="color: #FF5733""#),
"Expected HEX color as inline style, got: {}",
output
);
}
#[test]
fn test_inline_size_keyword() {
let input = "&size(lg){larger};";
let output = apply(input);
assert!(output.contains("<span class=\"umd-text-size-lg\">larger</span>"));
}
#[test]
fn test_inline_size_custom_value_rejected_by_default() {
let input = "&size(1.5){larger};";
let output = apply(input);
assert_eq!(output, "larger");
}
#[test]
fn test_inline_size_custom_value_allowed_when_enabled() {
let input = "&size(1.5){larger};";
let output = apply_second_pass(input, Some(5), false, true);
assert!(output.contains("<span style=\"font-size: 1.5rem\">larger</span>"));
}
#[test]
fn test_inline_sup() {
let input = "x&sup(2);";
let output = apply(input);
assert_eq!(output, "x<sup>2</sup>;");
}
#[test]
fn test_inline_sub() {
let input = "H&sub(2);O";
let output = apply(input);
assert_eq!(output, "H<sub>2</sub>;O");
}
#[test]
fn test_inline_lang() {
let input = "&lang(en){Hello};";
let output = apply(input);
assert_eq!(output, "<span lang=\"en\">Hello</span>;");
}
#[test]
fn test_inline_abbr() {
let input = "&abbr(HTML){HyperText Markup Language};";
let output = apply(input);
assert_eq!(
output,
"<abbr title=\"HyperText Markup Language\">HTML</abbr>;"
);
}
#[test]
fn test_multiple_inline_decorations() {
let input = "&color(red){Red}; and &size(lg){Big}; and &sup(superscript);";
let output = apply(input);
assert!(output.contains("umd-color-red"));
assert!(output.contains("umd-text-size-lg"));
assert!(output.contains("<sup>superscript</sup>"));
}
#[test]
fn test_inline_ruby() {
let input = "&ruby(Ashita){明日};";
let output = apply(input);
assert_eq!(
output,
"<ruby>明日<rp>(</rp><rt>Ashita</rt><rp>)</rp></ruby>;"
);
}
#[test]
fn test_semantic_elements() {
let input = "&dfn(term); &kbd(Ctrl); &samp(output); &var(x);";
let output = apply(input);
assert!(output.contains("<dfn>term</dfn>"));
assert!(output.contains("<kbd>Ctrl</kbd>"));
assert!(output.contains("<samp>output</samp>"));
assert!(output.contains("<var>x</var>"));
}
#[test]
fn test_cite_q_small() {
let input = "&cite(Book Title); &q(quote); &small(note);";
let output = apply(input);
assert!(output.contains("<cite>Book Title</cite>"));
assert!(output.contains("<q>quote</q>"));
assert!(output.contains("<small>note</small>"));
}
#[test]
fn test_time_and_data() {
let input = "&time(2026-01-26){today}; &data(12345){value};";
let output = apply(input);
assert!(output.contains("<time datetime=\"2026-01-26\">today</time>"));
assert!(output.contains("<data value=\"12345\">value</data>"));
}
#[test]
fn test_bidirectional_text() {
let input = "&bdi(مرحبا); &bdo(rtl){right-to-left};";
let output = apply(input);
assert!(output.contains("<bdi>مرحبا</bdi>"));
assert!(output.contains("<bdo dir=\"rtl\">right-to-left</bdo>"));
}
#[test]
fn test_wbr() {
let input = "Very&wbr;Long&wbr;Word";
let output = apply(input);
assert_eq!(output, "Very<wbr />Long<wbr />Word");
}
#[test]
fn test_br() {
let input = "Line 1&br;Line 2&br;Line 3";
let output = apply(input);
assert_eq!(output, "Line 1<br />Line 2<br />Line 3");
}
#[test]
fn test_inline_nesting_depth_detection() {
let input = "&color(blue){&abbr(text){content};};";
assert_eq!(inline_decoration_nesting_depth(input), 2);
}
#[test]
fn test_inline_nesting_depth_ignores_plugin_names() {
let input = "&color(blue){&plugin(arg){content};};";
assert_eq!(inline_decoration_nesting_depth(input), 1);
}
#[test]
fn test_inline_nesting_limit_blocks_expansion_when_exceeded() {
let input = "&color(blue){&abbr(text){content};};";
let output = apply_second_pass(input, Some(1), false, false);
assert!(output.contains(r#"<span class="umd-color-blue">"#));
assert!(output.contains(
r#"<span class="umd-error-deep-recursive">&abbr(text){content};</span>"#
));
assert!(!output.contains("<abbr"));
}
#[test]
fn test_inline_nesting_limit_disables_only_exceeded_marker() {
let input = "&color(red){ok}; and &color(blue){&abbr(text){content};};";
let output = apply_second_pass(input, Some(1), false, false);
assert!(output.contains(r#"<span class="umd-color-red">ok</span>"#));
assert!(output.contains(
r#"<span class="umd-error-deep-recursive">&abbr(text){content};</span>"#
));
assert!(!output.contains("<abbr title="));
}
#[test]
fn test_color_class() {
let input = "&color(blue){Blue text};";
let output = apply(input);
assert!(output.contains("class=\"umd-color-blue\""));
}
#[test]
fn test_color_custom_value() {
let input = "&color(#FF0000){Red text};";
let output = apply_second_pass(input, Some(5), true, false);
assert!(output.contains("style=\"color: #FF0000\""));
}
#[test]
fn test_spoiler_umd_function_parentheses() {
let input = "This is &spoiler(hidden text); in a sentence.";
let output = apply(input);
assert!(output.contains(r#"<span class="umd-spoiler" role="button" tabindex="0" aria-expanded="false">hidden text</span>"#));
}
#[test]
fn test_spoiler_umd_function_braces() {
let input = "This is &spoiler{hidden text}; in a sentence.";
let output = apply(input);
assert!(output.contains(r#"<span class="umd-spoiler" role="button" tabindex="0" aria-expanded="false">hidden text</span>"#));
}
#[test]
fn test_size_keyword_class() {
let input = "&size(sm){Medium text};";
let output = apply(input);
assert!(output.contains("class=\"umd-text-size-sm\""));
}
#[test]
fn test_size_custom_value_allowed_when_enabled() {
let input = "&size(3rem){Custom size};";
let output = apply_second_pass(input, Some(5), false, true);
assert!(output.contains("style=\"font-size: 3rem\""));
}
#[test]
fn test_multiple_spoilers_notation_and_plugin_combined() {
let input = "||spoiler1|| and ||spoiler2|| and &spoiler{spoiler3};";
let prepared = prepare(input, Some(5));
let with_notation = crate::extensions::inline::notation::apply(&prepared);
let output = expand(&with_notation, false, false);
let spoiler_count = output.matches(r#"<span class="umd-spoiler""#).count();
assert_eq!(spoiler_count, 3);
}
}