//! 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: UI examples first, then code tabs, then alerts. pub fn post_process_directives(html: &str) -> String { let with_ui = process_ui_examples(html); let with_tabs = process_tabs(&with_ui); process_alerts(&with_tabs) } /// Regex matching `[!UI] example-name` inside a blockquote paragraph. /// Captures the example name (alphanumeric, hyphens, underscores). static UI_RE: LazyLock = LazyLock::new(|| { regex_lite::Regex::new( r"

\s*

\[!UI\]\s+([a-z0-9_-]+)(?:)?\s*", ) .expect("valid UI regex") }); /// Replace `[!UI] name` blockquotes with `

` placeholder elements. /// /// The placeholder carries `data-ui="name"` for the doc loader to resolve. /// Any text after the name line becomes a `
`. fn process_ui_examples(html: &str) -> String { if !html.contains("[!UI]") { return html.to_string(); } let mut result = String::with_capacity(html.len()); let mut remaining = html; while let Some(bq_pos) = remaining.find("
") { let close_pos = match remaining[bq_pos..].find("
") { Some(p) => bq_pos + p, None => break, }; // Check if this blockquote contains [!UI] (check only up to its closing tag). let bq_slice = &remaining[bq_pos..close_pos + "
".len()]; let is_ui = UI_RE.is_match(bq_slice); if !is_ui { // Not a UI blockquote — copy through the entire blockquote and continue. let end = close_pos + "
".len(); result.push_str(&remaining[..end]); remaining = &remaining[end..]; continue; } // Copy everything before this blockquote. result.push_str(&remaining[..bq_pos]); // Extract the example name. if let Some(caps) = UI_RE.captures(bq_slice) { let name = &caps[1]; let marker_end = caps[0].len(); // Everything after the marker line is the caption. let after_marker = &remaining[(bq_pos + marker_end)..close_pos]; let caption = strip_html_tags_simple(after_marker).trim().to_string(); result.push_str(&format!( "
" )); result.push_str(&format!( "
" )); if !caption.is_empty() { result.push_str(&format!("
{caption}
")); } result.push_str("
"); } remaining = &remaining[close_pos + "".len()..]; } result.push_str(remaining); result } /// Minimal tag stripper for extracting caption text from inner HTML. fn strip_html_tags_simple(html: &str) -> String { let mut out = String::with_capacity(html.len()); let mut in_tag = false; for ch in html.chars() { match ch { '<' => in_tag = true, '>' => { in_tag = false; } _ if !in_tag => out.push(ch), _ => {} } } out } /// 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 and UI — already handled by their own processors. if kind == "TABS" || kind == "UI" { 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" && &c[1] != "UI") .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"); } // ===== UI example directives ===== #[test] fn ui_example_basic() { let html = "
\n

[!UI] discover-filters

\n
"; let result = post_process_directives(html); assert!(result.contains("doc-ui")); assert!(result.contains("data-ui=\"discover-filters\"")); assert!(result.contains("")); } #[test] fn ui_example_with_caption() { let html = "
\n

[!UI] discover-filters
\nFilter sidebar in items mode

\n
"; let result = post_process_directives(html); assert!(result.contains("data-ui=\"discover-filters\"")); assert!(result.contains("
")); assert!(result.contains("Filter sidebar in items mode")); } #[test] fn ui_example_not_confused_with_alert() { let html = concat!( "
\n

[!UI] my-example

\n
\n", "
\n

[!NOTE]
\nA note.

\n
" ); let result = post_process_directives(html); assert!(result.contains("doc-ui")); assert!(result.contains("alert alert-note")); } #[test] fn alert_close_replacement_does_not_consume_extra_blockquote() { // Pins the `replaced < alert_count` loop bound in process_alerts: // mutating `<` to `<=` would consume a SECOND `
` (from the // following normal quote) as if it were an alert close. let html = concat!( "
\n

[!WARNING]
\nWatch out!

\n
\n", "
\n

Normal quote.

\n
" ); let result = post_process_directives(html); // Normal blockquote must retain its closing tag. The opening also // survives (one remaining `
`; the alert's was removed). assert_eq!( result.matches("
").count(), 1, "expected exactly one surviving
, got: {result}" ); assert_eq!( result.matches("
").count(), 1, "expected exactly one surviving
, got: {result}" ); // And the alert structure is correctly closed exactly once. assert_eq!( result.matches("
").count(), 1, "exactly one for the single alert, got: {result}" ); } #[test] fn strip_html_tags_keeps_text_between_tags() { // strip_html_tags_simple is exercised via UI caption extraction. // This test asserts the exact text-between-tags behavior, which pins // the `<` (enter tag), `>` (exit tag), and `_ if !in_tag` arms. let html = concat!( "
\n

[!UI] widget

\n", "

Caption with emphasis and code inside.

\n", "
" ); let result = post_process_directives(html); // Caption text must contain every text segment with no tag fragments. assert!(result.contains("Caption with emphasis and code inside."), "stripped caption text missing or wrong: {result}"); // And must NOT contain any of the original inline tag names. assert!(!result.contains(""), " leaked into caption: {result}"); assert!(!result.contains(""), " leaked into caption: {result}"); } #[test] fn ui_caption_extraction_uses_correct_byte_range() { // Pins the `bq_pos + marker_end` and `..close_pos` arithmetic in // process_ui_examples: caption should be exactly what follows the // marker line, with no leakage from before the marker or after the // blockquote close. let html = concat!( "

Lead paragraph.

\n", "
\n

[!UI] preview

\n

Exact caption.

\n
\n", "

Trailing paragraph.

" ); let result = post_process_directives(html); assert!(result.contains("
Exact caption.
"), "caption byte-range arithmetic wrong: {result}"); // The surrounding paragraphs survive intact and unduplicated. assert_eq!(result.matches("Lead paragraph.").count(), 1, "lead duplicated/missing: {result}"); assert_eq!(result.matches("Trailing paragraph.").count(), 1, "trailer duplicated/missing: {result}"); // The marker line is fully consumed — `[!UI]` must not leak through. assert!(!result.contains("[!UI]"), "marker leaked: {result}"); } #[test] fn ui_example_preserves_other_blockquotes() { let html = concat!( "
\n

Normal quote.

\n
\n", "
\n

[!UI] cart-view

\n
" ); let result = post_process_directives(html); // After process_ui_examples, the normal blockquote should still have
. // But then process_alerts runs and leaves non-alert blockquotes alone. // Check the UI example was processed. assert!(result.contains("data-ui=\"cart-view\"")); assert!(result.contains("Normal quote.")); // The normal blockquote survives all processing. assert!(result.contains("
"), "Result: {result}"); } }