use crate::parser::ast::{AdmonitionKind, AdmonitionStyle, Document, Node, NodeKind};
pub fn apply_gfm_admonitions(document: &mut Document) {
apply_to_nodes(&mut document.children, true);
}
fn apply_to_nodes(nodes: &mut [Node], is_top_level: bool) {
for node in nodes.iter_mut() {
if is_top_level {
try_transform_blockquote(node);
}
if !node.children.is_empty() {
apply_to_nodes(&mut node.children, false);
}
}
}
fn try_transform_blockquote(node: &mut Node) {
if !matches!(node.kind, NodeKind::Blockquote) {
return;
}
let Some(first_child) = node.children.first_mut() else {
return;
};
let Some((spec, remove_first_paragraph)) =
strip_admonition_marker_from_first_paragraph(first_child)
else {
return;
};
if remove_first_paragraph {
node.children.remove(0);
}
node.kind = NodeKind::Admonition {
kind: spec.kind,
title: spec.title,
icon: spec.icon,
style: spec.style,
};
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AdmonitionSpec {
kind: AdmonitionKind,
title: Option<String>,
icon: Option<String>,
style: AdmonitionStyle,
}
fn strip_admonition_marker_from_first_paragraph(
paragraph: &mut Node,
) -> Option<(AdmonitionSpec, bool)> {
if !matches!(paragraph.kind, NodeKind::Paragraph) {
return None;
}
let mut raw = String::new();
let mut idx = 0usize;
while idx < paragraph.children.len() {
match ¶graph.children[idx].kind {
NodeKind::Text(t) => {
raw.push_str(t);
idx += 1;
}
NodeKind::SoftBreak | NodeKind::HardBreak => {
break;
}
_ => {
return None;
}
}
}
let spec = admonition_marker_spec_from_raw(&raw)?;
if idx < paragraph.children.len()
&& matches!(
paragraph.children[idx].kind,
NodeKind::SoftBreak | NodeKind::HardBreak
)
{
paragraph.children.drain(0..=idx);
return Some((spec, false));
}
Some((spec, true))
}
fn admonition_marker_spec_from_raw(raw: &str) -> Option<AdmonitionSpec> {
let normalized = raw.trim().to_ascii_uppercase();
match normalized.as_str() {
"[!NOTE]" => Some(AdmonitionSpec {
kind: AdmonitionKind::Note,
title: None,
icon: None,
style: AdmonitionStyle::Alert,
}),
"[!TIP]" => Some(AdmonitionSpec {
kind: AdmonitionKind::Tip,
title: None,
icon: None,
style: AdmonitionStyle::Alert,
}),
"[!IMPORTANT]" => Some(AdmonitionSpec {
kind: AdmonitionKind::Important,
title: None,
icon: None,
style: AdmonitionStyle::Alert,
}),
"[!WARNING]" => Some(AdmonitionSpec {
kind: AdmonitionKind::Warning,
title: None,
icon: None,
style: AdmonitionStyle::Alert,
}),
"[!CAUTION]" => Some(AdmonitionSpec {
kind: AdmonitionKind::Caution,
title: None,
icon: None,
style: AdmonitionStyle::Alert,
}),
_ => parse_custom_header_admonition(raw),
}
}
fn parse_custom_header_admonition(raw: &str) -> Option<AdmonitionSpec> {
let trimmed = raw.trim();
if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
return None;
}
let inner = trimmed
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))?
.trim();
if inner.is_empty() {
return None;
}
if inner.trim_start().starts_with('!') {
return None;
}
let mut parts = inner.splitn(2, char::is_whitespace);
let icon = parts.next()?.trim();
let title = parts.next().unwrap_or("").trim();
if icon.is_empty() || title.is_empty() {
return None;
}
Some(AdmonitionSpec {
kind: AdmonitionKind::Note,
title: Some(title.to_string()),
icon: Some(icon.to_string()),
style: AdmonitionStyle::Quote,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smoke_test_detects_marker_case_insensitive() {
let mut marker = Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![Node {
kind: NodeKind::Text("[!note]".to_string()),
span: None,
children: vec![],
}],
};
let (spec, remove) = strip_admonition_marker_from_first_paragraph(&mut marker).unwrap();
assert_eq!(spec.kind, AdmonitionKind::Note);
assert_eq!(spec.style, AdmonitionStyle::Alert);
assert!(remove);
}
#[test]
fn smoke_test_rejects_marker_with_non_text_children() {
let mut marker = Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![Node {
kind: NodeKind::Emphasis,
span: None,
children: vec![Node {
kind: NodeKind::Text("[!NOTE]".to_string()),
span: None,
children: vec![],
}],
}],
};
assert!(strip_admonition_marker_from_first_paragraph(&mut marker).is_none());
}
#[test]
fn smoke_test_transforms_top_level_blockquote_only() {
let mut doc = Document {
children: vec![Node {
kind: NodeKind::Blockquote,
span: None,
children: vec![
Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![Node {
kind: NodeKind::Text("[!NOTE]".to_string()),
span: None,
children: vec![],
}],
},
Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![Node {
kind: NodeKind::Text("Body".to_string()),
span: None,
children: vec![],
}],
},
],
}],
..Default::default()
};
apply_gfm_admonitions(&mut doc);
assert!(matches!(
doc.children[0].kind,
NodeKind::Admonition {
kind: AdmonitionKind::Note,
..
}
));
assert_eq!(doc.children[0].children.len(), 1);
}
}