1use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
34use comrak::{Arena, Options, parse_document};
35use serde::Serialize;
36
37use crate::ask::valid_asset_name;
38
39#[derive(Debug, Clone)]
49pub enum ImageBase {
50 None,
53 QuestionPanel {
56 id: String,
58 },
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Align {
65 None,
67 Left,
69 Center,
71 Right,
73}
74
75fn align_of(a: TableAlignment) -> Align {
76 match a {
77 TableAlignment::None => Align::None,
78 TableAlignment::Left => Align::Left,
79 TableAlignment::Center => Align::Center,
80 TableAlignment::Right => Align::Right,
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize)]
86pub struct TableCell {
87 pub header: bool,
89 pub align: Align,
91 pub children: Vec<Node>,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize)]
104#[serde(tag = "type", rename_all = "snake_case")]
105pub enum Node {
106 Paragraph {
108 children: Vec<Node>,
110 },
111 Heading {
113 level: u8,
115 children: Vec<Node>,
117 },
118 BulletList {
120 items: Vec<Node>,
122 },
123 OrderedList {
125 start: u32,
127 items: Vec<Node>,
129 },
130 ListItem {
132 checked: Option<bool>,
135 children: Vec<Node>,
137 },
138 Table {
140 align: Vec<Align>,
142 rows: Vec<Vec<TableCell>>,
144 },
145 BlockQuote {
147 children: Vec<Node>,
149 },
150 ThematicBreak,
152 CodeBlock {
158 lang: Option<String>,
160 code: String,
162 },
163 Code {
165 code: String,
167 },
168 Emphasis {
170 children: Vec<Node>,
172 },
173 Strong {
175 children: Vec<Node>,
177 },
178 Strikethrough {
180 children: Vec<Node>,
182 },
183 Link {
186 href: String,
188 children: Vec<Node>,
190 },
191 InternalLink {
194 href: String,
196 children: Vec<Node>,
198 },
199 Image {
203 src: String,
205 alt: String,
207 },
208 SoftBreak,
211 LineBreak,
214 Text {
220 value: String,
222 },
223}
224
225pub fn to_nodes(text: &str, image_base: &ImageBase) -> Vec<Node> {
233 let arena = Arena::new();
234 let mut options = Options::default();
235 options.extension.table = true;
236 options.extension.strikethrough = true;
237 options.extension.tasklist = true;
238 options.extension.autolink = true;
239 let root = parse_document(&arena, text, &options);
240 children_of(root, image_base)
241}
242
243fn children_of<'a>(node: &'a AstNode<'a>, image_base: &ImageBase) -> Vec<Node> {
244 node.children()
245 .filter_map(|child| convert(child, image_base))
246 .collect()
247}
248
249fn convert<'a>(node: &'a AstNode<'a>, image_base: &ImageBase) -> Option<Node> {
250 let value = node.data.borrow().value.clone();
251 Some(match value {
252 NodeValue::Paragraph => Node::Paragraph {
253 children: children_of(node, image_base),
254 },
255 NodeValue::Heading(h) => Node::Heading {
256 level: h.level,
257 children: children_of(node, image_base),
258 },
259 NodeValue::List(l) => {
260 let items = children_of(node, image_base);
261 if l.list_type == ListType::Ordered {
262 Node::OrderedList {
263 start: l.start as u32,
264 items,
265 }
266 } else {
267 Node::BulletList { items }
268 }
269 }
270 NodeValue::Item(_) => Node::ListItem {
271 checked: None,
272 children: children_of(node, image_base),
273 },
274 NodeValue::TaskItem(t) => Node::ListItem {
275 checked: Some(t.symbol.is_some()),
276 children: children_of(node, image_base),
277 },
278 NodeValue::BlockQuote => Node::BlockQuote {
279 children: children_of(node, image_base),
280 },
281 NodeValue::ThematicBreak => Node::ThematicBreak,
282 NodeValue::CodeBlock(cb) => Node::CodeBlock {
283 lang: (!cb.info.is_empty()).then_some(cb.info),
284 code: cb.literal,
285 },
286 NodeValue::Code(c) => Node::Code { code: c.literal },
287 NodeValue::HtmlBlock(h) => Node::Text { value: h.literal },
291 NodeValue::HtmlInline(s) => Node::Text { value: s },
292 NodeValue::Text(s) => Node::Text {
293 value: s.into_owned(),
294 },
295 NodeValue::SoftBreak => Node::SoftBreak,
296 NodeValue::LineBreak => Node::LineBreak,
297 NodeValue::Emph => Node::Emphasis {
298 children: children_of(node, image_base),
299 },
300 NodeValue::Strong => Node::Strong {
301 children: children_of(node, image_base),
302 },
303 NodeValue::Strikethrough => Node::Strikethrough {
304 children: children_of(node, image_base),
305 },
306 NodeValue::Link(l) => normalize_link(&l.url, children_of(node, image_base)),
307 NodeValue::Image(l) => {
308 let alt = plain_text(&children_of(node, image_base));
309 normalize_image(&l.url, alt, image_base)
310 }
311 NodeValue::Table(t) => {
312 let rows = node
313 .children()
314 .map(|row| table_row(row, &t.alignments, image_base))
315 .collect();
316 Node::Table {
317 align: t.alignments.iter().copied().map(align_of).collect(),
318 rows,
319 }
320 }
321 _ => return None,
327 })
328}
329
330fn table_row<'a>(
331 row: &'a AstNode<'a>,
332 aligns: &[TableAlignment],
333 image_base: &ImageBase,
334) -> Vec<TableCell> {
335 let header = matches!(row.data.borrow().value, NodeValue::TableRow(true));
336 row.children()
337 .enumerate()
338 .map(|(i, cell)| TableCell {
339 header,
340 align: aligns.get(i).copied().map(align_of).unwrap_or(Align::None),
341 children: children_of(cell, image_base),
342 })
343 .collect()
344}
345
346fn plain_text(nodes: &[Node]) -> String {
353 let mut out = String::new();
354 for node in nodes {
355 match node {
356 Node::Text { value } | Node::Code { code: value } => out.push_str(value),
357 Node::Image { alt, .. } => out.push_str(alt),
358 Node::SoftBreak => out.push(' '),
359 Node::LineBreak => out.push('\n'),
360 Node::Paragraph { children }
361 | Node::Heading { children, .. }
362 | Node::Emphasis { children }
363 | Node::Strong { children }
364 | Node::Strikethrough { children }
365 | Node::BlockQuote { children }
366 | Node::ListItem { children, .. }
367 | Node::Link { children, .. }
368 | Node::InternalLink { children, .. } => out.push_str(&plain_text(children)),
369 Node::BulletList { .. }
370 | Node::OrderedList { .. }
371 | Node::Table { .. }
372 | Node::ThematicBreak
373 | Node::CodeBlock { .. } => {}
374 }
375 }
376 out
377}
378
379fn normalize_link(url: &str, children: Vec<Node>) -> Node {
384 let allowed = match url.split_once(':') {
385 Some((scheme, _)) => {
386 scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
387 }
388 None => false,
389 };
390 if allowed {
391 Node::Link {
392 href: url.to_owned(),
393 children,
394 }
395 } else {
396 Node::Text {
397 value: plain_text(&children),
398 }
399 }
400}
401
402fn normalize_image(url: &str, alt: String, image_base: &ImageBase) -> Node {
410 if url.to_ascii_lowercase().starts_with("data:image/") {
411 return Node::Image {
412 src: url.to_owned(),
413 alt,
414 };
415 }
416 if let ImageBase::QuestionPanel { id } = image_base {
417 if valid_asset_name(url) {
418 return Node::Image {
419 src: format!("/api/questions/{id}/panel/{url}"),
420 alt,
421 };
422 }
423 }
424 let value = if alt.is_empty() {
425 url.to_owned()
426 } else {
427 format!("{alt} ({url})")
428 };
429 Node::Text { value }
430}
431
432#[cfg(test)]
433mod tests {
434 use pretty_assertions::assert_eq;
435
436 use super::*;
437
438 fn nodes(text: &str) -> Vec<Node> {
439 to_nodes(text, &ImageBase::None)
440 }
441
442 fn text(s: &str) -> Node {
443 Node::Text {
444 value: s.to_owned(),
445 }
446 }
447
448 #[test]
449 fn a_heading_carries_its_level() {
450 assert_eq!(
451 nodes("### Three"),
452 vec![Node::Heading {
453 level: 3,
454 children: vec![text("Three")],
455 }]
456 );
457 }
458
459 #[test]
460 fn emphasis_and_strong_and_strikethrough_each_get_their_own_node() {
461 assert_eq!(
462 nodes("*i* **b** ~~s~~"),
463 vec![Node::Paragraph {
464 children: vec![
465 Node::Emphasis {
466 children: vec![text("i")]
467 },
468 text(" "),
469 Node::Strong {
470 children: vec![text("b")]
471 },
472 text(" "),
473 Node::Strikethrough {
474 children: vec![text("s")]
475 },
476 ],
477 }]
478 );
479 }
480
481 #[test]
482 fn a_bullet_list_is_a_bullet_list() {
483 assert_eq!(
484 nodes("- one\n- two"),
485 vec![Node::BulletList {
486 items: vec![
487 Node::ListItem {
488 checked: None,
489 children: vec![Node::Paragraph {
490 children: vec![text("one")]
491 }],
492 },
493 Node::ListItem {
494 checked: None,
495 children: vec![Node::Paragraph {
496 children: vec![text("two")]
497 }],
498 },
499 ],
500 }]
501 );
502 }
503
504 #[test]
505 fn an_ordered_list_keeps_its_start_number() {
506 let Some(Node::OrderedList { start, items }) = nodes("5. five\n6. six").into_iter().next()
507 else {
508 panic!("expected an ordered list");
509 };
510 assert_eq!(start, 5);
511 assert_eq!(items.len(), 2);
512 }
513
514 #[test]
515 fn a_nested_list_is_a_list_item_containing_a_list() {
516 let doc = nodes("- outer\n - inner");
517 let Some(Node::BulletList { items }) = doc.into_iter().next() else {
518 panic!("expected a bullet list");
519 };
520 let Node::ListItem { children, .. } = &items[0] else {
521 panic!("expected a list item");
522 };
523 assert!(
524 children
525 .iter()
526 .any(|c| matches!(c, Node::BulletList { .. })),
527 "the outer item's children should hold the nested list: {children:?}"
528 );
529 }
530
531 #[test]
532 fn task_list_items_carry_their_checked_state() {
533 let Some(Node::BulletList { items }) = nodes("- [ ] todo\n- [x] done").into_iter().next()
534 else {
535 panic!("expected a bullet list");
536 };
537 assert_eq!(items.len(), 2);
538 assert!(matches!(
539 items[0],
540 Node::ListItem {
541 checked: Some(false),
542 ..
543 }
544 ));
545 assert!(matches!(
546 items[1],
547 Node::ListItem {
548 checked: Some(true),
549 ..
550 }
551 ));
552 }
553
554 #[test]
555 fn a_table_keeps_its_header_and_its_column_alignment() {
556 let md = "| a | b |\n|:--|--:|\n| 1 | 2 |\n";
557 let Some(Node::Table { align, rows }) = nodes(md).into_iter().next() else {
558 panic!("expected a table");
559 };
560 assert_eq!(align, vec![Align::Left, Align::Right]);
561 assert_eq!(rows.len(), 2, "a header row and one body row: {rows:?}");
562 assert!(rows[0][0].header, "the first row is the header: {rows:?}");
563 assert!(!rows[1][0].header, "the body row is not a header: {rows:?}");
564 assert_eq!(rows[0][0].align, Align::Left);
565 assert_eq!(rows[0][1].align, Align::Right);
566 }
567
568 #[test]
569 fn a_block_quote_is_a_block_quote() {
570 assert_eq!(
571 nodes("> quoted"),
572 vec![Node::BlockQuote {
573 children: vec![Node::Paragraph {
574 children: vec![text("quoted")]
575 }],
576 }]
577 );
578 }
579
580 #[test]
581 fn a_thematic_break_needs_nothing_else() {
582 assert_eq!(nodes("---"), vec![Node::ThematicBreak]);
583 }
584
585 #[test]
586 fn inline_code_is_never_interpreted_as_markdown() {
587 assert_eq!(
588 nodes("`*not italic*`"),
589 vec![Node::Paragraph {
590 children: vec![Node::Code {
591 code: "*not italic*".to_owned()
592 }],
593 }]
594 );
595 }
596
597 #[test]
598 fn a_fenced_code_block_carries_its_language_but_no_color() {
599 assert_eq!(
600 nodes("```rust\nfn x() {}\n```"),
601 vec![Node::CodeBlock {
602 lang: Some("rust".to_owned()),
603 code: "fn x() {}\n".to_owned(),
604 }]
605 );
606 }
607
608 #[test]
609 fn an_http_link_stays_a_link() {
610 assert_eq!(
611 nodes("[go](https://example.com/x)"),
612 vec![Node::Paragraph {
613 children: vec![Node::Link {
614 href: "https://example.com/x".to_owned(),
615 children: vec![text("go")],
616 }],
617 }]
618 );
619 }
620
621 #[test]
622 fn a_javascript_link_is_not_a_link_node_at_all() {
623 let doc = nodes("[x](javascript:alert(1))");
624 fn has_link(nodes: &[Node]) -> bool {
626 nodes.iter().any(|n| match n {
627 Node::Link { .. } => true,
628 Node::Paragraph { children }
629 | Node::Heading { children, .. }
630 | Node::Emphasis { children }
631 | Node::Strong { children }
632 | Node::Strikethrough { children }
633 | Node::BlockQuote { children }
634 | Node::ListItem { children, .. } => has_link(children),
635 _ => false,
636 })
637 }
638 assert!(!has_link(&doc), "must not contain a link node: {doc:?}");
639 assert_eq!(
640 doc,
641 vec![Node::Paragraph {
642 children: vec![text("x")]
643 }]
644 );
645 }
646
647 #[test]
648 fn an_absolute_https_image_does_not_render() {
649 let doc = nodes("");
650 assert_eq!(
651 doc,
652 vec![Node::Paragraph {
653 children: vec![text("a (https://example.com/x.png)")]
654 }]
655 );
656 }
657
658 #[test]
659 fn a_data_uri_image_renders() {
660 let doc = nodes("");
661 assert_eq!(
662 doc,
663 vec![Node::Paragraph {
664 children: vec![Node::Image {
665 src: "data:image/png;base64,AAAA".to_owned(),
666 alt: "a".to_owned(),
667 }],
668 }]
669 );
670 }
671
672 #[test]
673 fn a_question_relative_image_resolves_to_its_panel_route() {
674 let base = ImageBase::QuestionPanel {
675 id: "20260903-014455-ab12".to_owned(),
676 };
677 let doc = to_nodes("", &base);
678 assert_eq!(
679 doc,
680 vec![Node::Paragraph {
681 children: vec![Node::Image {
682 src: "/api/questions/20260903-014455-ab12/panel/shot.png".to_owned(),
683 alt: "shot".to_owned(),
684 }],
685 }]
686 );
687 }
688
689 #[test]
690 fn a_protocol_relative_image_does_not_render_even_with_a_question_base() {
691 let base = ImageBase::QuestionPanel {
692 id: "20260903-014455-ab12".to_owned(),
693 };
694 let doc = to_nodes("", &base);
695 assert!(
696 !doc.iter().any(|n| matches!(n, Node::Paragraph { children } if children.iter().any(|c| matches!(c, Node::Image { .. })))),
697 "a protocol-relative source must never become an image: {doc:?}"
698 );
699 }
700
701 #[test]
702 fn raw_html_becomes_text_everywhere_in_the_tree() {
703 let doc = nodes("before <script>alert(1)</script> after");
704 fn contains_html_markup(nodes: &[Node]) -> bool {
705 nodes.iter().any(|n| match n {
706 Node::Text { value } => value.contains("<script"),
707 Node::Paragraph { children }
708 | Node::Heading { children, .. }
709 | Node::Emphasis { children }
710 | Node::Strong { children }
711 | Node::Strikethrough { children }
712 | Node::BlockQuote { children }
713 | Node::ListItem { children, .. } => contains_html_markup(children),
714 _ => false,
715 })
716 }
717 assert!(
718 contains_html_markup(&doc),
719 "the literal tag text must survive as a text node: {doc:?}"
720 );
721 for node in &doc {
725 assert!(
726 matches!(node, Node::Paragraph { .. }),
727 "a document with only text and an HTML span is one paragraph: {doc:?}"
728 );
729 }
730 }
731
732 #[test]
733 fn a_block_level_script_tag_becomes_a_text_node_too() {
734 let doc = nodes("<script>alert(1)</script>");
735 assert_eq!(
736 doc,
737 vec![text("<script>alert(1)</script>")],
738 "an HTML block is one literal text node, not markup: {doc:?}"
739 );
740 }
741}