use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use serde::{Deserialize, Serialize};
const ALLOWED_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Node {
Paragraph {
content: Vec<Inline>,
},
Heading {
level: u8,
content: Vec<Inline>,
},
List {
ordered: bool,
items: Vec<Vec<Node>>,
},
CodeBlock {
language: Option<String>,
code: String,
},
BlockQuote {
content: Vec<Node>,
},
Rule,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Inline {
Text {
text: String,
},
Code {
code: String,
},
Emphasis {
content: Vec<Inline>,
},
Strong {
content: Vec<Inline>,
},
Strikethrough {
content: Vec<Inline>,
},
Link {
href: String,
content: Vec<Inline>,
},
Image {
source: ImageSource,
alt: String,
},
SoftBreak,
HardBreak,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ImageSource {
Local { path: String },
Blocked { url: String },
}
#[must_use]
pub fn render(markdown: &str) -> Vec<Node> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
let mut blocks = Vec::new();
let mut parser = Parser::new_ext(markdown, options).peekable();
while let Some(event) = parser.next() {
if let Some(node) = block(&mut parser, event) {
blocks.push(node);
}
}
blocks
}
type EventStream<'a> = std::iter::Peekable<Parser<'a>>;
fn block(p: &mut EventStream<'_>, event: Event<'_>) -> Option<Node> {
match event {
Event::Start(Tag::Paragraph) => Some(Node::Paragraph {
content: inlines(p, TagEnd::Paragraph),
}),
Event::Start(Tag::Heading { level, .. }) => Some(Node::Heading {
level: heading_level(level),
content: inlines(p, TagEnd::Heading(level)),
}),
Event::Start(Tag::CodeBlock(kind)) => {
let language = match kind {
pulldown_cmark::CodeBlockKind::Fenced(l) if !l.is_empty() => Some(l.to_string()),
_ => None,
};
let mut code = String::new();
for e in p.by_ref() {
match e {
Event::Text(t) | Event::Html(t) | Event::InlineHtml(t) => code.push_str(&t),
Event::End(TagEnd::CodeBlock) => break,
_ => {}
}
}
Some(Node::CodeBlock { language, code })
}
Event::Start(Tag::BlockQuote(_)) => {
let mut content = Vec::new();
while let Some(e) = p.next() {
if matches!(e, Event::End(TagEnd::BlockQuote(_))) {
break;
}
if let Some(n) = block(p, e) {
content.push(n);
}
}
Some(Node::BlockQuote { content })
}
Event::Start(Tag::List(start)) => {
let ordered = start.is_some();
let mut items = Vec::new();
while let Some(e) = p.next() {
match e {
Event::Start(Tag::Item) => {
let mut item = Vec::new();
while let Some(ie) = p.next() {
if matches!(ie, Event::End(TagEnd::Item)) {
break;
}
if opens_a_block(&ie) {
if let Some(n) = block(p, ie) {
item.push(n);
}
continue;
}
let produced = inline_events(p, ie);
if produced.is_empty() {
continue;
}
match item.last_mut() {
Some(Node::Paragraph { content }) => content.extend(produced),
_ => item.push(Node::Paragraph { content: produced }),
}
}
items.push(item);
}
Event::End(TagEnd::List(_)) => break,
_ => {}
}
}
Some(Node::List { ordered, items })
}
Event::Rule => Some(Node::Rule),
other => inline_of(&other).map(|i| Node::Paragraph { content: vec![i] }),
}
}
fn inlines(p: &mut EventStream<'_>, until: TagEnd) -> Vec<Inline> {
let mut out = Vec::new();
while let Some(e) = p.next() {
if e == Event::End(until) {
break;
}
out.extend(inline_events(p, e));
}
out
}
fn inline_events(p: &mut EventStream<'_>, e: Event<'_>) -> Vec<Inline> {
match e {
Event::Start(Tag::Emphasis) => vec![Inline::Emphasis {
content: inlines(p, TagEnd::Emphasis),
}],
Event::Start(Tag::Strong) => vec![Inline::Strong {
content: inlines(p, TagEnd::Strong),
}],
Event::Start(Tag::Strikethrough) => vec![Inline::Strikethrough {
content: inlines(p, TagEnd::Strikethrough),
}],
Event::Start(Tag::Link { dest_url, .. }) => {
let content = inlines(p, TagEnd::Link);
match sanitize_href(&dest_url) {
None => content,
Some(href) => vec![Inline::Link { href, content }],
}
}
Event::Start(Tag::Image { dest_url, .. }) => {
let alt = plain_text(&inlines(p, TagEnd::Image));
vec![Inline::Image {
source: image_source(&dest_url),
alt,
}]
}
other => inline_of(&other).into_iter().collect(),
}
}
fn opens_a_block(e: &Event<'_>) -> bool {
matches!(
e,
Event::Start(
Tag::Paragraph
| Tag::Heading { .. }
| Tag::CodeBlock(_)
| Tag::BlockQuote(_)
| Tag::List(_)
) | Event::Rule
)
}
fn inline_of(e: &Event<'_>) -> Option<Inline> {
match e {
Event::Text(t) => Some(Inline::Text {
text: t.to_string(),
}),
Event::Code(c) => Some(Inline::Code {
code: c.to_string(),
}),
Event::Html(h) | Event::InlineHtml(h) => Some(Inline::Text {
text: h.to_string(),
}),
Event::SoftBreak => Some(Inline::SoftBreak),
Event::HardBreak => Some(Inline::HardBreak),
_ => None,
}
}
fn sanitize_href(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.chars().any(char::is_control) {
return None;
}
match scheme_of(trimmed) {
None => Some(trimmed.to_owned()),
Some(scheme) => ALLOWED_SCHEMES
.contains(&scheme.as_str())
.then(|| trimmed.to_owned()),
}
}
fn scheme_of(s: &str) -> Option<String> {
let end = s.find(':')?;
let candidate = &s[..end];
if candidate.is_empty() || s[..end].contains(['/', '?', '#']) {
return None;
}
let mut chars = candidate.chars();
let first = chars.next()?;
if !first.is_ascii_alphabetic() {
return None;
}
if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
return None;
}
Some(candidate.to_ascii_lowercase())
}
fn image_source(raw: &str) -> ImageSource {
let trimmed = raw.trim();
match scheme_of(trimmed) {
None => ImageSource::Local {
path: trimmed.to_owned(),
},
Some(s) if s == "file" => ImageSource::Local {
path: trimmed.to_owned(),
},
_ => ImageSource::Blocked {
url: trimmed.to_owned(),
},
}
}
fn plain_text(inlines: &[Inline]) -> String {
let mut s = String::new();
for i in inlines {
match i {
Inline::Text { text } => s.push_str(text),
Inline::Code { code } => s.push_str(code),
Inline::Emphasis { content }
| Inline::Strong { content }
| Inline::Strikethrough { content }
| Inline::Link { content, .. } => s.push_str(&plain_text(content)),
Inline::SoftBreak | Inline::HardBreak => s.push(' '),
Inline::Image { alt, .. } => s.push_str(alt),
}
}
s
}
fn heading_level(l: HeadingLevel) -> u8 {
match l {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn visible(nodes: &[Node]) -> String {
fn walk(nodes: &[Node], out: &mut String) {
for n in nodes {
match n {
Node::Paragraph { content } | Node::Heading { content, .. } => {
out.push_str(&plain_text(content));
out.push('\n');
}
Node::CodeBlock { code, .. } => out.push_str(code),
Node::BlockQuote { content } => walk(content, out),
Node::List { items, .. } => {
for i in items {
walk(i, out);
}
}
Node::Rule => out.push('\n'),
}
}
}
let mut s = String::new();
walk(nodes, &mut s);
s
}
fn links(nodes: &[Node]) -> Vec<String> {
fn walk_inline(is: &[Inline], out: &mut Vec<String>) {
for i in is {
match i {
Inline::Link { href, content } => {
out.push(href.clone());
walk_inline(content, out);
}
Inline::Emphasis { content }
| Inline::Strong { content }
| Inline::Strikethrough { content } => walk_inline(content, out),
_ => {}
}
}
}
fn walk(nodes: &[Node], out: &mut Vec<String>) {
for n in nodes {
match n {
Node::Paragraph { content } | Node::Heading { content, .. } => {
walk_inline(content, out);
}
Node::BlockQuote { content } => walk(content, out),
Node::List { items, .. } => {
for i in items {
walk(i, out);
}
}
_ => {}
}
}
}
let mut v = Vec::new();
walk(nodes, &mut v);
v
}
fn images(nodes: &[Node]) -> Vec<ImageSource> {
let mut out = Vec::new();
fn walk_inline(is: &[Inline], out: &mut Vec<ImageSource>) {
for i in is {
match i {
Inline::Image { source, .. } => out.push(source.clone()),
Inline::Emphasis { content }
| Inline::Strong { content }
| Inline::Strikethrough { content }
| Inline::Link { content, .. } => walk_inline(content, out),
_ => {}
}
}
}
for n in nodes {
if let Node::Paragraph { content } | Node::Heading { content, .. } = n {
walk_inline(content, &mut out);
}
}
out
}
#[test]
fn ordinary_markdown_still_formats() {
let nodes = render("# Title\n\nSome **bold** and `code`.\n\n- one\n- two\n");
assert!(matches!(nodes[0], Node::Heading { level: 1, .. }));
let seen = visible(&nodes);
assert!(seen.contains("Title"), "{seen}");
assert!(seen.contains("bold"), "{seen}");
assert!(seen.contains("one"), "{seen}");
assert!(
nodes.iter().any(|n| matches!(n, Node::List { .. })),
"expected a list: {nodes:#?}"
);
}
#[test]
fn fenced_code_survives_verbatim() {
let nodes = render("```rust\nlet x = 1;\n```\n");
let Some(Node::CodeBlock { language, code }) = nodes.first() else {
panic!("expected a code block, got {nodes:#?}");
};
assert_eq!(language.as_deref(), Some("rust"));
assert_eq!(code, "let x = 1;\n");
}
#[test]
fn raw_html_becomes_literal_text() {
for body in [
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"<iframe src=\"https://evil.example\"></iframe>",
"hello <b>there</b>",
"<svg/onload=alert(1)>",
] {
let nodes = render(body);
let seen = visible(&nodes);
assert!(
seen.contains('<'),
"markup should survive as text for {body:?}, got {seen:?}"
);
assert!(
links(&nodes).is_empty(),
"raw html produced a link for {body:?}"
);
}
}
#[test]
fn dangerous_link_schemes_lose_the_link_and_keep_the_text() {
for body in [
"[click](javascript:alert(1))",
"[click](JaVaScRiPt:alert(1))",
"[click]( javascript:alert(1) )",
"[click](data:text/html;base64,PHNjcmlwdD4=)",
"[click](vbscript:msgbox)",
"[click](file:///etc/passwd)",
] {
let nodes = render(body);
assert!(
links(&nodes).is_empty(),
"{body:?} should not produce a link, got {:?}",
links(&nodes)
);
assert!(
visible(&nodes).contains("click"),
"{body:?} should keep its text"
);
}
}
#[test]
fn control_characters_cannot_smuggle_a_scheme() {
for body in [
"[x](java\u{0}script:alert(1))",
"[x](java\nscript:alert(1))",
"[x](\u{0}javascript:alert(1))",
] {
let nodes = render(body);
assert!(
links(&nodes).is_empty(),
"{body:?} smuggled a link through: {:?}",
links(&nodes)
);
}
}
#[test]
fn ordinary_links_are_kept() {
let nodes = render("[docs](https://example.com/a?b=1#c) and [mail](mailto:a@b.c)");
let hrefs = links(&nodes);
assert!(
hrefs.contains(&"https://example.com/a?b=1#c".to_owned()),
"{hrefs:?}"
);
assert!(hrefs.contains(&"mailto:a@b.c".to_owned()), "{hrefs:?}");
}
#[test]
fn relative_and_fragment_links_are_kept() {
let nodes = render("[a](#section) [b](./notes.md) [c](/abs/path)");
assert_eq!(links(&nodes).len(), 3, "{:?}", links(&nodes));
}
#[test]
fn remote_images_are_blocked_local_ones_are_not() {
let nodes = render("");
assert_eq!(
images(&nodes),
vec![ImageSource::Blocked {
url: "https://evil.example/pixel.png".to_owned()
}]
);
let nodes = render("");
assert_eq!(
images(&nodes),
vec![ImageSource::Local {
path: "./diagram.png".to_owned()
}]
);
}
#[test]
fn image_alt_text_is_plain() {
let nodes = render("");
let Some(Node::Paragraph { content }) = nodes.first() else {
panic!("expected paragraph, got {nodes:#?}");
};
let Some(Inline::Image { alt, .. }) = content.first() else {
panic!("expected image, got {content:#?}");
};
assert!(alt.contains("script"), "alt should keep text: {alt:?}");
}
#[test]
fn a_plain_message_is_still_a_paragraph() {
let nodes = render("just a normal message");
assert_eq!(visible(&nodes).trim(), "just a normal message");
}
#[test]
fn empty_input_is_empty_output() {
assert!(render("").is_empty());
assert!(render(" \n \n").is_empty());
}
#[test]
fn nested_structures_do_not_lose_content() {
let nodes = render("> quoted **bold**\n>\n> - item one\n> - item two\n");
let seen = visible(&nodes);
assert!(seen.contains("quoted"), "{seen}");
assert!(seen.contains("bold"), "{seen}");
assert!(seen.contains("item one"), "{seen}");
assert!(seen.contains("item two"), "{seen}");
}
#[test]
fn scheme_detection_does_not_trip_on_paths() {
assert_eq!(scheme_of("https://a.example/x:y"), Some("https".into()));
assert_eq!(scheme_of("/path/to:thing"), None);
assert_eq!(scheme_of("#frag:ment"), None);
assert_eq!(scheme_of("no-colon-here"), None);
assert_eq!(scheme_of("1nvalid:x"), None);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod list_tests {
use super::*;
#[test]
fn inline_formatting_does_not_split_a_list_item() {
let nodes = render("- plain **bold** and `code` and [a link](https://e.example) tail\n");
let Some(Node::List { items, .. }) = nodes.first() else {
panic!("expected a list, got {nodes:#?}");
};
assert_eq!(items.len(), 1, "one bullet");
assert_eq!(
items[0].len(),
1,
"a bullet with inline formatting must stay one block, got {:#?}",
items[0]
);
let Node::Paragraph { content } = &items[0][0] else {
panic!("expected a paragraph, got {:#?}", items[0][0]);
};
let kinds: Vec<&str> = content
.iter()
.map(|i| match i {
Inline::Text { .. } => "text",
Inline::Strong { .. } => "strong",
Inline::Code { .. } => "code",
Inline::Link { .. } => "link",
_ => "other",
})
.collect();
assert!(kinds.contains(&"strong"), "{kinds:?}");
assert!(kinds.contains(&"code"), "{kinds:?}");
assert!(kinds.contains(&"link"), "{kinds:?}");
}
#[test]
fn emoji_survive_intact() {
let nodes = render("- 🔒 locked\n- ✍️ writing\n- ⚡ fast\n");
let Some(Node::List { items, .. }) = nodes.first() else {
panic!("expected a list");
};
assert_eq!(items.len(), 3);
let first = match &items[0][0] {
Node::Paragraph { content } => content,
other => panic!("expected paragraph, got {other:#?}"),
};
let Inline::Text { text } = &first[0] else {
panic!("expected text");
};
assert!(text.starts_with('🔒'), "{text:?}");
let second = match &items[1][0] {
Node::Paragraph { content } => content,
other => panic!("expected paragraph, got {other:#?}"),
};
let Inline::Text { text } = &second[0] else {
panic!("expected text");
};
assert!(text.contains('\u{270D}'), "{text:?}");
}
#[test]
fn a_list_item_with_real_blocks_keeps_them() {
let nodes = render("- top\n - nested\n");
let Some(Node::List { items, .. }) = nodes.first() else {
panic!("expected a list");
};
assert!(
items[0].iter().any(|n| matches!(n, Node::List { .. })),
"nested list should survive: {:#?}",
items[0]
);
}
}