use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
use comrak::{Arena, Options, parse_document};
use serde::Serialize;
use crate::ask::valid_asset_name;
#[derive(Debug, Clone)]
pub enum ImageBase {
None,
QuestionPanel {
id: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Align {
None,
Left,
Center,
Right,
}
fn align_of(a: TableAlignment) -> Align {
match a {
TableAlignment::None => Align::None,
TableAlignment::Left => Align::Left,
TableAlignment::Center => Align::Center,
TableAlignment::Right => Align::Right,
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TableCell {
pub header: bool,
pub align: Align,
pub children: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Node {
Paragraph {
children: Vec<Node>,
},
Heading {
level: u8,
children: Vec<Node>,
},
BulletList {
items: Vec<Node>,
},
OrderedList {
start: u32,
items: Vec<Node>,
},
ListItem {
checked: Option<bool>,
children: Vec<Node>,
},
Table {
align: Vec<Align>,
rows: Vec<Vec<TableCell>>,
},
BlockQuote {
children: Vec<Node>,
},
ThematicBreak,
CodeBlock {
lang: Option<String>,
code: String,
},
Code {
code: String,
},
Emphasis {
children: Vec<Node>,
},
Strong {
children: Vec<Node>,
},
Strikethrough {
children: Vec<Node>,
},
Link {
href: String,
children: Vec<Node>,
},
Image {
src: String,
alt: String,
},
SoftBreak,
LineBreak,
Text {
value: String,
},
}
pub fn to_nodes(text: &str, image_base: &ImageBase) -> Vec<Node> {
let arena = Arena::new();
let mut options = Options::default();
options.extension.table = true;
options.extension.strikethrough = true;
options.extension.tasklist = true;
options.extension.autolink = true;
let root = parse_document(&arena, text, &options);
children_of(root, image_base)
}
fn children_of<'a>(node: &'a AstNode<'a>, image_base: &ImageBase) -> Vec<Node> {
node.children()
.filter_map(|child| convert(child, image_base))
.collect()
}
fn convert<'a>(node: &'a AstNode<'a>, image_base: &ImageBase) -> Option<Node> {
let value = node.data.borrow().value.clone();
Some(match value {
NodeValue::Paragraph => Node::Paragraph {
children: children_of(node, image_base),
},
NodeValue::Heading(h) => Node::Heading {
level: h.level,
children: children_of(node, image_base),
},
NodeValue::List(l) => {
let items = children_of(node, image_base);
if l.list_type == ListType::Ordered {
Node::OrderedList {
start: l.start as u32,
items,
}
} else {
Node::BulletList { items }
}
}
NodeValue::Item(_) => Node::ListItem {
checked: None,
children: children_of(node, image_base),
},
NodeValue::TaskItem(t) => Node::ListItem {
checked: Some(t.symbol.is_some()),
children: children_of(node, image_base),
},
NodeValue::BlockQuote => Node::BlockQuote {
children: children_of(node, image_base),
},
NodeValue::ThematicBreak => Node::ThematicBreak,
NodeValue::CodeBlock(cb) => Node::CodeBlock {
lang: (!cb.info.is_empty()).then_some(cb.info),
code: cb.literal,
},
NodeValue::Code(c) => Node::Code { code: c.literal },
NodeValue::HtmlBlock(h) => Node::Text { value: h.literal },
NodeValue::HtmlInline(s) => Node::Text { value: s },
NodeValue::Text(s) => Node::Text {
value: s.into_owned(),
},
NodeValue::SoftBreak => Node::SoftBreak,
NodeValue::LineBreak => Node::LineBreak,
NodeValue::Emph => Node::Emphasis {
children: children_of(node, image_base),
},
NodeValue::Strong => Node::Strong {
children: children_of(node, image_base),
},
NodeValue::Strikethrough => Node::Strikethrough {
children: children_of(node, image_base),
},
NodeValue::Link(l) => normalize_link(&l.url, children_of(node, image_base)),
NodeValue::Image(l) => {
let alt = plain_text(&children_of(node, image_base));
normalize_image(&l.url, alt, image_base)
}
NodeValue::Table(t) => {
let rows = node
.children()
.map(|row| table_row(row, &t.alignments, image_base))
.collect();
Node::Table {
align: t.alignments.iter().copied().map(align_of).collect(),
rows,
}
}
_ => return None,
})
}
fn table_row<'a>(
row: &'a AstNode<'a>,
aligns: &[TableAlignment],
image_base: &ImageBase,
) -> Vec<TableCell> {
let header = matches!(row.data.borrow().value, NodeValue::TableRow(true));
row.children()
.enumerate()
.map(|(i, cell)| TableCell {
header,
align: aligns.get(i).copied().map(align_of).unwrap_or(Align::None),
children: children_of(cell, image_base),
})
.collect()
}
fn plain_text(nodes: &[Node]) -> String {
let mut out = String::new();
for node in nodes {
match node {
Node::Text { value } | Node::Code { code: value } => out.push_str(value),
Node::Image { alt, .. } => out.push_str(alt),
Node::SoftBreak => out.push(' '),
Node::LineBreak => out.push('\n'),
Node::Paragraph { children }
| Node::Heading { children, .. }
| Node::Emphasis { children }
| Node::Strong { children }
| Node::Strikethrough { children }
| Node::BlockQuote { children }
| Node::ListItem { children, .. }
| Node::Link { children, .. } => out.push_str(&plain_text(children)),
Node::BulletList { .. }
| Node::OrderedList { .. }
| Node::Table { .. }
| Node::ThematicBreak
| Node::CodeBlock { .. } => {}
}
}
out
}
fn normalize_link(url: &str, children: Vec<Node>) -> Node {
let allowed = match url.split_once(':') {
Some((scheme, _)) => {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
}
None => false,
};
if allowed {
Node::Link {
href: url.to_owned(),
children,
}
} else {
Node::Text {
value: plain_text(&children),
}
}
}
fn normalize_image(url: &str, alt: String, image_base: &ImageBase) -> Node {
if url.to_ascii_lowercase().starts_with("data:image/") {
return Node::Image {
src: url.to_owned(),
alt,
};
}
if let ImageBase::QuestionPanel { id } = image_base {
if valid_asset_name(url) {
return Node::Image {
src: format!("/api/questions/{id}/panel/{url}"),
alt,
};
}
}
let value = if alt.is_empty() {
url.to_owned()
} else {
format!("{alt} ({url})")
};
Node::Text { value }
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
fn nodes(text: &str) -> Vec<Node> {
to_nodes(text, &ImageBase::None)
}
fn text(s: &str) -> Node {
Node::Text {
value: s.to_owned(),
}
}
#[test]
fn a_heading_carries_its_level() {
assert_eq!(
nodes("### Three"),
vec![Node::Heading {
level: 3,
children: vec![text("Three")],
}]
);
}
#[test]
fn emphasis_and_strong_and_strikethrough_each_get_their_own_node() {
assert_eq!(
nodes("*i* **b** ~~s~~"),
vec![Node::Paragraph {
children: vec![
Node::Emphasis {
children: vec![text("i")]
},
text(" "),
Node::Strong {
children: vec![text("b")]
},
text(" "),
Node::Strikethrough {
children: vec![text("s")]
},
],
}]
);
}
#[test]
fn a_bullet_list_is_a_bullet_list() {
assert_eq!(
nodes("- one\n- two"),
vec![Node::BulletList {
items: vec![
Node::ListItem {
checked: None,
children: vec![Node::Paragraph {
children: vec![text("one")]
}],
},
Node::ListItem {
checked: None,
children: vec![Node::Paragraph {
children: vec![text("two")]
}],
},
],
}]
);
}
#[test]
fn an_ordered_list_keeps_its_start_number() {
let Some(Node::OrderedList { start, items }) = nodes("5. five\n6. six").into_iter().next()
else {
panic!("expected an ordered list");
};
assert_eq!(start, 5);
assert_eq!(items.len(), 2);
}
#[test]
fn a_nested_list_is_a_list_item_containing_a_list() {
let doc = nodes("- outer\n - inner");
let Some(Node::BulletList { items }) = doc.into_iter().next() else {
panic!("expected a bullet list");
};
let Node::ListItem { children, .. } = &items[0] else {
panic!("expected a list item");
};
assert!(
children
.iter()
.any(|c| matches!(c, Node::BulletList { .. })),
"the outer item's children should hold the nested list: {children:?}"
);
}
#[test]
fn task_list_items_carry_their_checked_state() {
let Some(Node::BulletList { items }) = nodes("- [ ] todo\n- [x] done").into_iter().next()
else {
panic!("expected a bullet list");
};
assert_eq!(items.len(), 2);
assert!(matches!(
items[0],
Node::ListItem {
checked: Some(false),
..
}
));
assert!(matches!(
items[1],
Node::ListItem {
checked: Some(true),
..
}
));
}
#[test]
fn a_table_keeps_its_header_and_its_column_alignment() {
let md = "| a | b |\n|:--|--:|\n| 1 | 2 |\n";
let Some(Node::Table { align, rows }) = nodes(md).into_iter().next() else {
panic!("expected a table");
};
assert_eq!(align, vec![Align::Left, Align::Right]);
assert_eq!(rows.len(), 2, "a header row and one body row: {rows:?}");
assert!(rows[0][0].header, "the first row is the header: {rows:?}");
assert!(!rows[1][0].header, "the body row is not a header: {rows:?}");
assert_eq!(rows[0][0].align, Align::Left);
assert_eq!(rows[0][1].align, Align::Right);
}
#[test]
fn a_block_quote_is_a_block_quote() {
assert_eq!(
nodes("> quoted"),
vec![Node::BlockQuote {
children: vec![Node::Paragraph {
children: vec![text("quoted")]
}],
}]
);
}
#[test]
fn a_thematic_break_needs_nothing_else() {
assert_eq!(nodes("---"), vec![Node::ThematicBreak]);
}
#[test]
fn inline_code_is_never_interpreted_as_markdown() {
assert_eq!(
nodes("`*not italic*`"),
vec![Node::Paragraph {
children: vec![Node::Code {
code: "*not italic*".to_owned()
}],
}]
);
}
#[test]
fn a_fenced_code_block_carries_its_language_but_no_color() {
assert_eq!(
nodes("```rust\nfn x() {}\n```"),
vec![Node::CodeBlock {
lang: Some("rust".to_owned()),
code: "fn x() {}\n".to_owned(),
}]
);
}
#[test]
fn an_http_link_stays_a_link() {
assert_eq!(
nodes("[go](https://example.com/x)"),
vec![Node::Paragraph {
children: vec![Node::Link {
href: "https://example.com/x".to_owned(),
children: vec![text("go")],
}],
}]
);
}
#[test]
fn a_javascript_link_is_not_a_link_node_at_all() {
let doc = nodes("[x](javascript:alert(1))");
fn has_link(nodes: &[Node]) -> bool {
nodes.iter().any(|n| match n {
Node::Link { .. } => true,
Node::Paragraph { children }
| Node::Heading { children, .. }
| Node::Emphasis { children }
| Node::Strong { children }
| Node::Strikethrough { children }
| Node::BlockQuote { children }
| Node::ListItem { children, .. } => has_link(children),
_ => false,
})
}
assert!(!has_link(&doc), "must not contain a link node: {doc:?}");
assert_eq!(
doc,
vec![Node::Paragraph {
children: vec![text("x")]
}]
);
}
#[test]
fn an_absolute_https_image_does_not_render() {
let doc = nodes("");
assert_eq!(
doc,
vec![Node::Paragraph {
children: vec![text("a (https://example.com/x.png)")]
}]
);
}
#[test]
fn a_data_uri_image_renders() {
let doc = nodes("");
assert_eq!(
doc,
vec![Node::Paragraph {
children: vec![Node::Image {
src: "data:image/png;base64,AAAA".to_owned(),
alt: "a".to_owned(),
}],
}]
);
}
#[test]
fn a_question_relative_image_resolves_to_its_panel_route() {
let base = ImageBase::QuestionPanel {
id: "20260903-014455-ab12".to_owned(),
};
let doc = to_nodes("", &base);
assert_eq!(
doc,
vec![Node::Paragraph {
children: vec![Node::Image {
src: "/api/questions/20260903-014455-ab12/panel/shot.png".to_owned(),
alt: "shot".to_owned(),
}],
}]
);
}
#[test]
fn a_protocol_relative_image_does_not_render_even_with_a_question_base() {
let base = ImageBase::QuestionPanel {
id: "20260903-014455-ab12".to_owned(),
};
let doc = to_nodes("", &base);
assert!(
!doc.iter().any(|n| matches!(n, Node::Paragraph { children } if children.iter().any(|c| matches!(c, Node::Image { .. })))),
"a protocol-relative source must never become an image: {doc:?}"
);
}
#[test]
fn raw_html_becomes_text_everywhere_in_the_tree() {
let doc = nodes("before <script>alert(1)</script> after");
fn contains_html_markup(nodes: &[Node]) -> bool {
nodes.iter().any(|n| match n {
Node::Text { value } => value.contains("<script"),
Node::Paragraph { children }
| Node::Heading { children, .. }
| Node::Emphasis { children }
| Node::Strong { children }
| Node::Strikethrough { children }
| Node::BlockQuote { children }
| Node::ListItem { children, .. } => contains_html_markup(children),
_ => false,
})
}
assert!(
contains_html_markup(&doc),
"the literal tag text must survive as a text node: {doc:?}"
);
for node in &doc {
assert!(
matches!(node, Node::Paragraph { .. }),
"a document with only text and an HTML span is one paragraph: {doc:?}"
);
}
}
#[test]
fn a_block_level_script_tag_becomes_a_text_node_too() {
let doc = nodes("<script>alert(1)</script>");
assert_eq!(
doc,
vec![text("<script>alert(1)</script>")],
"an HTML block is one literal text node, not markup: {doc:?}"
);
}
}