use pulldown_cmark::{Parser, Options, Event, Tag};
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(tag = "type")]
pub enum Node {
#[serde(rename = "element")]
Element {
tag: String,
props: HashMap<String, serde_json::Value>,
children: Vec<Node>,
},
#[serde(rename = "text")]
Text {
content: String,
},
}
pub struct TranspileOptions {
pub allowed_tags: Vec<String>,
}
fn is_tag_name_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-'
}
fn parse_html_attrs(attrs_str: &str) -> HashMap<String, serde_json::Value> {
let mut props = HashMap::new();
let bytes = attrs_str.as_bytes();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let name_start = i;
while i < bytes.len() && is_tag_name_char(bytes[i] as char) {
i += 1;
}
if i == name_start {
break;
}
let key = &attrs_str[name_start..i];
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i < bytes.len() && bytes[i] == b'=' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
let value = if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
let quote = bytes[i];
i += 1;
let val_start = i;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
let s = attrs_str[val_start..i].to_string();
if i < bytes.len() {
i += 1;
}
serde_json::Value::String(s)
} else {
let val_start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
i += 1;
}
serde_json::Value::String(attrs_str[val_start..i].to_string())
};
props.insert(key.to_string(), value);
} else {
props.insert(key.to_string(), serde_json::Value::Bool(true));
}
}
props
}
fn parse_html_tag(html: &str) -> Option<(String, HashMap<String, serde_json::Value>, bool)> {
let html = html.trim();
if html.starts_with("</") && html.ends_with('>') {
let tag_name = html[2..html.len() - 1].trim().to_string();
return Some((tag_name, HashMap::new(), false));
}
if !html.starts_with('<') || !html.ends_with('>') {
return None;
}
let inner = &html[1..html.len() - 1];
let is_self_closing = inner.ends_with('/');
let inner = inner.trim_end_matches('/').trim();
let mut parts = inner.splitn(2, |c: char| c.is_ascii_whitespace());
let tag_name = parts.next()?.to_string();
if tag_name.is_empty() || !tag_name.chars().all(is_tag_name_char) {
return None;
}
let attrs_str = parts.next().unwrap_or("");
Some((tag_name, parse_html_attrs(attrs_str), is_self_closing))
}
const HTML_BLOCK_SENTINEL: &str = "__html_block__";
fn push_node(stack: &mut Vec<Node>, root: &mut Vec<Node>, node: Node) {
if let Some(Node::Element { children, .. }) = stack.last_mut() {
children.push(node);
} else {
root.push(node);
}
}
fn close_html_tag(stack: &mut Vec<Node>, root: &mut Vec<Node>, close_tag: &str) {
while let Some(node) = stack.pop() {
let is_match = matches!(&node, Node::Element { tag, .. } if tag == close_tag);
push_node(stack, root, node);
if is_match {
return;
}
}
}
fn merge_image_json_props(props: &mut HashMap<String, serde_json::Value>, raw: &str) -> bool {
let trimmed = raw.trim();
if !(trimmed.starts_with('{') && trimmed.ends_with('}')) {
return false;
}
let Ok(obj) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(trimmed) else {
return false;
};
const ALLOWED: &[&str] = &["className", "w", "id", "loading", "alt", "title"];
for (k, v) in obj {
if ALLOWED.contains(&k.as_str()) {
props.insert(k, v);
}
}
true
}
fn set_image_string_prop(props: &mut HashMap<String, serde_json::Value>, key: &str, raw: &str) {
if raw.is_empty() {
return;
}
if merge_image_json_props(props, raw) {
return;
}
props.insert(key.to_string(), serde_json::Value::String(raw.to_string()));
}
pub fn parse(markdown: &str, options: &TranspileOptions) -> Vec<Node> {
let mut p_options = Options::empty();
p_options.insert(Options::ENABLE_TABLES);
p_options.insert(Options::ENABLE_STRIKETHROUGH);
p_options.insert(Options::ENABLE_TASKLISTS);
p_options.insert(Options::ENABLE_FOOTNOTES);
p_options.insert(Options::ENABLE_SMART_PUNCTUATION);
let parser = Parser::new_ext(markdown, p_options);
let mut stack: Vec<Node> = Vec::new();
let mut root: Vec<Node> = Vec::new();
for event in parser {
match event {
Event::Start(tag) => {
let node = match tag {
Tag::Heading { level, .. } => Node::Element {
tag: format!("h{}", level as u32),
props: HashMap::new(),
children: Vec::new(),
},
Tag::Paragraph => Node::Element {
tag: "p".to_string(),
props: HashMap::new(),
children: Vec::new(),
},
Tag::Emphasis => Node::Element {
tag: "em".to_string(),
props: HashMap::new(),
children: Vec::new(),
},
Tag::Strong => Node::Element {
tag: "strong".to_string(),
props: HashMap::new(),
children: Vec::new(),
},
Tag::Link { dest_url, .. } => {
let mut props = HashMap::new();
props.insert("href".to_string(), serde_json::Value::String(dest_url.to_string()));
Node::Element {
tag: "a".to_string(),
props,
children: Vec::new(),
}
},
Tag::Image { dest_url, title, .. } => {
let mut props = HashMap::new();
props.insert("src".to_string(), serde_json::Value::String(dest_url.to_string()));
props.insert("alt".to_string(), serde_json::Value::String(String::new()));
if !title.is_empty() {
set_image_string_prop(&mut props, "title", &title);
}
Node::Element {
tag: "img".to_string(),
props,
children: Vec::new(),
}
},
Tag::List(first) => Node::Element {
tag: if first.is_some() { "ol".to_string() } else { "ul".to_string() },
props: HashMap::new(),
children: Vec::new(),
},
Tag::Item => Node::Element {
tag: "li".to_string(),
props: HashMap::new(),
children: Vec::new(),
},
Tag::Table(_) => Node::Element {
tag: "table".to_string(),
props: HashMap::new(),
children: Vec::new(),
},
Tag::TableHead => Node::Element {
tag: "thead".to_string(),
props: HashMap::new(),
children: Vec::new(),
},
Tag::TableRow => Node::Element {
tag: "tr".to_string(),
props: HashMap::new(),
children: Vec::new(),
},
Tag::TableCell => Node::Element {
tag: "td".to_string(),
props: HashMap::new(),
children: Vec::new(),
},
Tag::Strikethrough => Node::Element {
tag: "del".to_string(),
props: HashMap::new(),
children: Vec::new(),
},
Tag::HtmlBlock => Node::Element {
tag: HTML_BLOCK_SENTINEL.to_string(),
props: HashMap::new(),
children: Vec::new(),
},
Tag::FootnoteDefinition(label) => {
let mut props = HashMap::new();
props.insert("id".to_string(), serde_json::Value::String(format!("fn-{}", label)));
props.insert("className".to_string(), serde_json::Value::String("footnote-definition".to_string()));
Node::Element {
tag: "div".to_string(),
props,
children: Vec::new(),
}
},
_ => Node::Element {
tag: "div".to_string(),
props: HashMap::new(),
children: Vec::new(),
},
};
stack.push(node);
}
Event::End(tag_end) => {
if matches!(tag_end, pulldown_cmark::TagEnd::HtmlBlock) {
if let Some(Node::Element { tag: block_tag, children, .. }) = stack.pop() {
if block_tag == HTML_BLOCK_SENTINEL {
for child in children {
push_node(&mut stack, &mut root, child);
}
} else {
push_node(
&mut stack,
&mut root,
Node::Element {
tag: block_tag,
props: HashMap::new(),
children,
},
);
}
}
} else if let Some(node) = stack.pop() {
push_node(&mut stack, &mut root, node);
}
}
Event::Text(text) => {
if let Some(Node::Element { tag, props, .. }) = stack.last_mut() {
if tag == "img" {
let alt = props
.get("alt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
props.insert(
"alt".to_string(),
serde_json::Value::String(format!("{alt}{text}")),
);
continue;
}
}
let node = Node::Text { content: text.to_string() };
if stack.is_empty() {
root.push(node);
} else {
let parent = stack.last_mut().unwrap();
if let Node::Element { children, .. } = parent {
children.push(node);
}
}
}
Event::Code(code) => {
let node = Node::Element {
tag: "code".to_string(),
props: HashMap::new(),
children: vec![Node::Text { content: code.to_string() }],
};
if stack.is_empty() {
root.push(node);
} else {
let parent = stack.last_mut().unwrap();
if let Node::Element { children, .. } = parent {
children.push(node);
}
}
}
Event::FootnoteReference(label) => {
let mut props = HashMap::new();
props.insert("href".to_string(), serde_json::Value::String(format!("#fn-{}", label)));
props.insert("className".to_string(), serde_json::Value::String("footnote-ref".to_string()));
let node = Node::Element {
tag: "sup".to_string(),
props: HashMap::new(),
children: vec![Node::Element {
tag: "a".to_string(),
props,
children: vec![Node::Text { content: label.to_string() }],
}],
};
if stack.is_empty() {
root.push(node);
} else {
let parent = stack.last_mut().unwrap();
if let Node::Element { children, .. } = parent {
children.push(node);
}
}
}
Event::Html(html) | Event::InlineHtml(html) => {
let trimmed = html.trim();
if let Some((tag_name, props, is_self_closing)) = parse_html_tag(&html) {
if options.allowed_tags.contains(&tag_name) {
if trimmed.starts_with("</") {
close_html_tag(&mut stack, &mut root, &tag_name);
} else {
let node = Node::Element {
tag: tag_name,
props,
children: Vec::new(),
};
if is_self_closing {
push_node(&mut stack, &mut root, node);
} else {
stack.push(node);
}
}
} else {
push_node(
&mut stack,
&mut root,
Node::Text {
content: html.to_string(),
},
);
}
} else {
push_node(
&mut stack,
&mut root,
Node::Text {
content: html.to_string(),
},
);
}
}
Event::SoftBreak | Event::HardBreak => {
let node = Node::Text { content: "\n".to_string() };
if !stack.is_empty() {
let parent = stack.last_mut().unwrap();
if let Node::Element { children, .. } = parent {
children.push(node);
}
}
}
_ => {}
}
}
root
}
#[cfg(feature = "wasm")]
mod wasm {
use super::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn transpile(markdown: &str, allowed_tags: Vec<String>) -> Result<String, JsValue> {
let options = TranspileOptions { allowed_tags };
let ast = parse(markdown, &options);
serde_json::to_string(&ast).map_err(|e| JsValue::from_str(&e.to_string()))
}
}
#[cfg(feature = "android")]
mod android {
use super::*;
use jni::JNIEnv;
use jni::objects::{JClass, JString};
use jni::sys::jstring;
#[no_mangle]
pub extern "system" fn Java_com_clevertree_md2ast_MarkdownParser_nativeParse(
mut env: JNIEnv,
_class: JClass,
input: JString,
allowed_tags_json: JString,
) -> jstring {
let input: String = env.get_string(&input).expect("Couldn't get java string!").into();
let allowed_tags_json: String = env.get_string(&allowed_tags_json).expect("Couldn't get java string!").into();
let allowed_tags: Vec<String> = serde_json::from_str(&allowed_tags_json).unwrap_or_default();
let options = TranspileOptions { allowed_tags };
let ast = parse(&input, &options);
let result_json = serde_json::to_string(&ast).unwrap();
env.new_string(result_json).expect("Couldn't create java string!").into_raw()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn find_node<'a>(nodes: &'a [Node], tag_name: &str) -> Option<&'a Node> {
for node in nodes {
match node {
Node::Element { tag, children, .. } => {
if tag == tag_name {
return Some(node);
}
if let Some(found) = find_node(children, tag_name) {
return Some(found);
}
}
_ => {}
}
}
None
}
#[test]
fn test_gfm_footnotes() {
let markdown = "Here is a footnote[^1]\n\n[^1]: This is the footnote content.";
let options = TranspileOptions { allowed_tags: vec![] };
let ast = parse(markdown, &options);
println!("AST: {}", serde_json::to_string_pretty(&ast).unwrap());
let sup = find_node(&ast, "sup").expect("Should find sup for footnote ref");
if let Node::Element { children, .. } = sup {
let a = children.first().expect("Should have link child");
if let Node::Element { tag, props, .. } = a {
assert_eq!(tag, "a");
let href = props.get("href").unwrap().as_str().unwrap();
assert!(href.contains("#fn-1"));
}
}
let div = find_node(&ast, "div").expect("Should find footnote definition");
if let Node::Element { props, .. } = div {
assert_eq!(props.get("className").unwrap().as_str().unwrap(), "footnote-definition");
}
}
#[test]
fn test_basic_markdown() {
let markdown = "# Hello\nThis is **bold**";
let options = TranspileOptions { allowed_tags: vec![] };
let ast = parse(markdown, &options);
assert_eq!(ast.len(), 2);
if let Node::Element { tag, children, .. } = &ast[0] {
assert_eq!(tag, "h1");
assert_eq!(children[0], Node::Text { content: "Hello".to_string() });
} else {
panic!("Expected h1 element");
}
}
#[test]
fn test_html_tags() {
let markdown = "Hello <VideoPlayer src=\"test.mp4\" /> world";
let options = TranspileOptions { allowed_tags: vec!["VideoPlayer".to_string()] };
let ast = parse(markdown, &options);
let node = find_node(&ast, "VideoPlayer").expect("Should find VideoPlayer node");
if let Node::Element { props, .. } = node {
assert_eq!(props.get("src").unwrap(), "test.mp4");
}
}
#[test]
fn test_nested_html() {
let markdown = "<div>\n\n# Inside\n\n</div>";
let options = TranspileOptions { allowed_tags: vec!["div".to_string()] };
let ast = parse(markdown, &options);
assert!(find_node(&ast, "div").is_some());
}
#[test]
fn test_allowed_tags_filtering() {
let markdown = "<Allowed>Keep</Allowed><Forbidden>Drop</Forbidden>";
let options = TranspileOptions { allowed_tags: vec!["Allowed".to_string()] };
let ast = parse(markdown, &options);
assert!(find_node(&ast, "Allowed").is_some());
assert!(find_node(&ast, "Forbidden").is_none());
}
#[test]
fn test_corpus_image_json_title() {
let markdown = r#""#;
let options = TranspileOptions { allowed_tags: vec!["img".to_string()] };
let ast = parse(markdown, &options);
let img = find_node(&ast, "img").expect("img");
if let Node::Element { props, .. } = img {
assert_eq!(
props.get("src").and_then(|v| v.as_str()),
Some("/cosmos/mars/massacre.jpg?w=360")
);
assert_eq!(
props.get("className").and_then(|v| v.as_str()),
Some("rounded-md")
);
assert!(!props.contains_key("title"));
}
}
#[test]
fn test_images() {
let markdown = "";
let options = TranspileOptions { allowed_tags: vec!["img".to_string()] };
let ast = parse(markdown, &options);
let img = find_node(&ast, "img").expect("img node");
if let Node::Element { props, children, .. } = img {
assert_eq!(props.get("src").unwrap().as_str().unwrap(), "/path/img.jpg");
assert_eq!(props.get("alt").unwrap().as_str().unwrap(), "alt text");
assert!(children.is_empty());
} else {
panic!("expected element");
}
}
fn count_tag(nodes: &[Node], tag_name: &str) -> usize {
let mut n = 0;
for node in nodes {
if let Node::Element { tag, children, .. } = node {
if tag == tag_name {
n += 1;
}
n += count_tag(children, tag_name);
}
}
n
}
#[test]
fn test_multiline_paragraph_then_figure() {
let markdown = "# H1\n\nline1\nline2\nline3\n\n<figure>\n <iframe src=\"x\"/>\n</figure>\n\n## H2\n";
let allowed: Vec<String> = ["h1", "h2", "p", "figure", "iframe"]
.into_iter()
.map(String::from)
.collect();
let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
assert!(find_node(&ast, "figure").is_some(), "{:?}", ast);
assert!(count_tag(&ast, "h2") >= 1);
}
#[test]
fn test_smart_apostrophe_paragraph() {
let markdown = "# H1\n\nbut now I believe that the book\u{2019}s text was not redacted.\n\n## H2\n";
let allowed: Vec<String> = ["h1", "h2", "p"].into_iter().map(String::from).collect();
let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
assert!(count_tag(&ast, "h2") >= 1, "{:?}", ast);
}
#[test]
fn test_minimal_figure_block() {
let markdown = "# H1\n\npara\n\n<figure>\n <iframe src=\"x\"/>\n</figure>\n\n## H2\n";
let allowed: Vec<String> = ["h1", "h2", "p", "figure", "iframe"]
.into_iter()
.map(String::from)
.collect();
let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
eprintln!("{}", serde_json::to_string_pretty(&ast).unwrap());
assert!(find_node(&ast, "figure").is_some());
assert!(count_tag(&ast, "h2") >= 1);
}
#[test]
fn test_pulldown_mars_events() {
use pulldown_cmark::{Event, Options, Parser, Tag};
let md = r#"# The Deep State is on Mars
This article has been rewritten with the following updates.
Originally I said the Martian appearances in the War of the Worlds book must have been a redaction by H.G. Wells,
but now I believe that the book's text was not redacted.
<figure className="sm:float-right clear-right m-auto sm:m-1 sm:ml-4 sm:max-w-sm">
<iframe width="360" height="200" src="https://www.youtube.com/embed/FWp9TfS8ny4" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen="true" className="max-w-full m-auto hidden sm:block"/>
<figcaption className='flex justify-center'>
Listen to an auto-generated version of this article
</figcaption>
</figure>
## What is the Deep State?
"#;
let mut opts = Options::empty();
opts.insert(Options::ENABLE_SMART_PUNCTUATION);
let parser = Parser::new_ext(md, opts);
let mut n = 0;
for e in parser {
n += 1;
if n > 80 { break; }
println!("{:?}", e);
}
}
#[test]
fn test_pulldown_html_block_events() {
use pulldown_cmark::{Event, Options, Parser};
let md = r#"# Title
para
<figure className="sm:float-right">
<iframe width="360" src="https://www.youtube.com/embed/x" />
<figcaption>cap</figcaption>
</figure>
## Next
"#;
let parser = Parser::new_ext(md, Options::empty());
for e in parser {
if let Event::Html(h) | Event::InlineHtml(h) = e {
println!("HTML: {:?}", h);
}
}
}
#[test]
fn test_figcaption_multiline() {
let markdown = "# H1\n\n<figure>\n <iframe src=\"x\"/>\n <figcaption className='flex'>\n caption text\n </figcaption>\n</figure>\n\n## H2\n";
let allowed: Vec<String> = ["h1", "h2", "figure", "figcaption", "iframe"].into_iter().map(String::from).collect();
let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
eprintln!("{}", serde_json::to_string_pretty(&ast).unwrap_or_default());
assert!(find_node(&ast, "figure").is_some());
assert!(count_tag(&ast, "h2") >= 1);
}
#[test]
fn test_iframe_allow_semicolons() {
let markdown = "# H1\n\n<figure>\n <iframe allow=\"accelerometer; autoplay\"/>\n</figure>\n\n## H2\n";
let allowed: Vec<String> = ["h1", "h2", "figure", "iframe"].into_iter().map(String::from).collect();
let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
eprintln!("{}", serde_json::to_string_pretty(&ast).unwrap_or_default());
assert!(find_node(&ast, "figure").is_some());
assert!(count_tag(&ast, "h2") >= 1);
}
#[test]
fn test_pulldown_mars_iframe_events() {
use pulldown_cmark::{Event, Options, Parser};
let md = r#"# H1
para
<figure className="sm:float-right">
<iframe width="360" src="https://www.youtube.com/embed/x" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen="true" className="max-w-full"/>
</figure>
## H2
"#;
let parser = Parser::new_ext(md, Options::empty());
for e in parser {
println!("{:?}", e);
}
}
#[test]
fn test_mars_exact_iframe_attrs() {
let markdown = r#"# H1
para
<figure className="sm:float-right clear-right m-auto sm:m-1 sm:ml-4 sm:max-w-sm">
<iframe width="360" height="200" src="https://www.youtube.com/embed/FWp9TfS8ny4" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen="true" className="max-w-full m-auto hidden sm:block"/>
<figcaption className='flex justify-center'>
Listen to an auto-generated version of this article
</figcaption>
</figure>
## H2
"#;
let allowed: Vec<String> = ["h1", "h2", "p", "figure", "figcaption", "iframe"]
.into_iter()
.map(String::from)
.collect();
let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
if find_node(&ast, "figure").is_none() {
eprintln!("{}", serde_json::to_string_pretty(&ast).unwrap());
}
assert!(find_node(&ast, "figure").is_some());
assert!(count_tag(&ast, "h2") >= 1);
}
#[test]
fn test_mars_lead_block_and_figure() {
let markdown = r#"# The Deep State is on Mars
This article has been rewritten with the following updates.
Originally I said the Martian appearances in the War of the Worlds book must have been a redaction by H.G. Wells,
but now I believe that the book's text was not redacted.
<figure className="sm:float-right clear-right m-auto sm:m-1 sm:ml-4 sm:max-w-sm">
<iframe width="360" height="200" src="https://www.youtube.com/embed/FWp9TfS8ny4" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen="true" className="max-w-full m-auto hidden sm:block"/>
<iframe width="360" height="200" src="https://www.youtube.com/embed/9GyTNIh9Ffk" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen="true" className="max-w-full m-auto sm:hidden"/>
<figcaption className='flex justify-center'>
Listen to an auto-generated version of this article
</figcaption>
</figure>

## What is the Deep State?
The **Deep State** is an international technocratic cabal.
"#;
let allowed: Vec<String> = [
"h1", "h2", "p", "figure", "figcaption", "iframe", "img", "strong", "em", "a",
]
.into_iter()
.map(String::from)
.collect();
let options = TranspileOptions { allowed_tags: allowed };
let ast = parse(markdown, &options);
if find_node(&ast, "figure").is_none() {
eprintln!("{}", serde_json::to_string_pretty(&ast).unwrap_or_default());
}
assert!(find_node(&ast, "figure").is_some(), "expected figure");
assert!(find_node(&ast, "iframe").is_some(), "expected iframe");
assert!(find_node(&ast, "img").is_some(), "expected img");
assert!(count_tag(&ast, "h2") >= 1, "expected h2 after figure");
}
#[test]
fn test_gfm_table() {
let markdown = "| Header |\n| --- |\n| Cell |";
let options = TranspileOptions { allowed_tags: vec![] };
let ast = parse(markdown, &options);
assert!(find_node(&ast, "table").is_some());
assert!(find_node(&ast, "thead").is_some());
assert!(find_node(&ast, "td").is_some());
}
#[test]
fn test_strikethrough() {
let markdown = "~~deleted~~";
let options = TranspileOptions { allowed_tags: vec![] };
let ast = parse(markdown, &options);
assert!(find_node(&ast, "del").is_some());
}
#[test]
fn test_relay_readme() {
let markdown = r#"# Test Movie Repository
`v. 1.0.1`
Welcome! This is a sample content repository used to exercise the Relay server, client, and commit hooks.
"#;
let options = TranspileOptions { allowed_tags: vec![] };
let ast = parse(markdown, &options);
assert!(!ast.is_empty());
}
}