//! Post-process rendered HTML to convert blockquote-based directives into //! styled elements. //! //! **Alerts:** `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!CAUTION]`, //! `> [!IMPORTANT]`, and any custom `> [!TYPE]` marker become styled //! `
\s*")); } #[test] fn regular_blockquote_unchanged() { let html = "\[!([A-Z][A-Z0-9_-]*)\](?:
)?\s*", ) .expect("valid alert regex") }); /// Process all directives: code tabs first, then alerts. pub fn post_process_directives(html: &str) -> String { let with_tabs = process_tabs(html); process_alerts(&with_tabs) } /// Replace alert blockquotes with styled `` elements. fn process_alerts(html: &str) -> String { // First pass: replace opening markers. let opened = ALERT_RE.replace_all(html, |caps: ®ex_lite::Captures| { let kind = &caps[1]; // Skip TABS — already handled by process_tabs. if kind == "TABS" { return caps[0].to_string(); } let label = title_case(kind); format!( "")); assert!(!result.contains(""); remaining = &remaining[(pos + "".len())..]; replaced += 1; } else { break; } } result.push_str(remaining); result } /// Process `[!TABS]` blockquotes into tabbed code-block interfaces. fn process_tabs(html: &str) -> String { if !html.contains("[!TABS]") { return html.to_string(); } let mut result = String::with_capacity(html.len()); let mut remaining = html; while let Some(bq_pos) = remaining.find("{label}
", kind = kind.to_ascii_lowercase(), label = label, ) }); // Second pass: close any opened alerts. let alert_count = ALERT_RE .captures_iter(html) .filter(|c| &c[1] != "TABS") .count(); if alert_count == 0 { return opened.into_owned(); } let mut result = String::with_capacity(opened.len()); let mut remaining = opened.as_ref(); let mut replaced = 0; while replaced < alert_count { if let Some(pos) = remaining.find("") { result.push_str(&remaining[..pos]); result.push_str("
") { let after_bq_start = bq_pos + "") { Some(p) => bq_pos + p, None => break, }; let inner = &remaining[after_bq_start..close_pos]; // Check if the first".len(); // Find the closingfor this blockquote. let close_pos = match remaining[bq_pos..].find("in the blockquote contains [!TABS]. let is_tabs = { let trimmed = inner.trim_start(); trimmed.starts_with("
") && { let first_p_end = trimmed.find("
").unwrap_or(trimmed.len()); trimmed[..first_p_end].contains("[!TABS]") } }; if !is_tabs { // Not a TABS blockquote — copy through the opening tag and continue. result.push_str(&remaining[..after_bq_start]); remaining = &remaining[after_bq_start..]; continue; } // Copy everything before this blockquote. result.push_str(&remaining[..bq_pos]); // Extract code blocks from the inner HTML. let tabs = extract_code_blocks(inner); if tabs.is_empty() { // No code blocks found — wrap content in a plain div. result.push_str(""); result.push_str(inner); result.push_str(""); } else { result.push_str(&build_tabs_html(&tabs)); } remaining = &remaining[close_pos + "".len()..]; } result.push_str(remaining); result } /// Extract `(language, full_html_block)` pairs from HTML containing /// `"; while let Some(pre_pos) = html[search_from..].find("` elements. fn extract_code_blocks(html: &str) -> Vec<(String, String)> { let mut blocks = Vec::new(); let mut search_from = 0; let end_marker = "abs_pos + p + end_marker.len(), None => break, }; let full_block = &html[abs_pos..end_pos]; // Extract language from class="language-X". let lang = if let Some(class_start) = full_block.find("class=\"language-") { let after = &full_block[class_start + "class=\"language-".len()..]; after.split('"').next().unwrap_or("code").to_string() } else { "code".to_string() }; blocks.push((lang, full_block.to_string())); search_from = end_pos; } blocks } /// Build tabbed HTML from extracted code blocks. fn build_tabs_html(tabs: &[(String, String)]) -> String { let mut html = String::from("\n\n"); for (i, (_, block)) in tabs.iter().enumerate() { let active = if i == 0 { " active" } else { "" }; html.push_str(&format!( ""); html } /// Human-readable label for a code language identifier. fn code_language_label(lang: &str) -> String { match lang { "js" | "javascript" => "JavaScript".into(), "ts" | "typescript" => "TypeScript".into(), "sh" | "bash" | "zsh" | "shell" => "Shell".into(), "json" => "JSON".into(), "html" => "HTML".into(), "css" => "CSS".into(), "sql" => "SQL".into(), "toml" => "TOML".into(), "yaml" | "yml" => "YAML".into(), "xml" => "XML".into(), other => title_case(other), } } fn title_case(s: &str) -> String { let mut chars = s.chars(); match chars.next() { Some(c) => { let mut out = c.to_uppercase().to_string(); out.extend(chars.map(|c| c.to_ascii_lowercase())); out } None => String::new(), } } #[cfg(test)] mod tests { use super::*; // ===== Alert directives ===== #[test] fn note_alert() { let html = "{block}\n" )); } html.push_str("\n"; let result = post_process_directives(html); assert!(result.contains("alert alert-note")); assert!(result.contains("[!NOTE]
\n
\nThis is a note.Note
")); assert!(result.contains("This is a note.")); assert!(!result.contains("")); } #[test] fn tip_alert() { let html = "\n"; let result = post_process_directives(html); assert!(result.contains("alert alert-tip")); assert!(result.contains("[!TIP]
\n
\nHelpful tip here.Tip
")); } #[test] fn important_alert() { let html = "\n"; let result = post_process_directives(html); assert!(result.contains("alert alert-important")); assert!(result.contains("[!IMPORTANT]
\n
\nDo this.Important
")); } #[test] fn warning_alert() { let html = "\n"; let result = post_process_directives(html); assert!(result.contains("alert alert-warning")); assert!(result.contains("[!WARNING]
\n
\nBe careful.Warning
")); } #[test] fn caution_alert() { let html = "\n"; let result = post_process_directives(html); assert!(result.contains("alert alert-caution")); assert!(result.contains("[!CAUTION]
\n
\nDanger zone.Caution
")); } #[test] fn multi_paragraph_alert() { let html = "\n"; let result = post_process_directives(html); assert!(result.contains("alert alert-note")); assert!(result.contains("First paragraph.")); assert!(result.contains("Second paragraph.")); assert!(result.contains("[!NOTE]
\n
\nFirst paragraph.Second paragraph.
\n
\n"; let result = post_process_directives(html); assert_eq!(result, html); } #[test] fn mixed_alerts_and_blockquotes() { let html = concat!( "Just a normal quote.
\n
\n\n", "[!WARNING]
\n
\nWatch out!
\n" ); let result = post_process_directives(html); assert!(result.contains("alert alert-warning")); assert!(result.contains("Watch out!")); // The normal blockquote remains unchanged. assert!(result.contains("Normal quote.
\n
")); assert!(result.contains("Normal quote.")); } // ===== Custom alert types ===== #[test] fn custom_example_alert() { let html = "\n"; let result = post_process_directives(html); assert!(result.contains("alert alert-example")); assert!(result.contains("[!EXAMPLE]
\n
\nHere is an example.Example
")); assert!(result.contains("Here is an example.")); assert!(!result.contains("")); } #[test] fn custom_definition_alert() { let html = "\n"; let result = post_process_directives(html); assert!(result.contains("alert alert-definition")); assert!(result.contains("[!DEFINITION]
\n
\nA term and its meaning.Definition
")); } #[test] fn custom_alert_with_hyphen() { let html = "\n"; let result = post_process_directives(html); assert!(result.contains("alert alert-see-also")); assert!(result.contains("[!SEE-ALSO]
\n
\nRelated topics.See-also
")); } // ===== Code tabs ===== #[test] fn tabs_two_languages() { let html = concat!( "\n" ); let result = post_process_directives(html); assert!(result.contains("code-tabs")); assert!(result.contains("code-tabs-bar")); assert!(result.contains("Rust")); assert!(result.contains("Python")); assert!(result.contains("fn main() {}")); assert!(result.contains("def main(): pass")); assert!(!result.contains("[!TABS]
\n", "\n", "fn main() {}\n\n", "def main(): pass\n")); // First tab is active. assert!(result.contains("code-tab active")); assert!(result.contains("code-tab-panel active")); } #[test] fn tabs_three_languages() { let html = concat!( "\n" ); let result = post_process_directives(html); assert!(result.contains("Shell")); // bash → Shell assert!(result.contains("JavaScript")); // js → JavaScript assert!(result.contains("Python")); assert!(result.contains("data-tab-index=\"0\"")); assert!(result.contains("data-tab-index=\"1\"")); assert!(result.contains("data-tab-index=\"2\"")); } #[test] fn tabs_no_language_specified() { let html = concat!( "[!TABS]
\n", "\n", "curl https://api.example.com\n\n", "fetch('https://api.example.com')\n\n", "requests.get('https://api.example.com')\n\n" ); let result = post_process_directives(html); assert!(result.contains("Code")); // fallback label assert!(result.contains("Rust")); } #[test] fn tabs_with_br_marker() { let html = concat!( "[!TABS]
\n", "\n", "some code\n\n", "let x = 1;\n\n" ); let result = post_process_directives(html); assert!(result.contains("TOML")); assert!(result.contains("JSON")); } #[test] fn tabs_mixed_with_alert_and_blockquote() { let html = concat!( "[!TABS]
\n", "
\n\n", "[package]\n\n", "{}\n\n\n", "[!NOTE]
\n
\nA note.\n\n", "[!TABS]
\n", "\n", "let x = 1;\n\n" ); let result = post_process_directives(html); // Alert processed. assert!(result.contains("alert alert-note")); // Tabs processed. assert!(result.contains("code-tabs")); assert!(result.contains("Rust")); // Normal blockquote unchanged. assert!(result.contains("Normal quote.
\n")); assert!(result.contains("Normal quote.")); } #[test] fn tabs_no_code_blocks() { let html = concat!( "\n" ); let result = post_process_directives(html); assert!(result.contains("code-tabs")); assert!(result.contains("Just text, no code.")); assert!(!result.contains("[!TABS]
\n", "Just text, no code.
\n", "")); } // ===== Language label mapping ===== #[test] fn language_labels() { assert_eq!(code_language_label("js"), "JavaScript"); assert_eq!(code_language_label("typescript"), "TypeScript"); assert_eq!(code_language_label("bash"), "Shell"); assert_eq!(code_language_label("json"), "JSON"); assert_eq!(code_language_label("html"), "HTML"); assert_eq!(code_language_label("css"), "CSS"); assert_eq!(code_language_label("sql"), "SQL"); assert_eq!(code_language_label("toml"), "TOML"); assert_eq!(code_language_label("yaml"), "YAML"); assert_eq!(code_language_label("xml"), "XML"); assert_eq!(code_language_label("rust"), "Rust"); assert_eq!(code_language_label("python"), "Python"); assert_eq!(code_language_label("go"), "Go"); } }