use base64::{Engine as _, engine::general_purpose};
use regex::{Captures, Regex};
use super::{escape_html_text, render_args_as_data, render_math_html, render_popover_html};
fn parse_block_plugin_at(chars: &[char], start: usize) -> 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();
if chars.get(i) != Some(&'(') {
return None;
}
let paren_close = super::scan_balanced(chars, i, '(', ')')?;
let args: String = chars[i + 1..paren_close].iter().collect();
let after_args = paren_close + 1;
if chars.get(after_args) == Some(&'{') && chars.get(after_args + 1) == Some(&'{') {
if let Some(content_close) = super::scan_balanced_double(chars, after_args) {
let content: String = chars[after_args + 2..content_close].iter().collect();
let encoded_content = general_purpose::STANDARD.encode(content.as_bytes());
let marker = format!(
"{{{{BLOCK_PLUGIN:{}:{}:{}:BLOCK_PLUGIN}}}}",
function, args, encoded_content
);
return Some((content_close + 2, marker));
}
}
if chars.get(after_args) == Some(&'{') {
if let Some(content_close) = super::scan_balanced(chars, after_args, '{', '}') {
let content: String = chars[after_args + 1..content_close].iter().collect();
let encoded_content = general_purpose::STANDARD.encode(content.as_bytes());
let marker = format!(
"{{{{BLOCK_PLUGIN:{}:{}:{}:BLOCK_PLUGIN}}}}",
function, args, encoded_content
);
return Some((content_close + 1, marker));
}
}
let encoded_args = general_purpose::STANDARD.encode(args.as_bytes());
let marker = format!(
"{{{{BLOCK_PLUGIN_ARGSONLY:{}:{}:BLOCK_PLUGIN_ARGSONLY}}}}",
function, encoded_args
);
Some((after_args, marker))
}
pub fn protect_block_plugins(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
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_block_plugin_at(&chars, i) {
result.push_str(&marker);
i = next_i;
continue;
}
}
result.push(chars[i]);
i += 1;
}
result
}
pub(crate) fn restore_markers(html: &str) -> String {
let mut result = html.to_string();
let block_plugin_marker =
Regex::new(r"\{\{BLOCK_PLUGIN:(\w+):([\s\S]*?):([\s\S]*?):BLOCK_PLUGIN\}\}").unwrap();
result = block_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, true) {
return mathml;
}
}
if function == "popover" {
return render_popover_html(args, &content);
}
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-block umd-plugin-{}\">{}</template>",
function, args_html
)
} else {
format!(
"<template class=\"umd-plugin umd-plugin-block umd-plugin-{}\">{}{}</template>",
function, args_html, escaped_content
)
}
})
.to_string();
let block_plugin_argsonly_marker =
Regex::new(r"\{\{BLOCK_PLUGIN_ARGSONLY:(\w+):([\s\S]*?):BLOCK_PLUGIN_ARGSONLY\}\}")
.unwrap();
result = block_plugin_argsonly_marker
.replace_all(&result, |caps: &Captures| {
let function = &caps[1];
let encoded_args = &caps[2];
let args = general_purpose::STANDARD
.decode(encoded_args.as_bytes())
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.unwrap_or_else(|| encoded_args.to_string());
if function == "clear" && args.trim().is_empty() {
return "<div class=\"clearfix\"></div>".to_string();
}
if function == "math" {
if let Some(mathml) = render_math_html(&args, true) {
return mathml;
}
}
let args_html = render_args_as_data(&args);
format!(
"<template class=\"umd-plugin umd-plugin-block umd-plugin-{}\">{}</template>",
function, args_html
)
})
.to_string();
result
}
pub(crate) fn render_fence_block_plugin(function: &str, args: &str, content: &str) -> String {
if function == "clear" && args.trim().is_empty() && content.trim().is_empty() {
return "<div class=\"clearfix\"></div>".to_string();
}
if function == "math" {
let formula = if content.trim().is_empty() {
args
} else {
content
};
if let Some(mathml) = render_math_html(formula, true) {
return mathml;
}
}
if function == "popover" {
return render_popover_html(args, content);
}
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-block umd-plugin-{}\">{}</template>",
function, args_html
)
} else {
format!(
"<template class=\"umd-plugin umd-plugin-block umd-plugin-{}\">{}{}</template>",
function, args_html, escaped_content
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_protect_block_plugin_multiline() {
let input = "@test(args){{ content }}";
let output = protect_block_plugins(input);
assert!(output.contains("BLOCK_PLUGIN:test:args:"));
}
#[test]
fn test_protect_block_plugin_single_line() {
let input = "@test(args){content}";
let output = protect_block_plugins(input);
assert!(output.contains("BLOCK_PLUGIN:test:args:"));
}
#[test]
fn test_protect_block_plugin_args_only() {
let input = "@test(args)";
let output = protect_block_plugins(input);
assert!(output.contains("BLOCK_PLUGIN_ARGSONLY:test:"));
}
#[test]
fn test_protect_block_plugin_nested_multiline() {
let input = "@tab(One){{ @tab-content(A){{ inner content }} more }}";
let output = protect_block_plugins(input);
assert!(output.starts_with("{{BLOCK_PLUGIN:tab:One:"));
assert!(output.ends_with(":BLOCK_PLUGIN}}"));
assert_eq!(output.matches("BLOCK_PLUGIN:").count(), 1);
let encoded = output
.trim_start_matches("{{BLOCK_PLUGIN:tab:One:")
.trim_end_matches(":BLOCK_PLUGIN}}");
let decoded = String::from_utf8(
general_purpose::STANDARD.decode(encoded).unwrap(),
)
.unwrap();
assert_eq!(decoded, " @tab-content(A){{ inner content }} more ");
}
#[test]
fn test_protect_block_plugin_sibling_multiline_blocks() {
let input = "@tab(One){{ first }}
@tab(Two){{ second }}";
let output = protect_block_plugins(input);
assert_eq!(output.matches("BLOCK_PLUGIN:").count(), 2);
assert!(output.contains("{{BLOCK_PLUGIN:tab:One:"));
assert!(output.contains("{{BLOCK_PLUGIN:tab:Two:"));
}
#[test]
fn test_protect_block_plugin_args_with_nested_parens() {
let input = "@cite([Source](https://example.com/)){{ content }}";
let output = protect_block_plugins(input);
assert!(
output.starts_with("{{BLOCK_PLUGIN:cite:[Source](https://example.com/):"),
"expected full args with nested link parens preserved, got: {}",
output
);
}
#[test]
fn test_protect_block_plugin_unclosed_content_falls_back_to_args_only() {
let input = "@tab(One){{ unterminated";
let output = protect_block_plugins(input);
assert_eq!(
output,
"{{BLOCK_PLUGIN_ARGSONLY:tab:T25l:BLOCK_PLUGIN_ARGSONLY}}{{ unterminated"
);
}
#[test]
fn test_protect_block_plugin_unclosed_args_is_left_literal() {
let input = "@tab(unterminated";
let output = protect_block_plugins(input);
assert_eq!(output, input);
}
#[test]
fn test_protect_block_plugin_no_bare_form() {
let input = "Email me @someone about this.";
let output = protect_block_plugins(input);
assert_eq!(output, input);
}
#[test]
fn test_restore_generic_template_fallback() {
let protected = protect_block_plugins("@youtube(dQw4w9WgXcQ)");
let output = restore_markers(&protected);
assert!(output.contains("<template class=\"umd-plugin umd-plugin-block umd-plugin-youtube\">"));
assert!(output.contains("<data value=\"0\">dQw4w9WgXcQ</data>"));
}
}