1use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
28use serde::{Deserialize, Serialize};
29
30const ALLOWED_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum Node {
36 Paragraph {
37 content: Vec<Inline>,
38 },
39 Heading {
40 level: u8,
41 content: Vec<Inline>,
42 },
43 List {
44 ordered: bool,
45 items: Vec<Vec<Node>>,
46 },
47 CodeBlock {
48 language: Option<String>,
49 code: String,
50 },
51 BlockQuote {
52 content: Vec<Node>,
53 },
54 Rule,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum Inline {
60 Text {
61 text: String,
62 },
63 Code {
64 code: String,
65 },
66 Emphasis {
67 content: Vec<Inline>,
68 },
69 Strong {
70 content: Vec<Inline>,
71 },
72 Strikethrough {
73 content: Vec<Inline>,
74 },
75 Link {
77 href: String,
78 content: Vec<Inline>,
79 },
80 Image {
81 source: ImageSource,
82 alt: String,
83 },
84 SoftBreak,
85 HardBreak,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(tag = "kind", rename_all = "snake_case")]
90pub enum ImageSource {
91 Local { path: String },
94 Blocked { url: String },
98}
99
100#[must_use]
102pub fn render(markdown: &str) -> Vec<Node> {
103 let mut options = Options::empty();
104 options.insert(Options::ENABLE_STRIKETHROUGH);
105 options.insert(Options::ENABLE_TABLES);
106 options.insert(Options::ENABLE_FOOTNOTES);
107 let mut blocks = Vec::new();
111 let mut parser = Parser::new_ext(markdown, options).peekable();
112 while let Some(event) = parser.next() {
113 if let Some(node) = block(&mut parser, event) {
114 blocks.push(node);
115 }
116 }
117 blocks
118}
119
120type EventStream<'a> = std::iter::Peekable<Parser<'a>>;
121
122fn block(p: &mut EventStream<'_>, event: Event<'_>) -> Option<Node> {
123 match event {
124 Event::Start(Tag::Paragraph) => Some(Node::Paragraph {
125 content: inlines(p, TagEnd::Paragraph),
126 }),
127 Event::Start(Tag::Heading { level, .. }) => Some(Node::Heading {
128 level: heading_level(level),
129 content: inlines(p, TagEnd::Heading(level)),
130 }),
131 Event::Start(Tag::CodeBlock(kind)) => {
132 let language = match kind {
133 pulldown_cmark::CodeBlockKind::Fenced(l) if !l.is_empty() => Some(l.to_string()),
134 _ => None,
135 };
136 let mut code = String::new();
137 for e in p.by_ref() {
138 match e {
139 Event::Text(t) | Event::Html(t) | Event::InlineHtml(t) => code.push_str(&t),
142 Event::End(TagEnd::CodeBlock) => break,
143 _ => {}
144 }
145 }
146 Some(Node::CodeBlock { language, code })
147 }
148 Event::Start(Tag::BlockQuote(_)) => {
149 let mut content = Vec::new();
150 while let Some(e) = p.next() {
151 if matches!(e, Event::End(TagEnd::BlockQuote(_))) {
152 break;
153 }
154 if let Some(n) = block(p, e) {
155 content.push(n);
156 }
157 }
158 Some(Node::BlockQuote { content })
159 }
160 Event::Start(Tag::List(start)) => {
161 let ordered = start.is_some();
162 let mut items = Vec::new();
163 while let Some(e) = p.next() {
164 match e {
165 Event::Start(Tag::Item) => {
166 let mut item = Vec::new();
167 while let Some(ie) = p.next() {
168 if matches!(ie, Event::End(TagEnd::Item)) {
169 break;
170 }
171 if opens_a_block(&ie) {
172 if let Some(n) = block(p, ie) {
173 item.push(n);
174 }
175 continue;
176 }
177 let produced = inline_events(p, ie);
183 if produced.is_empty() {
184 continue;
185 }
186 match item.last_mut() {
187 Some(Node::Paragraph { content }) => content.extend(produced),
188 _ => item.push(Node::Paragraph { content: produced }),
189 }
190 }
191 items.push(item);
192 }
193 Event::End(TagEnd::List(_)) => break,
194 _ => {}
195 }
196 }
197 Some(Node::List { ordered, items })
198 }
199 Event::Rule => Some(Node::Rule),
200 other => inline_of(&other).map(|i| Node::Paragraph { content: vec![i] }),
202 }
203}
204
205fn inlines(p: &mut EventStream<'_>, until: TagEnd) -> Vec<Inline> {
206 let mut out = Vec::new();
207 while let Some(e) = p.next() {
208 if e == Event::End(until) {
209 break;
210 }
211 out.extend(inline_events(p, e));
212 }
213 out
214}
215
216fn inline_events(p: &mut EventStream<'_>, e: Event<'_>) -> Vec<Inline> {
222 match e {
223 Event::Start(Tag::Emphasis) => vec![Inline::Emphasis {
224 content: inlines(p, TagEnd::Emphasis),
225 }],
226 Event::Start(Tag::Strong) => vec![Inline::Strong {
227 content: inlines(p, TagEnd::Strong),
228 }],
229 Event::Start(Tag::Strikethrough) => vec![Inline::Strikethrough {
230 content: inlines(p, TagEnd::Strikethrough),
231 }],
232 Event::Start(Tag::Link { dest_url, .. }) => {
233 let content = inlines(p, TagEnd::Link);
234 match sanitize_href(&dest_url) {
235 None => content,
237 Some(href) => vec![Inline::Link { href, content }],
238 }
239 }
240 Event::Start(Tag::Image { dest_url, .. }) => {
241 let alt = plain_text(&inlines(p, TagEnd::Image));
242 vec![Inline::Image {
243 source: image_source(&dest_url),
244 alt,
245 }]
246 }
247 other => inline_of(&other).into_iter().collect(),
248 }
249}
250
251fn opens_a_block(e: &Event<'_>) -> bool {
254 matches!(
255 e,
256 Event::Start(
257 Tag::Paragraph
258 | Tag::Heading { .. }
259 | Tag::CodeBlock(_)
260 | Tag::BlockQuote(_)
261 | Tag::List(_)
262 ) | Event::Rule
263 )
264}
265
266fn inline_of(e: &Event<'_>) -> Option<Inline> {
267 match e {
268 Event::Text(t) => Some(Inline::Text {
269 text: t.to_string(),
270 }),
271 Event::Code(c) => Some(Inline::Code {
272 code: c.to_string(),
273 }),
274 Event::Html(h) | Event::InlineHtml(h) => Some(Inline::Text {
277 text: h.to_string(),
278 }),
279 Event::SoftBreak => Some(Inline::SoftBreak),
280 Event::HardBreak => Some(Inline::HardBreak),
281 _ => None,
282 }
283}
284
285fn sanitize_href(raw: &str) -> Option<String> {
289 let trimmed = raw.trim();
290 if trimmed.is_empty() {
291 return None;
292 }
293 if trimmed.chars().any(char::is_control) {
296 return None;
297 }
298 match scheme_of(trimmed) {
299 None => Some(trimmed.to_owned()),
300 Some(scheme) => ALLOWED_SCHEMES
301 .contains(&scheme.as_str())
302 .then(|| trimmed.to_owned()),
303 }
304}
305
306fn scheme_of(s: &str) -> Option<String> {
309 let end = s.find(':')?;
310 let candidate = &s[..end];
311 if candidate.is_empty() || s[..end].contains(['/', '?', '#']) {
312 return None;
313 }
314 let mut chars = candidate.chars();
316 let first = chars.next()?;
317 if !first.is_ascii_alphabetic() {
318 return None;
319 }
320 if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
321 return None;
322 }
323 Some(candidate.to_ascii_lowercase())
324}
325
326fn image_source(raw: &str) -> ImageSource {
327 let trimmed = raw.trim();
328 match scheme_of(trimmed) {
329 None => ImageSource::Local {
331 path: trimmed.to_owned(),
332 },
333 Some(s) if s == "file" => ImageSource::Local {
334 path: trimmed.to_owned(),
335 },
336 _ => ImageSource::Blocked {
337 url: trimmed.to_owned(),
338 },
339 }
340}
341
342fn plain_text(inlines: &[Inline]) -> String {
343 let mut s = String::new();
344 for i in inlines {
345 match i {
346 Inline::Text { text } => s.push_str(text),
347 Inline::Code { code } => s.push_str(code),
348 Inline::Emphasis { content }
349 | Inline::Strong { content }
350 | Inline::Strikethrough { content }
351 | Inline::Link { content, .. } => s.push_str(&plain_text(content)),
352 Inline::SoftBreak | Inline::HardBreak => s.push(' '),
353 Inline::Image { alt, .. } => s.push_str(alt),
354 }
355 }
356 s
357}
358
359fn heading_level(l: HeadingLevel) -> u8 {
360 match l {
361 HeadingLevel::H1 => 1,
362 HeadingLevel::H2 => 2,
363 HeadingLevel::H3 => 3,
364 HeadingLevel::H4 => 4,
365 HeadingLevel::H5 => 5,
366 HeadingLevel::H6 => 6,
367 }
368}
369
370#[cfg(test)]
371#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
372mod tests {
373 use super::*;
374
375 fn visible(nodes: &[Node]) -> String {
378 fn walk(nodes: &[Node], out: &mut String) {
379 for n in nodes {
380 match n {
381 Node::Paragraph { content } | Node::Heading { content, .. } => {
382 out.push_str(&plain_text(content));
383 out.push('\n');
384 }
385 Node::CodeBlock { code, .. } => out.push_str(code),
386 Node::BlockQuote { content } => walk(content, out),
387 Node::List { items, .. } => {
388 for i in items {
389 walk(i, out);
390 }
391 }
392 Node::Rule => out.push('\n'),
393 }
394 }
395 }
396 let mut s = String::new();
397 walk(nodes, &mut s);
398 s
399 }
400
401 fn links(nodes: &[Node]) -> Vec<String> {
402 fn walk_inline(is: &[Inline], out: &mut Vec<String>) {
403 for i in is {
404 match i {
405 Inline::Link { href, content } => {
406 out.push(href.clone());
407 walk_inline(content, out);
408 }
409 Inline::Emphasis { content }
410 | Inline::Strong { content }
411 | Inline::Strikethrough { content } => walk_inline(content, out),
412 _ => {}
413 }
414 }
415 }
416 fn walk(nodes: &[Node], out: &mut Vec<String>) {
417 for n in nodes {
418 match n {
419 Node::Paragraph { content } | Node::Heading { content, .. } => {
420 walk_inline(content, out);
421 }
422 Node::BlockQuote { content } => walk(content, out),
423 Node::List { items, .. } => {
424 for i in items {
425 walk(i, out);
426 }
427 }
428 _ => {}
429 }
430 }
431 }
432 let mut v = Vec::new();
433 walk(nodes, &mut v);
434 v
435 }
436
437 fn images(nodes: &[Node]) -> Vec<ImageSource> {
438 let mut out = Vec::new();
439 fn walk_inline(is: &[Inline], out: &mut Vec<ImageSource>) {
440 for i in is {
441 match i {
442 Inline::Image { source, .. } => out.push(source.clone()),
443 Inline::Emphasis { content }
444 | Inline::Strong { content }
445 | Inline::Strikethrough { content }
446 | Inline::Link { content, .. } => walk_inline(content, out),
447 _ => {}
448 }
449 }
450 }
451 for n in nodes {
452 if let Node::Paragraph { content } | Node::Heading { content, .. } = n {
453 walk_inline(content, &mut out);
454 }
455 }
456 out
457 }
458
459 #[test]
460 fn ordinary_markdown_still_formats() {
461 let nodes = render("# Title\n\nSome **bold** and `code`.\n\n- one\n- two\n");
462 assert!(matches!(nodes[0], Node::Heading { level: 1, .. }));
463 let seen = visible(&nodes);
464 assert!(seen.contains("Title"), "{seen}");
465 assert!(seen.contains("bold"), "{seen}");
466 assert!(seen.contains("one"), "{seen}");
467 assert!(
468 nodes.iter().any(|n| matches!(n, Node::List { .. })),
469 "expected a list: {nodes:#?}"
470 );
471 }
472
473 #[test]
474 fn fenced_code_survives_verbatim() {
475 let nodes = render("```rust\nlet x = 1;\n```\n");
476 let Some(Node::CodeBlock { language, code }) = nodes.first() else {
477 panic!("expected a code block, got {nodes:#?}");
478 };
479 assert_eq!(language.as_deref(), Some("rust"));
480 assert_eq!(code, "let x = 1;\n");
481 }
482
483 #[test]
485 fn raw_html_becomes_literal_text() {
486 for body in [
487 "<script>alert(1)</script>",
488 "<img src=x onerror=alert(1)>",
489 "<iframe src=\"https://evil.example\"></iframe>",
490 "hello <b>there</b>",
491 "<svg/onload=alert(1)>",
492 ] {
493 let nodes = render(body);
494 let seen = visible(&nodes);
495 assert!(
496 seen.contains('<'),
497 "markup should survive as text for {body:?}, got {seen:?}"
498 );
499 assert!(
501 links(&nodes).is_empty(),
502 "raw html produced a link for {body:?}"
503 );
504 }
505 }
506
507 #[test]
508 fn dangerous_link_schemes_lose_the_link_and_keep_the_text() {
509 for body in [
510 "[click](javascript:alert(1))",
511 "[click](JaVaScRiPt:alert(1))",
512 "[click]( javascript:alert(1) )",
513 "[click](data:text/html;base64,PHNjcmlwdD4=)",
514 "[click](vbscript:msgbox)",
515 "[click](file:///etc/passwd)",
516 ] {
517 let nodes = render(body);
518 assert!(
519 links(&nodes).is_empty(),
520 "{body:?} should not produce a link, got {:?}",
521 links(&nodes)
522 );
523 assert!(
524 visible(&nodes).contains("click"),
525 "{body:?} should keep its text"
526 );
527 }
528 }
529
530 #[test]
531 fn control_characters_cannot_smuggle_a_scheme() {
532 for body in [
535 "[x](java\u{0}script:alert(1))",
536 "[x](java\nscript:alert(1))",
537 "[x](\u{0}javascript:alert(1))",
538 ] {
539 let nodes = render(body);
540 assert!(
541 links(&nodes).is_empty(),
542 "{body:?} smuggled a link through: {:?}",
543 links(&nodes)
544 );
545 }
546 }
547
548 #[test]
549 fn ordinary_links_are_kept() {
550 let nodes = render("[docs](https://example.com/a?b=1#c) and [mail](mailto:a@b.c)");
551 let hrefs = links(&nodes);
552 assert!(
553 hrefs.contains(&"https://example.com/a?b=1#c".to_owned()),
554 "{hrefs:?}"
555 );
556 assert!(hrefs.contains(&"mailto:a@b.c".to_owned()), "{hrefs:?}");
557 }
558
559 #[test]
560 fn relative_and_fragment_links_are_kept() {
561 let nodes = render("[a](#section) [b](./notes.md) [c](/abs/path)");
563 assert_eq!(links(&nodes).len(), 3, "{:?}", links(&nodes));
564 }
565
566 #[test]
569 fn remote_images_are_blocked_local_ones_are_not() {
570 let nodes = render("");
571 assert_eq!(
572 images(&nodes),
573 vec![ImageSource::Blocked {
574 url: "https://evil.example/pixel.png".to_owned()
575 }]
576 );
577
578 let nodes = render("");
579 assert_eq!(
580 images(&nodes),
581 vec![ImageSource::Local {
582 path: "./diagram.png".to_owned()
583 }]
584 );
585 }
586
587 #[test]
588 fn image_alt_text_is_plain() {
589 let nodes = render("");
590 let Some(Node::Paragraph { content }) = nodes.first() else {
591 panic!("expected paragraph, got {nodes:#?}");
592 };
593 let Some(Inline::Image { alt, .. }) = content.first() else {
594 panic!("expected image, got {content:#?}");
595 };
596 assert!(alt.contains("script"), "alt should keep text: {alt:?}");
597 }
598
599 #[test]
600 fn a_plain_message_is_still_a_paragraph() {
601 let nodes = render("just a normal message");
603 assert_eq!(visible(&nodes).trim(), "just a normal message");
604 }
605
606 #[test]
607 fn empty_input_is_empty_output() {
608 assert!(render("").is_empty());
609 assert!(render(" \n \n").is_empty());
610 }
611
612 #[test]
613 fn nested_structures_do_not_lose_content() {
614 let nodes = render("> quoted **bold**\n>\n> - item one\n> - item two\n");
615 let seen = visible(&nodes);
616 assert!(seen.contains("quoted"), "{seen}");
617 assert!(seen.contains("bold"), "{seen}");
618 assert!(seen.contains("item one"), "{seen}");
619 assert!(seen.contains("item two"), "{seen}");
620 }
621
622 #[test]
623 fn scheme_detection_does_not_trip_on_paths() {
624 assert_eq!(scheme_of("https://a.example/x:y"), Some("https".into()));
626 assert_eq!(scheme_of("/path/to:thing"), None);
627 assert_eq!(scheme_of("#frag:ment"), None);
628 assert_eq!(scheme_of("no-colon-here"), None);
629 assert_eq!(scheme_of("1nvalid:x"), None);
630 }
631}
632
633#[cfg(test)]
634#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
635mod list_tests {
636 use super::*;
637
638 #[test]
644 fn inline_formatting_does_not_split_a_list_item() {
645 let nodes = render("- plain **bold** and `code` and [a link](https://e.example) tail\n");
646 let Some(Node::List { items, .. }) = nodes.first() else {
647 panic!("expected a list, got {nodes:#?}");
648 };
649 assert_eq!(items.len(), 1, "one bullet");
650 assert_eq!(
651 items[0].len(),
652 1,
653 "a bullet with inline formatting must stay one block, got {:#?}",
654 items[0]
655 );
656 let Node::Paragraph { content } = &items[0][0] else {
657 panic!("expected a paragraph, got {:#?}", items[0][0]);
658 };
659 let kinds: Vec<&str> = content
660 .iter()
661 .map(|i| match i {
662 Inline::Text { .. } => "text",
663 Inline::Strong { .. } => "strong",
664 Inline::Code { .. } => "code",
665 Inline::Link { .. } => "link",
666 _ => "other",
667 })
668 .collect();
669 assert!(kinds.contains(&"strong"), "{kinds:?}");
670 assert!(kinds.contains(&"code"), "{kinds:?}");
671 assert!(kinds.contains(&"link"), "{kinds:?}");
672 }
673
674 #[test]
677 fn emoji_survive_intact() {
678 let nodes = render("- 🔒 locked\n- ✍️ writing\n- ⚡ fast\n");
679 let Some(Node::List { items, .. }) = nodes.first() else {
680 panic!("expected a list");
681 };
682 assert_eq!(items.len(), 3);
683 let first = match &items[0][0] {
684 Node::Paragraph { content } => content,
685 other => panic!("expected paragraph, got {other:#?}"),
686 };
687 let Inline::Text { text } = &first[0] else {
688 panic!("expected text");
689 };
690 assert!(text.starts_with('🔒'), "{text:?}");
691 let second = match &items[1][0] {
694 Node::Paragraph { content } => content,
695 other => panic!("expected paragraph, got {other:#?}"),
696 };
697 let Inline::Text { text } = &second[0] else {
698 panic!("expected text");
699 };
700 assert!(text.contains('\u{270D}'), "{text:?}");
701 }
702
703 #[test]
705 fn a_list_item_with_real_blocks_keeps_them() {
706 let nodes = render("- top\n - nested\n");
707 let Some(Node::List { items, .. }) = nodes.first() else {
708 panic!("expected a list");
709 };
710 assert!(
711 items[0].iter().any(|n| matches!(n, Node::List { .. })),
712 "nested list should survive: {:#?}",
713 items[0]
714 );
715 }
716}