use crate::{
Document, HasSpan,
blocks::{
Block, Break, CompoundDelimitedBlock, IsBlock, ListBlock, ListItem, ListItemMarker,
ListType, MediaBlock, Preamble, RawDelimitedBlock, SectionBlock, SimpleBlock,
SimpleBlockStyle,
},
};
fn decode_html_entities(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
.replace("'", "'")
}
fn parse_html_content(text: &str) -> Vec<VirtualNode> {
let mut result = Vec::new();
let mut last_pos = 0;
let mut i = 0;
while i < text.len() {
if text[i..].starts_with('<') {
if let Some((element, new_pos)) = try_parse_element(text, i) {
if i > last_pos {
let text_content = &text[last_pos..i];
if !text_content.is_empty() {
result.push(VirtualNode::new("text").with_text(text_content));
}
}
result.push(element);
i = new_pos;
last_pos = new_pos;
continue;
}
}
i += 1;
}
if last_pos < text.len() {
let remaining = &text[last_pos..];
if !remaining.is_empty() {
result.push(VirtualNode::new("text").with_text(remaining));
}
}
if result.is_empty() && !text.is_empty() {
result.push(VirtualNode::new("text").with_text(text));
}
result
}
fn try_parse_element(text: &str, pos: usize) -> Option<(VirtualNode, usize)> {
if !text[pos..].starts_with('<') {
return None;
}
let tag_end = text[pos + 1..].find('>')?;
let tag_content = &text[pos + 1..pos + 1 + tag_end];
let tag_name = extract_tag_name(tag_content)?;
if tag_content.ends_with('/') {
return None; }
let after_opening = pos + 1 + tag_end + 1;
let closing_tag = format!("</{tag_name}>");
let close_pos = text[after_opening..].find(&closing_tag)?;
let content = &text[after_opening..after_opening + close_pos];
let after_closing = after_opening + close_pos + closing_tag.len();
let element = if content.contains('<') {
VirtualNode::new(tag_name).with_children(parse_html_content(content))
} else {
VirtualNode::new(tag_name).with_text(content)
};
Some((element, after_closing))
}
fn extract_tag_name(tag_content: &str) -> Option<String> {
let tag_content = tag_content.trim();
if tag_content.is_empty() || tag_content.starts_with('/') {
return None;
}
let tag_name = tag_content
.split_whitespace()
.next()
.unwrap_or(tag_content)
.trim_end_matches('/');
if tag_name.is_empty() {
None
} else {
Some(tag_name.to_string())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct VirtualNode {
pub tag: String,
pub classes: Vec<String>,
pub id: Option<String>,
pub text: Option<String>,
pub attributes: std::collections::HashMap<String, String>,
pub children: Vec<VirtualNode>,
}
#[allow(dead_code)] impl VirtualNode {
pub fn new(tag: impl Into<String>) -> Self {
Self {
tag: tag.into(),
classes: Vec::new(),
id: None,
text: None,
attributes: std::collections::HashMap::new(),
children: Vec::new(),
}
}
pub fn with_class(mut self, class: impl Into<String>) -> Self {
self.classes.push(class.into());
self
}
pub fn with_classes(mut self, classes: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.classes.extend(classes.into_iter().map(Into::into));
self
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn with_attribute(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.insert(name.into(), value.into());
self
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.text = Some(decode_html_entities(&text.into()));
self
}
pub fn with_html_content(mut self, text: impl Into<String>) -> Self {
let content = text.into();
if content.contains('<') {
self.children = parse_html_content(&content);
} else {
self.text = Some(decode_html_entities(&content));
}
self
}
pub fn with_child(mut self, child: VirtualNode) -> Self {
self.children.push(child);
self
}
pub fn with_children(mut self, children: impl IntoIterator<Item = VirtualNode>) -> Self {
self.children.extend(children);
self
}
}
pub trait ToVirtualDom {
fn to_virtual_dom(&self) -> VirtualNode;
}
impl ToVirtualDom for Document<'_> {
fn to_virtual_dom(&self) -> VirtualNode {
let mut node = VirtualNode::new("div").with_class("document");
if let Some(id) = self.id() {
node = node.with_id(id);
}
for block in self.nested_blocks() {
add_block_with_title(&mut node, block);
}
node
}
}
fn add_block_with_title<'a>(parent: &mut VirtualNode, block: &'a Block<'a>) {
let handles_title_internally = matches!(block, Block::List(_));
if !handles_title_internally && let Some(title) = block.title() {
let title_node = VirtualNode::new("div").with_class("title").with_text(title);
parent.children.push(title_node);
}
if let Block::Simple(simple) = block
&& simple.declared_style().is_none()
&& simple.style() == SimpleBlockStyle::Paragraph
{
let mut p_node = block.to_virtual_dom();
let mut wrapper = VirtualNode::new("div").with_class("paragraph");
wrapper.classes.append(&mut p_node.classes);
if p_node.id.is_some() {
wrapper.id = p_node.id.take();
}
wrapper.children.push(p_node);
parent.children.push(wrapper);
} else {
parent.children.push(block.to_virtual_dom());
}
}
impl ToVirtualDom for Block<'_> {
fn to_virtual_dom(&self) -> VirtualNode {
match self {
Block::Simple(simple) => {
if simple.declared_style() == Some("comment") {
return VirtualNode::new("comment");
}
let mut node = simple_block_to_node(simple);
if simple.style() == SimpleBlockStyle::Literal
|| simple.declared_style() == Some("literal")
|| simple.declared_style() == Some("verse")
{
let pre_node =
VirtualNode::new("pre").with_text(simple.content().rendered().to_string());
node = node.with_child(pre_node);
}
node
}
Block::List(list) => list_block_to_node(list),
Block::ListItem(item) => list_item_to_node(item),
Block::Section(section) => {
let mut node = section_to_node(section);
let heading_level = (section.level() + 1).min(6);
let heading_tag = format!("h{}", heading_level);
let mut title_node =
VirtualNode::new(heading_tag).with_text(section.section_title());
if let Some(id) = section.id() {
title_node = title_node.with_id(id);
}
node.children.insert(0, title_node);
node
}
Block::Media(media) => media_to_node(media),
Block::RawDelimited(raw) => raw_delimited_to_node(raw),
Block::CompoundDelimited(compound) => compound_delimited_to_node(compound),
Block::Preamble(preamble) => preamble_to_node(preamble),
Block::Break(break_) => break_to_node(break_),
Block::DocumentAttribute(_) => {
VirtualNode::new("comment")
}
}
}
}
fn simple_block_to_node<'a>(block: &'a SimpleBlock<'a>) -> VirtualNode {
let declared_style = block.declared_style();
let block_style = block.style();
let (tag, wrapper_classes) =
if block_style == SimpleBlockStyle::Literal || declared_style == Some("literal") {
("div", vec!["literalblock"])
} else {
match declared_style {
Some("paragraph") | None => ("p", vec![]),
Some("verse") => ("div", vec!["verseblock"]),
Some("quote") => ("div", vec!["quoteblock"]),
Some("sidebar") => ("div", vec!["sidebarblock"]),
Some("example") => ("div", vec!["exampleblock"]),
Some("open") => ("div", vec!["openblock"]),
Some("pass") => ("div", vec!["passblock"]),
_ => ("p", vec![]),
}
};
let mut node = VirtualNode::new(tag);
for class in wrapper_classes {
node = node.with_class(class);
}
for role in block.roles() {
node = node.with_class(role);
}
if let Some(id) = block.id() {
node = node.with_id(id);
}
if tag == "p" {
node = node.with_html_content(block.content().rendered().to_string());
}
node
}
fn list_block_to_node<'a>(list: &'a ListBlock<'a>) -> VirtualNode {
let is_horizontal =
list.type_() == ListType::Description && list.declared_style() == Some("horizontal");
let (list_tag, base_class) = match list.type_() {
ListType::Unordered => ("ul", "ulist"),
ListType::Ordered => ("ol", "olist"),
ListType::Description => {
if is_horizontal {
("table", "hdlist")
} else {
("dl", "dlist")
}
}
};
let mut list_element = VirtualNode::new(list_tag);
if list.type_() == ListType::Ordered
&& list.declared_style().is_none()
&& let Some(style) = list.marker_style()
{
list_element = list_element.with_class(style);
}
if let Some(attrlist) = list.attrlist() {
for attr in attrlist.attributes() {
if let Some(attr_name) = attr.name() {
list_element = list_element.with_attribute(attr_name, attr.value());
}
}
}
for option in list.options() {
list_element = list_element.with_attribute(option, "");
}
if !is_horizontal && let Some(style) = list.declared_style() {
list_element = list_element.with_class(style);
}
for item in list.nested_blocks() {
if list.type_() == ListType::Description {
if let Block::ListItem(list_item) = item {
if let ListItemMarker::DefinedTerm { term, .. } = list_item.list_item_marker() {
if is_horizontal {
let mut tr_node = VirtualNode::new("tr");
let td_term = VirtualNode::new("td")
.with_class("hdlist1")
.with_html_content(term.rendered().to_string());
tr_node.children.push(td_term);
let mut td_def = VirtualNode::new("td").with_class("hdlist2");
let nested = list_item.nested_blocks().collect::<Vec<_>>();
for child in &nested {
td_def.children.push(child.to_virtual_dom());
}
tr_node.children.push(td_def);
list_element.children.push(tr_node);
} else {
let mut dt_node = VirtualNode::new("dt");
for role in list_item.roles() {
dt_node = dt_node.with_class(role);
}
if let Some(id) = list_item.id() {
dt_node = dt_node.with_id(id);
}
dt_node = dt_node.with_html_content(term.rendered().to_string());
list_element.children.push(dt_node);
let nested = list_item.nested_blocks().collect::<Vec<_>>();
if !nested.is_empty() {
let mut dd_node = VirtualNode::new("dd");
let has_multiple_blocks = nested.len() > 1;
let first_block_from_continuation =
nested.first().is_some_and(|first_block| {
let item_span = list_item.span();
let marker_span = list_item.list_item_marker().span();
let marker_end_offset =
marker_span.byte_offset() + marker_span.data().len();
let first_block_offset = first_block.span().byte_offset();
let item_start = item_span.byte_offset();
if first_block_offset > marker_end_offset
&& marker_end_offset >= item_start
{
let start = marker_end_offset - item_start;
let end = first_block_offset - item_start;
if end <= item_span.data().len() {
let between = &item_span.data()[start..end];
between.lines().any(|line| line.trim() == "+")
} else {
false
}
} else {
false
}
});
for (index, child) in nested.iter().enumerate() {
let child_vdom = child.to_virtual_dom();
let should_wrap = child_vdom.tag == "p"
&& child_vdom.classes.is_empty()
&& ((has_multiple_blocks && index > 0)
|| (index == 0 && first_block_from_continuation));
if should_wrap {
let wrapper = VirtualNode::new("div")
.with_class("paragraph")
.with_child(child_vdom);
dd_node.children.push(wrapper);
} else {
dd_node.children.push(child_vdom);
}
}
list_element.children.push(dd_node);
}
}
}
}
} else {
list_element.children.push(item.to_virtual_dom());
}
}
let mut wrapper = VirtualNode::new("div").with_class(base_class);
if list.type_() == ListType::Ordered
&& list.declared_style().is_none()
&& let Some(style) = list.marker_style()
{
wrapper = wrapper.with_class(style);
}
if !is_horizontal && let Some(style) = list.declared_style() {
wrapper = wrapper.with_class(style);
}
for role in list.roles() {
wrapper = wrapper.with_class(role);
}
if let Some(id) = list.id() {
wrapper = wrapper.with_id(id);
}
if let Some(title) = list.title() {
let title_node = VirtualNode::new("div").with_class("title").with_text(title);
wrapper.children.push(title_node);
}
wrapper.children.push(list_element);
wrapper
}
fn list_item_to_node<'a>(item: &'a ListItem<'a>) -> VirtualNode {
let mut node = VirtualNode::new("li");
for role in item.roles() {
node = node.with_class(role);
}
if let Some(id) = item.id() {
node = node.with_id(id);
}
let nested = item.nested_blocks().collect::<Vec<_>>();
let has_multiple_blocks = nested.len() > 1;
for (index, child) in nested.iter().enumerate() {
let child_vdom = child.to_virtual_dom();
if has_multiple_blocks
&& index > 0
&& child_vdom.tag == "p"
&& child_vdom.classes.is_empty()
{
let wrapper = VirtualNode::new("div")
.with_class("paragraph")
.with_child(child_vdom);
node.children.push(wrapper);
} else {
node.children.push(child_vdom);
}
}
node
}
fn section_to_node<'a>(section: &'a SectionBlock<'a>) -> VirtualNode {
let class = format!("sect{}", section.level());
let mut node = VirtualNode::new("div").with_class(class);
for role in section.roles() {
node = node.with_class(role);
}
if let Some(id) = section.id() {
node = node.with_id(id);
}
for child in section.nested_blocks() {
add_block_with_title(&mut node, child);
}
node
}
fn media_to_node<'a>(media: &'a MediaBlock<'a>) -> VirtualNode {
let context = media.raw_context();
let class = format!("{}block", context.as_ref());
let mut node = VirtualNode::new("div").with_class(class);
for role in media.roles() {
node = node.with_class(role);
}
if let Some(id) = media.id() {
node = node.with_id(id);
}
node
}
fn raw_delimited_to_node<'a>(raw: &'a RawDelimitedBlock<'a>) -> VirtualNode {
let context = raw.raw_context();
let (tag, classes): (&str, Vec<String>) = match context.as_ref() {
"listing" => ("div", vec!["listingblock".to_string()]),
"literal" => ("div", vec!["literalblock".to_string()]),
"comment" => ("comment", vec![]),
_ => ("div", vec![format!("{}block", context.as_ref())]),
};
let mut node = VirtualNode::new(tag);
for class in classes {
node = node.with_class(class);
}
for role in raw.roles() {
node = node.with_class(role);
}
if let Some(id) = raw.id() {
node = node.with_id(id);
}
if let Some(title) = raw.title() {
let title_node = VirtualNode::new("div").with_class("title").with_text(title);
node.children.push(title_node);
}
if tag != "comment" {
let is_source_block = raw
.attrlist()
.and_then(|attrlist| attrlist.attributes().next())
.map(|attr| attr.value() == "source")
.unwrap_or(false);
if is_source_block {
let mut code = VirtualNode::new("code");
if let Some(attrlist) = raw.attrlist() {
let mut attrs = attrlist.attributes();
attrs.next();
if let Some(lang_attr) = attrs.next() {
code = code.with_attribute("data-lang", lang_attr.value());
}
}
if let Some(content) = raw.rendered_content() {
code = code.with_text(content);
}
let pre = VirtualNode::new("pre").with_child(code);
node.children.push(pre);
} else {
let mut pre = VirtualNode::new("pre");
if let Some(content) = raw.rendered_content() {
pre = pre.with_text(content);
}
node.children.push(pre);
}
}
node
}
fn compound_delimited_to_node<'a>(compound: &'a CompoundDelimitedBlock<'a>) -> VirtualNode {
let context = compound.raw_context();
let class = format!("{}block", context.as_ref());
let mut node = VirtualNode::new("div").with_class(class);
for role in compound.roles() {
node = node.with_class(role);
}
if let Some(id) = compound.id() {
node = node.with_id(id);
}
for child in compound.nested_blocks() {
node.children.push(child.to_virtual_dom());
}
node
}
fn preamble_to_node<'a>(preamble: &'a Preamble<'a>) -> VirtualNode {
let mut node = VirtualNode::new("div").with_id("preamble");
for child in preamble.nested_blocks() {
node.children.push(child.to_virtual_dom());
}
node
}
fn break_to_node<'a>(break_: &'a Break<'a>) -> VirtualNode {
let context = break_.raw_context();
match context.as_ref() {
"thematic_break" => VirtualNode::new("hr"),
"page_break" => VirtualNode::new("div").with_class("page-break"),
_ => VirtualNode::new("hr"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::prelude::*;
#[test]
fn empty_document() {
let doc = Parser::default().parse("");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.tag, "div");
assert_eq!(vdom.classes, vec!["document"]);
assert_eq!(vdom.children.len(), 0);
}
#[test]
fn single_paragraph() {
let doc = Parser::default().parse("Hello, world!");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.tag, "div");
assert_eq!(vdom.classes, vec!["document"]);
assert_eq!(vdom.children.len(), 1);
let wrapper = &vdom.children[0];
assert_eq!(wrapper.tag, "div");
assert!(wrapper.classes.contains(&"paragraph".to_string()));
assert_eq!(wrapper.children.len(), 1);
let para = &wrapper.children[0];
assert_eq!(para.tag, "p");
assert_eq!(para.text.as_deref(), Some("Hello, world!"));
}
#[test]
fn unordered_list() {
let doc = Parser::default().parse("* item 1\n* item 2\n* item 3");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children.len(), 1);
let wrapper = &vdom.children[0];
assert_eq!(wrapper.tag, "div");
assert!(wrapper.classes.contains(&"ulist".to_string()));
assert_eq!(wrapper.children.len(), 1);
let ul = &wrapper.children[0];
assert_eq!(ul.tag, "ul");
assert_eq!(ul.children.len(), 3);
for li in &ul.children {
assert_eq!(li.tag, "li");
}
}
#[test]
fn section_with_paragraph() {
let doc = Parser::default().parse("== Section Title\n\nSome text.");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children.len(), 1);
let section = &vdom.children[0];
assert_eq!(section.tag, "div");
assert!(section.classes.contains(&"sect1".to_string()));
assert_eq!(section.children.len(), 2);
assert_eq!(section.children[0].tag, "h2");
let para_wrapper = §ion.children[1];
assert_eq!(para_wrapper.tag, "div");
assert!(para_wrapper.classes.contains(&"paragraph".to_string()));
assert_eq!(para_wrapper.children.len(), 1);
assert_eq!(para_wrapper.children[0].tag, "p");
}
#[test]
fn ordered_list_has_arabic_class() {
let doc = Parser::default().parse(". item 1\n. item 2\n. item 3");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children.len(), 1);
let wrapper = &vdom.children[0];
assert_eq!(wrapper.tag, "div");
assert!(wrapper.classes.contains(&"olist".to_string()));
assert!(wrapper.classes.contains(&"arabic".to_string()));
assert_eq!(wrapper.children.len(), 1);
let ol = &wrapper.children[0];
assert_eq!(ol.tag, "ol");
assert!(ol.classes.contains(&"arabic".to_string()));
assert_eq!(ol.children.len(), 3);
for li in &ol.children {
assert_eq!(li.tag, "li");
}
}
#[test]
fn inline_html_markup_in_paragraph() {
let doc = Parser::default().parse("I am *strong* and _emphasized_ and `code`.");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children.len(), 1);
let wrapper = &vdom.children[0];
assert_eq!(wrapper.tag, "div");
assert!(wrapper.classes.contains(&"paragraph".to_string()));
assert_eq!(wrapper.children.len(), 1);
let para = &wrapper.children[0];
assert_eq!(para.tag, "p");
assert!(
!para.children.is_empty(),
"Should have child nodes from parsed HTML"
);
let strong = para.children.iter().find(|c| c.tag == "strong");
assert!(strong.is_some(), "Should have a <strong> element");
assert_eq!(strong.unwrap().text.as_deref(), Some("strong"));
let em = para.children.iter().find(|c| c.tag == "em");
assert!(em.is_some(), "Should have an <em> element");
assert_eq!(em.unwrap().text.as_deref(), Some("emphasized"));
let code = para.children.iter().find(|c| c.tag == "code");
assert!(code.is_some(), "Should have a <code> element");
assert_eq!(code.unwrap().text.as_deref(), Some("code"));
}
#[test]
fn description_list_uses_dt_and_dd_tags() {
let doc = Parser::default().parse("term1:: definition1\nterm2:: definition2");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children.len(), 1);
let wrapper = &vdom.children[0];
assert_eq!(wrapper.tag, "div");
assert!(wrapper.classes.contains(&"dlist".to_string()));
assert_eq!(wrapper.children.len(), 1);
let dl = &wrapper.children[0];
assert_eq!(dl.tag, "dl");
assert_eq!(dl.children.len(), 4);
assert_eq!(dl.children[0].tag, "dt");
assert_eq!(dl.children[0].text.as_deref(), Some("term1"));
assert_eq!(dl.children[1].tag, "dd");
assert_eq!(dl.children[1].children.len(), 1);
assert_eq!(dl.children[1].children[0].tag, "p");
assert_eq!(
dl.children[1].children[0].text.as_deref(),
Some("definition1")
);
assert_eq!(dl.children[2].tag, "dt");
assert_eq!(dl.children[2].text.as_deref(), Some("term2"));
assert_eq!(dl.children[3].tag, "dd");
assert_eq!(dl.children[3].children.len(), 1);
assert_eq!(dl.children[3].children[0].tag, "p");
assert_eq!(
dl.children[3].children[0].text.as_deref(),
Some("definition2")
);
}
}