mirror of
https://github.com/lexmount/moli.git
synced 2026-09-23 00:01:25 +00:00
480 lines
14 KiB
Rust
480 lines
14 KiB
Rust
use moli_html2md::{Converter, Dom, NodeKind, Options, convert};
|
|
|
|
// A second DOM implementation, independent of NativeDom or an HTML parser.
|
|
// Its stable arena IDs and borrowed strings are enough for the converter.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct Node {
|
|
kind: NodeKind<'static>,
|
|
attrs: Vec<(&'static str, &'static str)>,
|
|
first: Option<usize>,
|
|
last: Option<usize>,
|
|
next: Option<usize>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct Tree(Vec<Node>);
|
|
|
|
impl Tree {
|
|
fn new() -> Self {
|
|
Self(vec![Node {
|
|
kind: NodeKind::Document,
|
|
attrs: Vec::new(),
|
|
first: None,
|
|
last: None,
|
|
next: None,
|
|
}])
|
|
}
|
|
|
|
fn add(&mut self, parent: usize, kind: NodeKind<'static>) -> usize {
|
|
let id = self.0.len();
|
|
self.0.push(Node {
|
|
kind,
|
|
attrs: Vec::new(),
|
|
first: None,
|
|
last: None,
|
|
next: None,
|
|
});
|
|
if let Some(previous) = self.0[parent].last {
|
|
self.0[previous].next = Some(id);
|
|
} else {
|
|
self.0[parent].first = Some(id);
|
|
}
|
|
self.0[parent].last = Some(id);
|
|
id
|
|
}
|
|
|
|
fn element(&mut self, parent: usize, tag: &'static str) -> usize {
|
|
self.add(parent, NodeKind::Element(tag))
|
|
}
|
|
|
|
fn text(&mut self, parent: usize, text: &'static str) {
|
|
self.add(parent, NodeKind::Text(text));
|
|
}
|
|
|
|
fn attr(&mut self, node: usize, name: &'static str, value: &'static str) {
|
|
self.0[node].attrs.push((name, value));
|
|
}
|
|
|
|
fn leaf(&mut self, parent: usize, tag: &'static str, text: &'static str) -> usize {
|
|
let element = self.element(parent, tag);
|
|
self.text(element, text);
|
|
element
|
|
}
|
|
}
|
|
|
|
impl Dom for Tree {
|
|
type NodeId = usize;
|
|
|
|
fn node_kind(&self, node: usize) -> NodeKind<'_> {
|
|
self.0[node].kind
|
|
}
|
|
|
|
fn first_child(&self, node: usize) -> Option<usize> {
|
|
self.0[node].first
|
|
}
|
|
|
|
fn next_sibling(&self, node: usize) -> Option<usize> {
|
|
self.0[node].next
|
|
}
|
|
|
|
fn attribute(&self, node: usize, name: &str) -> Option<&str> {
|
|
self.0[node]
|
|
.attrs
|
|
.iter()
|
|
.find_map(|(key, value)| (*key == name).then_some(*value))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn coalesces_styles_without_changing_the_tree() {
|
|
let mut dom = Tree::new();
|
|
dom.leaf(0, "em", "foo");
|
|
dom.leaf(0, "i", "bar");
|
|
dom.text(0, " ");
|
|
let bold = dom.element(0, "strong");
|
|
dom.leaf(bold, "b", "nested");
|
|
dom.leaf(0, "strong", "!");
|
|
let before = dom.clone();
|
|
let converter = Converter::default();
|
|
for _ in 0..2 {
|
|
assert_eq!(converter.convert(&dom, 0), "*foobar* **nested!**");
|
|
assert_eq!(dom, before);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn moves_whitespace_outside_formatting_but_keeps_nonbreaking_spaces() {
|
|
let mut dom = Tree::new();
|
|
dom.text(0, "by");
|
|
dom.leaf(0, "strong", " \t");
|
|
dom.leaf(0, "small", "Albert Einstein");
|
|
dom.text(0, "\u{a0}");
|
|
let em = dom.element(0, "em");
|
|
dom.leaf(em, "strong", " next ");
|
|
dom.text(0, "word");
|
|
assert_eq!(convert(&dom, 0), "by Albert Einstein\u{a0} ***next*** word");
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_separate_links_and_escapes_destinations_and_titles() {
|
|
let mut dom = Tree::new();
|
|
for label in ["one", "two"] {
|
|
let link = dom.leaf(0, "a", label);
|
|
dom.attr(link, "href", "/a(b) c");
|
|
dom.attr(link, "title", "a \"title\"");
|
|
}
|
|
assert_eq!(
|
|
convert(&dom, 0),
|
|
"[one](/a\\(b\\)%20c \"a \\\"title\\\"\")[two](/a\\(b\\)%20c \"a \\\"title\\\"\")"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn escapes_literal_markdown_and_decoded_entities_across_text_nodes() {
|
|
let mut dom = Tree::new();
|
|
let p = dom.element(0, "p");
|
|
dom.text(p, "12");
|
|
dom.text(p, ". [x] *y* _z_ `a` <tag> ©");
|
|
dom.leaf(0, "p", "# heading");
|
|
dom.leaf(0, "p", "- item");
|
|
dom.text(0, "!");
|
|
let link = dom.leaf(0, "a", "link");
|
|
dom.attr(link, "href", "/");
|
|
assert_eq!(
|
|
convert(&dom, 0),
|
|
"12\\. \\[x\\] \\*y\\* \\_z\\_ \\`a\\` \\<tag\\> &copy;\n\n\\# heading\n\n\\- item\n\n\\"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_adjacent_code_elements_distinct_and_chooses_safe_delimiters() {
|
|
let mut dom = Tree::new();
|
|
dom.leaf(0, "code", "a`");
|
|
dom.leaf(0, "code", "`b");
|
|
dom.text(0, " ");
|
|
dom.leaf(0, "code", " ` ");
|
|
dom.text(0, "after");
|
|
assert_eq!(
|
|
rendered_html(&convert(&dom, 0)),
|
|
"<p><code>a`</code><code>`b</code> <code>`</code> after</p>\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_text_nodes_do_not_separate_code_values() {
|
|
let mut dom = Tree::new();
|
|
dom.leaf(0, "code", "name");
|
|
dom.text(0, "");
|
|
dom.leaf(0, "code", "string");
|
|
dom.text(0, "");
|
|
dom.text(0, "");
|
|
dom.leaf(0, "code", "third");
|
|
for preformatted_code in [false, true] {
|
|
let result = Converter::new(Options {
|
|
preformatted_code,
|
|
..Options::default()
|
|
})
|
|
.convert(&dom, 0);
|
|
assert_eq!(
|
|
rendered_html(&result),
|
|
"<p><code>name</code><code>string</code><code>third</code></p>\n",
|
|
"preformatted={preformatted_code}: {result}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_preformatted_text_including_blank_lines_and_nested_elements() {
|
|
let mut dom = Tree::new();
|
|
let pre = dom.element(0, "pre");
|
|
let code = dom.element(pre, "code");
|
|
dom.attr(code, "class", "highlight language-rust");
|
|
dom.text(code, " first\r\n\r\n");
|
|
dom.leaf(code, "span", "```\n");
|
|
dom.text(code, "\n");
|
|
assert_eq!(convert(&dom, 0), "````rust\n first\n\n```\n\n````");
|
|
}
|
|
|
|
#[test]
|
|
fn prefixes_multiline_list_items_and_quotes() {
|
|
let mut dom = Tree::new();
|
|
let quote = dom.element(0, "blockquote");
|
|
let list = dom.element(quote, "ol");
|
|
dom.attr(list, "start", "9");
|
|
let item = dom.element(list, "li");
|
|
dom.leaf(item, "p", "first");
|
|
dom.leaf(item, "p", "second");
|
|
dom.leaf(list, "li", "last");
|
|
assert_eq!(
|
|
convert(&dom, 0),
|
|
"> 9. first\n>\n> second\n>\n> 10. last"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn supports_headings_images_and_non_content_elements() {
|
|
let mut dom = Tree::new();
|
|
dom.leaf(0, "h2", "Heading");
|
|
let image = dom.element(0, "img");
|
|
dom.attr(image, "src", "/image.png");
|
|
dom.attr(image, "alt", "[image]");
|
|
dom.element(0, "hr");
|
|
for tag in ["head", "script", "style", "noscript", "template"] {
|
|
dom.leaf(0, tag, "invisible");
|
|
}
|
|
dom.add(0, NodeKind::Other);
|
|
dom.leaf(0, "p", "visible");
|
|
assert_eq!(
|
|
convert(&dom, 0),
|
|
"## Heading\n\n![\\[image\\]](/image.png)\n\n---\n\nvisible"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn adds_empty_headers_to_headerless_tables() {
|
|
let mut dom = Tree::new();
|
|
let table = dom.element(0, "table");
|
|
let row = dom.element(table, "tr");
|
|
dom.leaf(row, "td", "one");
|
|
dom.leaf(row, "td", "two");
|
|
assert_eq!(convert(&dom, 0), "| | |\n| --- | --- |\n| one | two |");
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_table_cell_boundaries_hard_breaks_and_literal_pipes() {
|
|
let mut dom = Tree::new();
|
|
let table = dom.element(0, "table");
|
|
let header = dom.element(table, "tr");
|
|
let cell = dom.leaf(header, "th", "Key");
|
|
dom.attr(cell, "align", "right");
|
|
let row = dom.element(table, "tr");
|
|
let cell = dom.element(row, "td");
|
|
dom.text(cell, "a|b");
|
|
dom.element(cell, "br");
|
|
dom.text(cell, "c");
|
|
assert_eq!(convert(&dom, 0), "| Key |\n| ---: |\n| a\\|b<br>c |");
|
|
}
|
|
|
|
#[test]
|
|
fn table_spans_do_not_duplicate_cell_text() {
|
|
let mut dom = Tree::new();
|
|
let table = dom.element(0, "table");
|
|
let row = dom.element(table, "tr");
|
|
let cell = dom.leaf(row, "td", "one");
|
|
dom.attr(cell, "rowspan", "2");
|
|
dom.leaf(row, "td", "two");
|
|
let row = dom.element(table, "tr");
|
|
let cell = dom.leaf(row, "td", "end");
|
|
dom.attr(cell, "colspan", "999999999999");
|
|
assert_eq!(convert(&dom, 0), "one\n\ntwo\n\nend");
|
|
}
|
|
|
|
#[test]
|
|
fn bounds_every_traversal_including_raw_code() {
|
|
for tag in [
|
|
"div",
|
|
"span",
|
|
"strong",
|
|
"blockquote",
|
|
"table",
|
|
"pre",
|
|
"code",
|
|
] {
|
|
let mut dom = Tree::new();
|
|
dom.text(0, "visible");
|
|
let mut node = 0;
|
|
for _ in 0..30 {
|
|
node = dom.element(node, tag);
|
|
}
|
|
dom.text(node, "too deep");
|
|
let converter = Converter::new(Options {
|
|
max_depth: 10,
|
|
..Options::default()
|
|
});
|
|
let result = converter.convert(&dom, 0);
|
|
assert!(!result.contains("too deep"), "{tag}: {result}");
|
|
assert!(result.starts_with("visible"), "{tag}: {result}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn converts_deep_trees_on_a_small_thread_stack() {
|
|
std::thread::Builder::new()
|
|
.stack_size(64 * 1024)
|
|
.spawn(|| {
|
|
for tag in ["span", "strong", "pre"] {
|
|
let mut dom = Tree::new();
|
|
let mut node = 0;
|
|
for _ in 0..20_000 {
|
|
node = dom.element(node, tag);
|
|
}
|
|
dom.text(node, "deep");
|
|
let converter = Converter::new(Options {
|
|
max_depth: usize::MAX,
|
|
..Options::default()
|
|
});
|
|
let expected = match tag {
|
|
"strong" => "**deep**",
|
|
"pre" => "```\ndeep\n```",
|
|
_ => "deep",
|
|
};
|
|
assert_eq!(converter.convert(&dom, 0), expected);
|
|
}
|
|
})
|
|
.expect("spawn small-stack test")
|
|
.join()
|
|
.expect("iterative conversion should complete");
|
|
}
|
|
|
|
#[test]
|
|
fn converts_deep_tables_on_a_small_thread_stack() {
|
|
std::thread::Builder::new()
|
|
.stack_size(64 * 1024)
|
|
.spawn(|| {
|
|
let mut dom = Tree::new();
|
|
let mut node = 0;
|
|
for _ in 0..10_000 {
|
|
node = dom.element(node, "table");
|
|
node = dom.element(node, "tr");
|
|
node = dom.element(node, "td");
|
|
}
|
|
dom.text(node, "deep");
|
|
let converter = Converter::new(Options {
|
|
max_depth: usize::MAX,
|
|
..Options::default()
|
|
});
|
|
assert_eq!(converter.convert(&dom, 0), "| |\n| --- |\n| deep |");
|
|
})
|
|
.expect("spawn small-stack test")
|
|
.join()
|
|
.expect("nested tables should not recurse");
|
|
}
|
|
|
|
#[test]
|
|
fn converts_nested_header_tables_on_a_small_thread_stack() {
|
|
std::thread::Builder::new()
|
|
.stack_size(64 * 1024)
|
|
.spawn(|| {
|
|
let mut dom = Tree::new();
|
|
let mut node = 0;
|
|
for _ in 0..1_000 {
|
|
let table = dom.element(node, "table");
|
|
let row = dom.element(table, "tr");
|
|
dom.leaf(row, "th", "Header");
|
|
let row = dom.element(table, "tr");
|
|
node = dom.element(row, "td");
|
|
}
|
|
dom.text(node, "deep");
|
|
let converter = Converter::new(Options {
|
|
max_depth: usize::MAX,
|
|
..Options::default()
|
|
});
|
|
let actual = converter.convert(&dom, 0);
|
|
assert_eq!(actual.matches("Header").count(), 1_000);
|
|
assert_eq!(actual.matches("| --- |").count(), 1);
|
|
assert!(actual.ends_with("| deep |"));
|
|
})
|
|
.expect("spawn small-stack test")
|
|
.join()
|
|
.expect("header table conversion should not recurse");
|
|
}
|
|
|
|
#[test]
|
|
fn conversion_is_limited_to_the_supplied_subtree() {
|
|
let mut dom = Tree::new();
|
|
let first = dom.leaf(0, "p", "first");
|
|
dom.leaf(0, "p", "second");
|
|
assert_eq!(convert(&dom, first), "first");
|
|
let converter = Converter::new(Options {
|
|
max_depth: 0,
|
|
..Options::default()
|
|
});
|
|
assert_eq!(converter.convert(&dom, first), "");
|
|
}
|
|
|
|
fn rendered_html(markdown: &str) -> String {
|
|
let parser = pulldown_cmark::Parser::new_ext(
|
|
markdown,
|
|
pulldown_cmark::Options::ENABLE_TABLES | pulldown_cmark::Options::ENABLE_STRIKETHROUGH,
|
|
);
|
|
let mut html = String::new();
|
|
pulldown_cmark::html::push_html(&mut html, parser);
|
|
html
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_parser_recovers_adjacent_and_nested_formatting() {
|
|
let mut dom = Tree::new();
|
|
dom.leaf(0, "em", "foo");
|
|
dom.leaf(0, "em", "bar");
|
|
dom.text(0, " ");
|
|
let strong = dom.element(0, "strong");
|
|
dom.leaf(strong, "em", "both");
|
|
dom.text(0, " ");
|
|
dom.leaf(0, "del", "deleted");
|
|
assert_eq!(
|
|
rendered_html(&convert(&dom, 0)),
|
|
"<p><em>foobar</em> <em><strong>both</strong></em> <del>deleted</del></p>\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_parser_recovers_unicode_spaces_at_emphasis_boundaries() {
|
|
let mut dom = Tree::new();
|
|
dom.leaf(0, "em", "\u{a0} a \u{2003}");
|
|
dom.text(0, "b");
|
|
assert_eq!(
|
|
rendered_html(&convert(&dom, 0)),
|
|
"<p>\u{a0} <em>a</em> \u{2003}b</p>\n"
|
|
);
|
|
let mut dom = Tree::new();
|
|
dom.leaf(0, "p", "\u{a0} a");
|
|
assert_eq!(convert(&dom, 0), "\u{a0} a");
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_parser_recovers_intraword_emphasis_next_to_punctuation() {
|
|
for (tag, html_tag) in [("strong", "strong"), ("em", "em"), ("del", "del")] {
|
|
for text in ["(word)", "word!", "!word", "日本語。"] {
|
|
let mut dom = Tree::new();
|
|
dom.text(0, "before");
|
|
dom.leaf(0, tag, text);
|
|
dom.text(0, "after");
|
|
let markdown = convert(&dom, 0);
|
|
assert_eq!(
|
|
rendered_html(&markdown),
|
|
format!("<p>before<{html_tag}>{text}</{html_tag}>after</p>\n"),
|
|
"{markdown}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_parser_recovers_literal_hashes_at_the_end_of_headings() {
|
|
let mut dom = Tree::new();
|
|
dom.leaf(0, "h1", "title #");
|
|
assert_eq!(rendered_html(&convert(&dom, 0)), "<h1>title #</h1>\n");
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_parser_recovers_pipes_and_preformatted_code_inside_tables() {
|
|
let mut dom = Tree::new();
|
|
let table = dom.element(0, "table");
|
|
let header = dom.element(table, "tr");
|
|
dom.leaf(header, "th", "Code");
|
|
let row = dom.element(table, "tr");
|
|
let cell = dom.element(row, "td");
|
|
dom.leaf(cell, "code", "a|b");
|
|
let row = dom.element(table, "tr");
|
|
let cell = dom.element(row, "td");
|
|
dom.leaf(cell, "pre", " <a>|b\nnext\n");
|
|
let markdown = convert(&dom, 0);
|
|
let html = rendered_html(&markdown);
|
|
assert_eq!(
|
|
html,
|
|
"<p>Code</p>\n<p><code>a|b</code></p>\n<pre><code> <a>|b\nnext\n</code></pre>\n",
|
|
"{markdown}"
|
|
);
|
|
}
|