//! 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 //! `
` callout elements. //! //! **Code tabs:** `> [!TABS]` followed by fenced code blocks become a tabbed //! interface with language-labelled tabs. use std::sync::LazyLock; /// Matches any `[!TYPE]` alert marker inside a blockquote paragraph. /// Accepts any uppercase word (letters, digits, hyphens, underscores). static ALERT_RE: LazyLock = LazyLock::new(|| { regex_lite::Regex::new( r"
\s*

\[!([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!( "

{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("
"); 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("
") { let after_bq_start = bq_pos + "
".len(); // Find the closing
for this blockquote. let close_pos = match remaining[bq_pos..].find("
") { Some(p) => bq_pos + p, None => break, }; let inner = &remaining[after_bq_start..close_pos]; // Check if the first

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 /// `
` elements.
fn extract_code_blocks(html: &str) -> Vec<(String, String)> {
    let mut blocks = Vec::new();
    let mut search_from = 0;
    let end_marker = "
"; while let Some(pre_pos) = html[search_from..].find("
 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
"); for (i, (lang, _)) in tabs.iter().enumerate() { let active = if i == 0 { " active" } else { "" }; let label = code_language_label(lang); html.push_str(&format!( "" )); } html.push_str("
\n"); for (i, (_, block)) in tabs.iter().enumerate() { let active = if i == 0 { " active" } else { "" }; html.push_str(&format!( "
{block}
\n" )); } html.push_str("
"); 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 = "
\n

[!NOTE]
\nThis is a note.

\n
"; let result = post_process_directives(html); assert!(result.contains("alert alert-note")); assert!(result.contains("

Note

")); assert!(result.contains("This is a note.")); assert!(!result.contains("
")); } #[test] fn tip_alert() { let html = "
\n

[!TIP]
\nHelpful tip here.

\n
"; let result = post_process_directives(html); assert!(result.contains("alert alert-tip")); assert!(result.contains("

Tip

")); } #[test] fn important_alert() { let html = "
\n

[!IMPORTANT]
\nDo this.

\n
"; let result = post_process_directives(html); assert!(result.contains("alert alert-important")); assert!(result.contains("

Important

")); } #[test] fn warning_alert() { let html = "
\n

[!WARNING]
\nBe careful.

\n
"; let result = post_process_directives(html); assert!(result.contains("alert alert-warning")); assert!(result.contains("

Warning

")); } #[test] fn caution_alert() { let html = "
\n

[!CAUTION]
\nDanger zone.

\n
"; let result = post_process_directives(html); assert!(result.contains("alert alert-caution")); assert!(result.contains("

Caution

")); } #[test] fn multi_paragraph_alert() { let html = "
\n

[!NOTE]
\nFirst paragraph.

\n

Second paragraph.

\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("")); assert!(!result.contains("
")); } #[test] fn regular_blockquote_unchanged() { let html = "
\n

Just a normal quote.

\n
"; let result = post_process_directives(html); assert_eq!(result, html); } #[test] fn mixed_alerts_and_blockquotes() { let html = concat!( "
\n

[!WARNING]
\nWatch out!

\n
\n", "
\n

Normal quote.

\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("
")); assert!(result.contains("Normal quote.")); } // ===== Custom alert types ===== #[test] fn custom_example_alert() { let html = "
\n

[!EXAMPLE]
\nHere is an example.

\n
"; let result = post_process_directives(html); assert!(result.contains("alert alert-example")); assert!(result.contains("

Example

")); assert!(result.contains("Here is an example.")); assert!(!result.contains("
")); } #[test] fn custom_definition_alert() { let html = "
\n

[!DEFINITION]
\nA term and its meaning.

\n
"; let result = post_process_directives(html); assert!(result.contains("alert alert-definition")); assert!(result.contains("

Definition

")); } #[test] fn custom_alert_with_hyphen() { let html = "
\n

[!SEE-ALSO]
\nRelated topics.

\n
"; let result = post_process_directives(html); assert!(result.contains("alert alert-see-also")); assert!(result.contains("

See-also

")); } // ===== Code tabs ===== #[test] fn tabs_two_languages() { let html = concat!( "
\n

[!TABS]

\n", "
fn main() {}\n
\n", "
def main(): pass\n
\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("
")); // 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

[!TABS]

\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("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!( "
\n

[!TABS]

\n", "
some code\n
\n", "
let x = 1;\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!( "
\n

[!TABS]
\n

\n", "
[package]\n
\n", "
{}\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!( "
\n

[!NOTE]
\nA note.

\n
\n", "
\n

[!TABS]

\n", "
let x = 1;\n
\n", "
\n", "
\n

Normal quote.

\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("
")); assert!(result.contains("Normal quote.")); } #[test] fn tabs_no_code_blocks() { let html = concat!( "
\n

[!TABS]

\n", "

Just text, no code.

\n", "
" ); let result = post_process_directives(html); assert!(result.contains("code-tabs")); assert!(result.contains("Just text, no code.")); assert!(!result.contains("
")); } // ===== 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"); } }