dioxus-mdx 0.6.1

MDX parsing and rendering components for Dioxus
Documentation
//! Tabs component parser.

use std::sync::LazyLock;

use regex::Regex;

use super::content::parse_content;
use super::utils::{find_closing_tag, skip_to_next_tag};
use crate::parser::types::*;

static TAB_OPEN_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r#"^<Tab\s+title="([^"]*)">"#).unwrap());

/// Try to parse a Tabs component.
pub(super) fn try_parse_tabs(content: &str) -> Option<(DocNode, &str)> {
    if !content.starts_with("<Tabs>") {
        return None;
    }

    let after_open = &content["<Tabs>".len()..];
    let close_idx = find_closing_tag(after_open, "Tabs")?;
    let inner = &after_open[..close_idx];
    let rest = &after_open[close_idx + "</Tabs>".len()..];

    // Parse inner tabs
    let tabs = parse_tabs(inner);

    Some((DocNode::Tabs(TabsNode { tabs }), rest))
}

/// Parse Tab elements from content.
fn parse_tabs(content: &str) -> Vec<TabNode> {
    let mut tabs = Vec::new();
    let mut remaining = content.trim();

    while !remaining.is_empty() {
        remaining = remaining.trim();

        if let Some(caps) = TAB_OPEN_RE.captures(remaining) {
            let full_match = caps.get(0).expect("regex group 0");
            let title = caps
                .get(1)
                .map(|m| m.as_str())
                .unwrap_or_default()
                .to_string();

            let after_open = &remaining[full_match.end()..];
            if let Some(close_idx) = find_closing_tag(after_open, "Tab") {
                let inner = after_open[..close_idx].trim();
                // Parse inner content recursively
                let parsed_content = parse_content(inner);
                tabs.push(TabNode {
                    title,
                    content: parsed_content,
                });
                remaining = &after_open[close_idx + "</Tab>".len()..];
                continue;
            }
        }

        // Skip to next Tab
        match skip_to_next_tag(remaining, "<Tab") {
            Some(next) => remaining = next,
            None => break,
        }
    }

    tabs
}

#[cfg(test)]
mod tests {
    use crate::parser::content::parse_mdx;
    use crate::parser::types::*;

    #[test]
    fn test_parse_tabs() {
        let content = r#"<Tabs>
  <Tab title="macOS">Mac instructions</Tab>
  <Tab title="Windows">Windows instructions</Tab>
</Tabs>"#;

        let nodes = parse_mdx(content);
        assert_eq!(nodes.len(), 1);
        if let DocNode::Tabs(t) = &nodes[0] {
            assert_eq!(t.tabs.len(), 2);
            assert_eq!(t.tabs[0].title, "macOS");
            assert_eq!(t.tabs[1].title, "Windows");
        } else {
            panic!("Expected Tabs node");
        }
    }

    #[test]
    fn test_code_block_in_tab() {
        let content = r#"<Tabs>
  <Tab title="JavaScript">
    ```js
    console.log("hello");
    ```
  </Tab>
</Tabs>"#;
        let nodes = parse_mdx(content);
        assert_eq!(nodes.len(), 1);
        if let DocNode::Tabs(t) = &nodes[0] {
            assert_eq!(t.tabs.len(), 1);
            // Tab content should contain the code block
            let has_code_block = t.tabs[0]
                .content
                .iter()
                .any(|n| matches!(n, DocNode::CodeBlock(_)));
            assert!(has_code_block, "Expected CodeBlock in Tab content");
        } else {
            panic!("Expected Tabs node");
        }
    }

    #[test]
    fn non_ascii_prose_between_tabs_does_not_panic() {
        // Stray prose before the first <Tab> used to be skipped by a raw byte
        // slice, splitting any multi-byte char mid-codepoint.
        let content = "<Tabs>\n\u{dc}berblick\n<Tab title=\"A\">body</Tab>\n</Tabs>";
        let nodes = parse_mdx(content);
        let tabs = nodes.iter().find_map(|n| match n {
            DocNode::Tabs(t) => Some(t),
            _ => None,
        });
        assert_eq!(tabs.expect("Tabs node").tabs.len(), 1);
    }

    #[test]
    fn nested_tabs_keep_the_outer_tab() {
        let content = "<Tabs><Tab title=\"Outer\"><Tabs><Tab title=\"Inner\">inner body</Tab></Tabs></Tab></Tabs>";
        let nodes = parse_mdx(content);
        let tabs = nodes
            .iter()
            .find_map(|n| match n {
                DocNode::Tabs(t) => Some(t),
                _ => None,
            })
            .expect("Tabs node");
        assert_eq!(tabs.tabs.len(), 1);
        assert_eq!(tabs.tabs[0].title, "Outer");
    }
}