use once_cell::sync::Lazy;
use regex::Regex;
static INLINE_BADGE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&badge\(([^)]+?)\)\{([^}]+?)\};").unwrap());
static MARKDOWN_LINK: Lazy<Regex> = Lazy::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
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 LUKIWIKI_STRIKETHROUGH: Lazy<Regex> = Lazy::new(|| Regex::new(r"%%([^%]+)%%").unwrap());
static DISCORD_SPOILER: Lazy<Regex> = Lazy::new(|| Regex::new(r"\|\|([^|]+)\|\|").unwrap());
static INLINE_SPOILER: Lazy<Regex> =
Lazy::new(|| Regex::new(r"&spoiler(?:\(([^)]+?)\)|\{([^}]+?)\});").unwrap());
fn map_font_size(value: &str) -> (bool, String) {
if value.contains("rem") || value.contains("em") || value.contains("px") {
return (false, value.to_string()); }
let class = match value {
"2.5" => "fs-1",
"2" | "2.0" => "fs-2",
"1.75" => "fs-3",
"1.5" => "fs-4",
"1.25" => "fs-5",
"0.875" => "fs-6",
_ => return (false, format!("{}rem", value)), };
(true, class.to_string())
}
fn map_color(value: &str, is_background: bool) -> Option<(bool, String)> {
let trimmed = value.trim();
let bootstrap_colors = [
"primary",
"secondary",
"success",
"danger",
"warning",
"info",
"light",
"dark",
"body",
"body-secondary",
"body-tertiary",
"body-emphasis",
"blue",
"indigo",
"purple",
"pink",
"red",
"orange",
"yellow",
"green",
"teal",
"cyan",
"primary-subtle",
"secondary-subtle",
"success-subtle",
"danger-subtle",
"warning-subtle",
"info-subtle",
"light-subtle",
"dark-subtle",
"primary-emphasis",
"secondary-emphasis",
"success-emphasis",
"danger-emphasis",
"warning-emphasis",
"info-emphasis",
"light-emphasis",
"dark-emphasis",
"blue-subtle",
"indigo-subtle",
"purple-subtle",
"pink-subtle",
"red-subtle",
"orange-subtle",
"yellow-subtle",
"green-subtle",
"teal-subtle",
"cyan-subtle",
"blue-emphasis",
"indigo-emphasis",
"purple-emphasis",
"pink-emphasis",
"red-emphasis",
"orange-emphasis",
"yellow-emphasis",
"green-emphasis",
"teal-emphasis",
"cyan-emphasis",
];
for color in &bootstrap_colors {
if trimmed == *color || trimmed.starts_with(&format!("{}-", color)) {
let prefix = if is_background { "bg" } else { "text" };
return Some((true, format!("{}-{}", prefix, trimmed)));
}
}
if trimmed.starts_with('#') && (trimmed.len() == 4 || trimmed.len() == 7) {
if trimmed[1..].chars().all(|c| c.is_ascii_hexdigit()) {
return Some((false, trimmed.to_string()));
}
}
None
}
fn map_badge_type(badge_type: &str) -> String {
if badge_type.ends_with("-pill") {
let color = badge_type.trim_end_matches("-pill");
format!("badge rounded-pill bg-{}", color)
} else {
format!("badge bg-{}", badge_type)
}
}
pub fn apply_inline_decorations(html: &str) -> String {
let mut result = html.to_string();
result = result.replace("&color(", "&color(");
result = result.replace("&badge(", "&badge(");
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");
result = LUKIWIKI_STRIKETHROUGH
.replace_all(&result, "<s>$1</s>")
.to_string();
result = DISCORD_SPOILER
.replace_all(
&result,
r#"<span class="spoiler" role="button" tabindex="0" aria-expanded="false">$1</span>"#,
)
.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="spoiler" role="button" tabindex="0" aria-expanded="false">{}</span>"#, text)
})
.to_string();
result = INLINE_BADGE
.replace_all(&result, |caps: ®ex::Captures| {
let badge_type = caps.get(1).map_or("", |m| m.as_str());
let content = caps.get(2).map_or("", |m| m.as_str());
let badge_class = map_badge_type(badge_type);
if let Some(link_caps) = MARKDOWN_LINK.captures(content) {
let text = link_caps.get(1).map_or("", |m| m.as_str());
let url = link_caps.get(2).map_or("", |m| m.as_str());
format!("<a href=\"{}\" class=\"{}\">{}</a>", url, badge_class, text)
} else {
format!("<span class=\"{}\">{}</span>", badge_class, content)
}
})
.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(fg, false) {
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(bg, true) {
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());
let (is_class, value) = map_font_size(size);
if is_class {
format!("<span class=\"{}\">{}</span>", value, text)
} else {
format!("<span style=\"font-size: {}\">{}</span>", value, text)
}
})
.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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_color_blue() {
let result = map_color("blue", false);
assert!(result.is_some(), "blue should be recognized as a valid color");
let (is_class, class_or_style) = result.unwrap();
assert!(is_class, "blue should be recognized as a Bootstrap class");
assert_eq!(class_or_style, "text-blue", "Expected text-blue, got {}", class_or_style);
}
#[test]
fn test_map_color_hex() {
let result = map_color("#FF5733", false);
assert!(result.is_some(), "#FF5733 should be recognized as a valid HEX color");
let (is_class, value) = result.unwrap();
assert!(!is_class, "HEX color should not be a Bootstrap class");
assert_eq!(value, "#FF5733", "Expected #FF5733, got {}", value);
}
#[test]
fn test_map_color_invalid_html_name() {
let result = map_color("white", false);
assert!(result.is_none(), "HTML color name 'white' should be rejected");
let result = map_color("black", false);
assert!(result.is_none(), "HTML color name 'black' should be rejected");
}
#[test]
fn test_inline_color_foreground() {
let input = "This is &color(red){red text};";
let output = apply_inline_decorations(input);
assert!(output.contains(r#"<span class="text-red">red text</span>"#));
}
#[test]
fn test_inline_color_background() {
let input = "&color(,yellow){yellow bg};";
let output = apply_inline_decorations(input);
assert!(output.contains(r#"<span class="bg-yellow">yellow bg</span>"#));
}
#[test]
fn test_inline_color_both() {
let input = "&color(cyan,yellow){cyan on yellow};";
let output = apply_inline_decorations(input);
assert!(output.contains(r#"class="text-cyan 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_inline_decorations(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_inline_decorations(input);
assert!(output.contains(r#"style="color: #FF5733""#), "Expected HEX color as inline style, got: {}", output);
}
#[test]
fn test_inline_size() {
let input = "&size(1.5){larger};";
let output = apply_inline_decorations(input);
assert!(output.contains("<span class=\"fs-4\">larger</span>"));
}
#[test]
fn test_inline_sup() {
let input = "x&sup(2);";
let output = apply_inline_decorations(input);
assert_eq!(output, "x<sup>2</sup>;");
}
#[test]
fn test_inline_sub() {
let input = "H&sub(2);O";
let output = apply_inline_decorations(input);
assert_eq!(output, "H<sub>2</sub>;O");
}
#[test]
fn test_inline_lang() {
let input = "&lang(en){Hello};";
let output = apply_inline_decorations(input);
assert_eq!(output, "<span lang=\"en\">Hello</span>;");
}
#[test]
fn test_inline_abbr() {
let input = "&abbr(HTML){HyperText Markup Language};";
let output = apply_inline_decorations(input);
assert_eq!(
output,
"<abbr title=\"HyperText Markup Language\">HTML</abbr>;"
);
}
#[test]
fn test_multiple_inline_decorations() {
let input = "&color(red){Red}; and &size(2){Big}; and &sup(superscript);";
let output = apply_inline_decorations(input);
assert!(output.contains(&"text-red"));
assert!(output.contains("fs-2"));
assert!(output.contains("<sup>superscript</sup>"));
}
#[test]
fn test_lukiwiki_strikethrough() {
let input = "This is %%strikethrough%% text.";
let output = apply_inline_decorations(input);
assert_eq!(output, "This is <s>strikethrough</s> text.");
}
#[test]
fn test_lukiwiki_strikethrough_multiple() {
let input = "%%first%% and %%second%%";
let output = apply_inline_decorations(input);
assert_eq!(output, "<s>first</s> and <s>second</s>");
}
#[test]
fn test_inline_ruby() {
let input = "&ruby(Ashita){明日};";
let output = apply_inline_decorations(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_inline_decorations(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_inline_decorations(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_inline_decorations(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_inline_decorations(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_inline_decorations(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_inline_decorations(input);
assert_eq!(output, "Line 1<br />Line 2<br />Line 3");
}
#[test]
fn test_badge_basic() {
let input = "&badge(primary){New};";
let output = apply_inline_decorations(input);
assert!(output.contains("<span class=\"badge bg-primary\">New</span>"));
}
#[test]
fn test_badge_pill() {
let input = "&badge(success-pill){Active};";
let output = apply_inline_decorations(input);
assert!(output.contains("badge rounded-pill"));
assert!(output.contains("bg-success"));
}
#[test]
fn test_badge_with_link() {
let input = "&badge(danger){[Error](/error)};";
let output = apply_inline_decorations(input);
assert!(output.contains("<a href=\"/error\" class=\"badge bg-danger\">Error</a>"));
}
#[test]
fn test_color_bootstrap_class() {
let input = "&color(primary){Primary text};";
let output = apply_inline_decorations(input);
assert!(output.contains("class=\"text-primary\""));
}
#[test]
fn test_color_custom_value() {
let input = "&color(#FF0000){Red text};";
let output = apply_inline_decorations(input);
assert!(output.contains("style=\"color: #FF0000\""));
}
#[test]
fn test_spoiler_discord_syntax() {
let input = "This is ||hidden text|| in a sentence.";
let output = apply_inline_decorations(input);
assert!(output.contains(r#"<span class="spoiler" role="button" tabindex="0" aria-expanded="false">hidden text</span>"#));
}
#[test]
fn test_spoiler_umd_function_parentheses() {
let input = "This is &spoiler(hidden text); in a sentence.";
let output = apply_inline_decorations(input);
assert!(output.contains(r#"<span class="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_inline_decorations(input);
assert!(output.contains(r#"<span class="spoiler" role="button" tabindex="0" aria-expanded="false">hidden text</span>"#));
}
#[test]
fn test_multiple_spoilers() {
let input = "||spoiler1|| and ||spoiler2|| and &spoiler{spoiler3};";
let output = apply_inline_decorations(input);
let spoiler_count = output.matches(r#"<span class="spoiler""#).count();
assert_eq!(spoiler_count, 3);
}
#[test]
fn test_size_bootstrap_class() {
let input = "&size(1.5){Medium text};";
let output = apply_inline_decorations(input);
assert!(output.contains("class=\"fs-4\""));
}
#[test]
fn test_size_custom_value() {
let input = "&size(3rem){Custom size};";
let output = apply_inline_decorations(input);
assert!(output.contains("style=\"font-size: 3rem\""));
}
}