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 Image {
195 src: String,
197 alt: String,
199 },
200 SoftBreak,
203 LineBreak,
206 Text {
212 value: String,
214 },
215}
216
217pub fn to_nodes(text: &str, image_base: &ImageBase) -> Vec<Node> {
225 let arena = Arena::new();
226 let mut options = Options::default();
227 options.extension.table = true;
228 options.extension.strikethrough = true;
229 options.extension.tasklist = true;
230 options.extension.autolink = true;
231 let root = parse_document(&arena, text, &options);
232 children_of(root, image_base)
233}
234
235fn children_of<'a>(node: &'a AstNode<'a>, image_base: &ImageBase) -> Vec<Node> {
236 node.children()
237 .filter_map(|child| convert(child, image_base))
238 .collect()
239}
240
241fn convert<'a>(node: &'a AstNode<'a>, image_base: &ImageBase) -> Option<Node> {
242 let value = node.data.borrow().value.clone();
243 Some(match value {
244 NodeValue::Paragraph => Node::Paragraph {
245 children: children_of(node, image_base),
246 },
247 NodeValue::Heading(h) => Node::Heading {
248 level: h.level,
249 children: children_of(node, image_base),
250 },
251 NodeValue::List(l) => {
252 let items = children_of(node, image_base);
253 if l.list_type == ListType::Ordered {
254 Node::OrderedList {
255 start: l.start as u32,
256 items,
257 }
258 } else {
259 Node::BulletList { items }
260 }
261 }
262 NodeValue::Item(_) => Node::ListItem {
263 checked: None,
264 children: children_of(node, image_base),
265 },
266 NodeValue::TaskItem(t) => Node::ListItem {
267 checked: Some(t.symbol.is_some()),
268 children: children_of(node, image_base),
269 },
270 NodeValue::BlockQuote => Node::BlockQuote {
271 children: children_of(node, image_base),
272 },
273 NodeValue::ThematicBreak => Node::ThematicBreak,
274 NodeValue::CodeBlock(cb) => Node::CodeBlock {
275 lang: (!cb.info.is_empty()).then_some(cb.info),
276 code: cb.literal,
277 },
278 NodeValue::Code(c) => Node::Code { code: c.literal },
279 NodeValue::HtmlBlock(h) => Node::Text { value: h.literal },
283 NodeValue::HtmlInline(s) => Node::Text { value: s },
284 NodeValue::Text(s) => Node::Text {
285 value: s.into_owned(),
286 },
287 NodeValue::SoftBreak => Node::SoftBreak,
288 NodeValue::LineBreak => Node::LineBreak,
289 NodeValue::Emph => Node::Emphasis {
290 children: children_of(node, image_base),
291 },
292 NodeValue::Strong => Node::Strong {
293 children: children_of(node, image_base),
294 },
295 NodeValue::Strikethrough => Node::Strikethrough {
296 children: children_of(node, image_base),
297 },
298 NodeValue::Link(l) => normalize_link(&l.url, children_of(node, image_base)),
299 NodeValue::Image(l) => {
300 let alt = plain_text(&children_of(node, image_base));
301 normalize_image(&l.url, alt, image_base)
302 }
303 NodeValue::Table(t) => {
304 let rows = node
305 .children()
306 .map(|row| table_row(row, &t.alignments, image_base))
307 .collect();
308 Node::Table {
309 align: t.alignments.iter().copied().map(align_of).collect(),
310 rows,
311 }
312 }
313 _ => return None,
319 })
320}
321
322fn table_row<'a>(
323 row: &'a AstNode<'a>,
324 aligns: &[TableAlignment],
325 image_base: &ImageBase,
326) -> Vec<TableCell> {
327 let header = matches!(row.data.borrow().value, NodeValue::TableRow(true));
328 row.children()
329 .enumerate()
330 .map(|(i, cell)| TableCell {
331 header,
332 align: aligns.get(i).copied().map(align_of).unwrap_or(Align::None),
333 children: children_of(cell, image_base),
334 })
335 .collect()
336}
337
338fn plain_text(nodes: &[Node]) -> String {
345 let mut out = String::new();
346 for node in nodes {
347 match node {
348 Node::Text { value } | Node::Code { code: value } => out.push_str(value),
349 Node::Image { alt, .. } => out.push_str(alt),
350 Node::SoftBreak => out.push(' '),
351 Node::LineBreak => out.push('\n'),
352 Node::Paragraph { children }
353 | Node::Heading { children, .. }
354 | Node::Emphasis { children }
355 | Node::Strong { children }
356 | Node::Strikethrough { children }
357 | Node::BlockQuote { children }
358 | Node::ListItem { children, .. }
359 | Node::Link { children, .. } => out.push_str(&plain_text(children)),
360 Node::BulletList { .. }
361 | Node::OrderedList { .. }
362 | Node::Table { .. }
363 | Node::ThematicBreak
364 | Node::CodeBlock { .. } => {}
365 }
366 }
367 out
368}
369
370fn normalize_link(url: &str, children: Vec<Node>) -> Node {
375 let allowed = match url.split_once(':') {
376 Some((scheme, _)) => {
377 scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
378 }
379 None => false,
380 };
381 if allowed {
382 Node::Link {
383 href: url.to_owned(),
384 children,
385 }
386 } else {
387 Node::Text {
388 value: plain_text(&children),
389 }
390 }
391}
392
393fn normalize_image(url: &str, alt: String, image_base: &ImageBase) -> Node {
401 if url.to_ascii_lowercase().starts_with("data:image/") {
402 return Node::Image {
403 src: url.to_owned(),
404 alt,
405 };
406 }
407 if let ImageBase::QuestionPanel { id } = image_base {
408 if valid_asset_name(url) {
409 return Node::Image {
410 src: format!("/api/questions/{id}/panel/{url}"),
411 alt,
412 };
413 }
414 }
415 let value = if alt.is_empty() {
416 url.to_owned()
417 } else {
418 format!("{alt} ({url})")
419 };
420 Node::Text { value }
421}
422
423#[cfg(test)]
424mod tests {
425 use pretty_assertions::assert_eq;
426
427 use super::*;
428
429 fn nodes(text: &str) -> Vec<Node> {
430 to_nodes(text, &ImageBase::None)
431 }
432
433 fn text(s: &str) -> Node {
434 Node::Text {
435 value: s.to_owned(),
436 }
437 }
438
439 #[test]
440 fn a_heading_carries_its_level() {
441 assert_eq!(
442 nodes("### Three"),
443 vec![Node::Heading {
444 level: 3,
445 children: vec![text("Three")],
446 }]
447 );
448 }
449
450 #[test]
451 fn emphasis_and_strong_and_strikethrough_each_get_their_own_node() {
452 assert_eq!(
453 nodes("*i* **b** ~~s~~"),
454 vec![Node::Paragraph {
455 children: vec![
456 Node::Emphasis {
457 children: vec![text("i")]
458 },
459 text(" "),
460 Node::Strong {
461 children: vec![text("b")]
462 },
463 text(" "),
464 Node::Strikethrough {
465 children: vec![text("s")]
466 },
467 ],
468 }]
469 );
470 }
471
472 #[test]
473 fn a_bullet_list_is_a_bullet_list() {
474 assert_eq!(
475 nodes("- one\n- two"),
476 vec![Node::BulletList {
477 items: vec![
478 Node::ListItem {
479 checked: None,
480 children: vec![Node::Paragraph {
481 children: vec![text("one")]
482 }],
483 },
484 Node::ListItem {
485 checked: None,
486 children: vec![Node::Paragraph {
487 children: vec![text("two")]
488 }],
489 },
490 ],
491 }]
492 );
493 }
494
495 #[test]
496 fn an_ordered_list_keeps_its_start_number() {
497 let Some(Node::OrderedList { start, items }) = nodes("5. five\n6. six").into_iter().next()
498 else {
499 panic!("expected an ordered list");
500 };
501 assert_eq!(start, 5);
502 assert_eq!(items.len(), 2);
503 }
504
505 #[test]
506 fn a_nested_list_is_a_list_item_containing_a_list() {
507 let doc = nodes("- outer\n - inner");
508 let Some(Node::BulletList { items }) = doc.into_iter().next() else {
509 panic!("expected a bullet list");
510 };
511 let Node::ListItem { children, .. } = &items[0] else {
512 panic!("expected a list item");
513 };
514 assert!(
515 children
516 .iter()
517 .any(|c| matches!(c, Node::BulletList { .. })),
518 "the outer item's children should hold the nested list: {children:?}"
519 );
520 }
521
522 #[test]
523 fn task_list_items_carry_their_checked_state() {
524 let Some(Node::BulletList { items }) = nodes("- [ ] todo\n- [x] done").into_iter().next()
525 else {
526 panic!("expected a bullet list");
527 };
528 assert_eq!(items.len(), 2);
529 assert!(matches!(
530 items[0],
531 Node::ListItem {
532 checked: Some(false),
533 ..
534 }
535 ));
536 assert!(matches!(
537 items[1],
538 Node::ListItem {
539 checked: Some(true),
540 ..
541 }
542 ));
543 }
544
545 #[test]
546 fn a_table_keeps_its_header_and_its_column_alignment() {
547 let md = "| a | b |\n|:--|--:|\n| 1 | 2 |\n";
548 let Some(Node::Table { align, rows }) = nodes(md).into_iter().next() else {
549 panic!("expected a table");
550 };
551 assert_eq!(align, vec![Align::Left, Align::Right]);
552 assert_eq!(rows.len(), 2, "a header row and one body row: {rows:?}");
553 assert!(rows[0][0].header, "the first row is the header: {rows:?}");
554 assert!(!rows[1][0].header, "the body row is not a header: {rows:?}");
555 assert_eq!(rows[0][0].align, Align::Left);
556 assert_eq!(rows[0][1].align, Align::Right);
557 }
558
559 #[test]
560 fn a_block_quote_is_a_block_quote() {
561 assert_eq!(
562 nodes("> quoted"),
563 vec![Node::BlockQuote {
564 children: vec![Node::Paragraph {
565 children: vec![text("quoted")]
566 }],
567 }]
568 );
569 }
570
571 #[test]
572 fn a_thematic_break_needs_nothing_else() {
573 assert_eq!(nodes("---"), vec![Node::ThematicBreak]);
574 }
575
576 #[test]
577 fn inline_code_is_never_interpreted_as_markdown() {
578 assert_eq!(
579 nodes("`*not italic*`"),
580 vec![Node::Paragraph {
581 children: vec![Node::Code {
582 code: "*not italic*".to_owned()
583 }],
584 }]
585 );
586 }
587
588 #[test]
589 fn a_fenced_code_block_carries_its_language_but_no_color() {
590 assert_eq!(
591 nodes("```rust\nfn x() {}\n```"),
592 vec![Node::CodeBlock {
593 lang: Some("rust".to_owned()),
594 code: "fn x() {}\n".to_owned(),
595 }]
596 );
597 }
598
599 #[test]
600 fn an_http_link_stays_a_link() {
601 assert_eq!(
602 nodes("[go](https://example.com/x)"),
603 vec![Node::Paragraph {
604 children: vec![Node::Link {
605 href: "https://example.com/x".to_owned(),
606 children: vec![text("go")],
607 }],
608 }]
609 );
610 }
611
612 #[test]
613 fn a_javascript_link_is_not_a_link_node_at_all() {
614 let doc = nodes("[x](javascript:alert(1))");
615 fn has_link(nodes: &[Node]) -> bool {
617 nodes.iter().any(|n| match n {
618 Node::Link { .. } => true,
619 Node::Paragraph { children }
620 | Node::Heading { children, .. }
621 | Node::Emphasis { children }
622 | Node::Strong { children }
623 | Node::Strikethrough { children }
624 | Node::BlockQuote { children }
625 | Node::ListItem { children, .. } => has_link(children),
626 _ => false,
627 })
628 }
629 assert!(!has_link(&doc), "must not contain a link node: {doc:?}");
630 assert_eq!(
631 doc,
632 vec![Node::Paragraph {
633 children: vec![text("x")]
634 }]
635 );
636 }
637
638 #[test]
639 fn an_absolute_https_image_does_not_render() {
640 let doc = nodes("");
641 assert_eq!(
642 doc,
643 vec![Node::Paragraph {
644 children: vec![text("a (https://example.com/x.png)")]
645 }]
646 );
647 }
648
649 #[test]
650 fn a_data_uri_image_renders() {
651 let doc = nodes("");
652 assert_eq!(
653 doc,
654 vec![Node::Paragraph {
655 children: vec![Node::Image {
656 src: "data:image/png;base64,AAAA".to_owned(),
657 alt: "a".to_owned(),
658 }],
659 }]
660 );
661 }
662
663 #[test]
664 fn a_question_relative_image_resolves_to_its_panel_route() {
665 let base = ImageBase::QuestionPanel {
666 id: "20260903-014455-ab12".to_owned(),
667 };
668 let doc = to_nodes("", &base);
669 assert_eq!(
670 doc,
671 vec![Node::Paragraph {
672 children: vec![Node::Image {
673 src: "/api/questions/20260903-014455-ab12/panel/shot.png".to_owned(),
674 alt: "shot".to_owned(),
675 }],
676 }]
677 );
678 }
679
680 #[test]
681 fn a_protocol_relative_image_does_not_render_even_with_a_question_base() {
682 let base = ImageBase::QuestionPanel {
683 id: "20260903-014455-ab12".to_owned(),
684 };
685 let doc = to_nodes("", &base);
686 assert!(
687 !doc.iter().any(|n| matches!(n, Node::Paragraph { children } if children.iter().any(|c| matches!(c, Node::Image { .. })))),
688 "a protocol-relative source must never become an image: {doc:?}"
689 );
690 }
691
692 #[test]
693 fn raw_html_becomes_text_everywhere_in_the_tree() {
694 let doc = nodes("before <script>alert(1)</script> after");
695 fn contains_html_markup(nodes: &[Node]) -> bool {
696 nodes.iter().any(|n| match n {
697 Node::Text { value } => value.contains("<script"),
698 Node::Paragraph { children }
699 | Node::Heading { children, .. }
700 | Node::Emphasis { children }
701 | Node::Strong { children }
702 | Node::Strikethrough { children }
703 | Node::BlockQuote { children }
704 | Node::ListItem { children, .. } => contains_html_markup(children),
705 _ => false,
706 })
707 }
708 assert!(
709 contains_html_markup(&doc),
710 "the literal tag text must survive as a text node: {doc:?}"
711 );
712 for node in &doc {
716 assert!(
717 matches!(node, Node::Paragraph { .. }),
718 "a document with only text and an HTML span is one paragraph: {doc:?}"
719 );
720 }
721 }
722
723 #[test]
724 fn a_block_level_script_tag_becomes_a_text_node_too() {
725 let doc = nodes("<script>alert(1)</script>");
726 assert_eq!(
727 doc,
728 vec![text("<script>alert(1)</script>")],
729 "an HTML block is one literal text node, not markup: {doc:?}"
730 );
731 }
732}