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());
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()..];
let tabs = parse_tabs(inner);
Some((DocNode::Tabs(TabsNode { tabs }), rest))
}
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();
let parsed_content = parse_content(inner);
tabs.push(TabNode {
title,
content: parsed_content,
});
remaining = &after_open[close_idx + "</Tab>".len()..];
continue;
}
}
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);
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() {
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");
}
}