use std::{
cell::{Cell, RefCell},
sync::LazyLock,
thread::LocalKey,
};
use regex::Regex;
use crate::{
Document, HasSpan,
blocks::{
AdmonitionBlock, Block, Break, ColumnStyle, CompoundDelimitedBlock, ContentModel, Frame,
Grid, HorizontalAlignment, IsBlock, ListBlock, ListItem, ListItemMarker, ListType,
MediaBlock, Preamble, QuoteBlock, QuoteType, RawDelimitedBlock, SectionBlock, SimpleBlock,
SimpleBlockStyle, Stripes, TableBlock, TableCellContent, TableColumn, TableRow,
VerticalAlignment,
},
document::{InterpretedValue, TocMode},
};
#[derive(Clone, Copy, Eq, PartialEq)]
enum IconsMode {
None,
Image,
Font,
}
thread_local! {
static ICONS_MODE: Cell<IconsMode> = const { Cell::new(IconsMode::None) };
}
#[derive(Clone)]
struct TocData {
ul: Option<VirtualNode>,
title: String,
class: String,
}
impl TocData {
fn build(levels: usize, title: &str, class: &str, blocks: &[Block]) -> Self {
Self {
ul: build_toc_ul(blocks, 1, levels),
title: title.to_string(),
class: class.to_string(),
}
}
}
thread_local! {
static MACRO_TOC: RefCell<Option<TocData>> = const { RefCell::new(None) };
static PREAMBLE_TOC: RefCell<Option<TocData>> = const { RefCell::new(None) };
}
struct TocScopeGuard {
key: &'static LocalKey<RefCell<Option<TocData>>>,
prev: Option<TocData>,
}
impl Drop for TocScopeGuard {
fn drop(&mut self) {
self.key.with(|c| *c.borrow_mut() = self.prev.take());
}
}
fn scoped_toc(
key: &'static LocalKey<RefCell<Option<TocData>>>,
value: Option<TocData>,
) -> TocScopeGuard {
let prev = key.with(|c| c.replace(value));
TocScopeGuard { key, prev }
}
static TOC_MACRO: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^toc::\[.*\]$").unwrap());
fn is_toc_macro(block: &Block) -> bool {
matches!(block, Block::Simple(simple)
if simple.declared_style().is_none()
&& TOC_MACRO.is_match(simple.content().original().data().trim()))
}
fn toc_macro_node(block: &Block) -> Option<VirtualNode> {
MACRO_TOC.with(|m| {
m.borrow()
.as_ref()
.map(|data| toc_block(block.id().unwrap_or("toc"), true, data))
})
}
fn build_toc_ul(blocks: &[Block], level: usize, max_level: usize) -> Option<VirtualNode> {
if level > max_level {
return None;
}
let mut ul = VirtualNode::new("ul").with_class(format!("sectlevel{level}"));
for block in blocks {
if let Block::Section(section) = block {
let mut li = VirtualNode::new("li");
let id = section.id().unwrap_or_default();
li.children.push(
VirtualNode::new("a")
.with_attribute("href", format!("#{id}"))
.with_text(section.section_title()),
);
if let Some(sub) =
build_toc_ul(section.nested_blocks().as_slice(), level + 1, max_level)
{
li.children.push(sub);
}
ul.children.push(li);
}
}
(!ul.children.is_empty()).then_some(ul)
}
fn toc_block(id: &str, title_class: bool, data: &TocData) -> VirtualNode {
let mut node = VirtualNode::new("div")
.with_id(id)
.with_class(data.class.as_str());
let mut title = VirtualNode::new("div")
.with_id(format!("{id}title"))
.with_text(data.title.as_str());
if title_class {
title = title.with_class("title");
}
node.children.push(title);
if let Some(ul) = data.ul.clone() {
node.children.push(ul);
}
node
}
fn icons_mode_from_document(doc: &Document) -> IconsMode {
for attr in doc.header().attributes() {
if attr.name().data() == "icons" {
return match attr.value() {
InterpretedValue::Value(v) if v == "font" => IconsMode::Font,
InterpretedValue::Unset => IconsMode::None,
_ => IconsMode::Image,
};
}
}
IconsMode::None
}
fn decode_html_entities(s: &str) -> String {
let s = decode_numeric_entities(s);
s.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
.replace("'", "'")
}
fn decode_numeric_entities(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut rest = s;
while let Some(amp) = rest.find("&#") {
result.push_str(&rest[..amp]);
let after = &rest[amp + 2..];
let (digits, radix) = match after.strip_prefix(['x', 'X']) {
Some(hex) => (hex, 16),
None => (after, 10),
};
let end = digits.find(';');
let parsed = end
.map(|e| &digits[..e])
.and_then(|d| u32::from_str_radix(d, radix).ok())
.and_then(char::from_u32);
match (end, parsed) {
(Some(e), Some(ch)) => {
result.push(ch);
rest = &digits[e + 1..];
}
_ => {
result.push_str("&#");
rest = after;
}
}
}
result.push_str(rest);
result
}
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)?;
const VOID_ELEMENTS: &[&str] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
"track", "wbr",
];
if tag_content.ends_with('/') || VOID_ELEMENTS.iter().any(|v| *v == tag_name) {
let after_opening = pos + 1 + tag_end + 1;
let element = apply_tag_attributes(VirtualNode::new(tag_name), tag_content);
return Some((element, after_opening));
}
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)
};
let element = apply_tag_attributes(element, tag_content);
Some((element, after_closing))
}
static HTML_ATTR: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*"([^"]*)""#).unwrap()
});
fn apply_tag_attributes(mut node: VirtualNode, tag_content: &str) -> VirtualNode {
let attrs = tag_content
.trim()
.split_once(char::is_whitespace)
.map(|(_, rest)| rest)
.unwrap_or("");
for caps in HTML_ATTR.captures_iter(attrs) {
let name = &caps[1];
let value = caps[2].to_string();
match name {
"id" => node.id = Some(value),
"class" => {
for class in value.split_whitespace() {
node.classes.push(class.to_string());
}
}
_ => {
node.attributes.insert(name.to_string(), value);
}
}
}
node
}
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 {
ICONS_MODE.with(|m| m.set(icons_mode_from_document(self)));
let mut node = VirtualNode::new("div").with_class("document");
if let Some(id) = self.id() {
node = node.with_id(id);
}
let show_doctitle = if self.has_attribute("showtitle") {
self.is_attribute_set("showtitle")
} else if self.has_attribute("notitle") {
!self.is_attribute_set("notitle")
} else {
false
};
if show_doctitle && let Some(title) = self.doctitle() {
node.children.push(VirtualNode::new("h1").with_text(title));
}
let toc_mode = self.toc_mode();
let toc_data = toc_mode.is_enabled().then(|| {
TocData::build(
self.toc_levels(),
self.toc_title(),
self.toc_class(),
self.nested_blocks().as_slice(),
)
});
let mut _macro_scope = None;
let mut _preamble_scope = None;
match toc_mode {
TocMode::Auto | TocMode::Left | TocMode::Right => {
if let Some(data) = &toc_data {
node.children.push(toc_block("toc", false, data));
}
}
TocMode::Preamble => _preamble_scope = Some(scoped_toc(&PREAMBLE_TOC, toc_data)),
TocMode::Macro => _macro_scope = Some(scoped_toc(&MACRO_TOC, toc_data)),
TocMode::Disabled => {}
}
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>) {
if is_toc_macro(block) {
if let Some(toc) = toc_macro_node(block) {
parent.children.push(toc);
}
return;
}
let handles_title_internally = is_collapsible(block)
|| is_sidebar(block)
|| is_example(block)
|| is_open(block)
|| matches!(
block,
Block::List(_) | Block::Table(_) | Block::Admonition(_) | Block::Quote(_)
);
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 {
if is_collapsible(self) {
return collapsible_to_node(self);
}
if is_sidebar(self) {
return sidebar_to_node(self);
}
if is_example(self) {
return example_to_node(self);
}
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::Admonition(admonition) => admonition_to_node(admonition),
Block::Quote(quote) => quote_to_node(quote),
Block::Table(table) => table_to_node(table),
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 {
if list.type_() == ListType::Callout {
let icons = ICONS_MODE.with(|m| m.get());
if icons != IconsMode::None {
return colist_icon_table_to_node(list, icons);
}
}
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")
}
}
ListType::Callout => ("ol", "colist"),
};
let mut list_element = VirtualNode::new(list_tag);
let is_checklist = list.is_checklist();
let interactive = is_checklist && list.has_option("interactive");
let icons_font = ICONS_MODE.with(|m| m.get()) == IconsMode::Font;
if is_checklist {
list_element = list_element.with_class("checklist");
}
if list.type_() == ListType::Ordered
&& list.declared_style().is_none()
&& let Some(style) = list.marker_style()
{
list_element = list_element.with_class(style);
}
if list.type_() == ListType::Ordered
&& let Some(Block::ListItem(first)) = list.nested_blocks().next()
&& let Some(ordinal) = first.list_item_marker().ordinal_value()
&& ordinal != 1
{
list_element = list_element.with_attribute("start", ordinal.to_string());
}
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);
}
if list.is_bibliography() && list.declared_style() != Some("bibliography") {
list_element = list_element.with_class("bibliography");
}
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 if is_checklist && let Block::ListItem(list_item) = item {
list_element
.children
.push(checklist_item_to_node(list_item, interactive, icons_font));
} else {
list_element.children.push(item.to_virtual_dom());
}
}
let mut wrapper = VirtualNode::new("div").with_class(base_class);
if is_checklist {
wrapper = wrapper.with_class("checklist");
}
if matches!(list.type_(), ListType::Ordered | ListType::Callout)
&& 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);
}
if list.is_bibliography() && list.declared_style() != Some("bibliography") {
wrapper = wrapper.with_class("bibliography");
}
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 colist_icon_table_to_node<'a>(list: &'a ListBlock<'a>, icons: IconsMode) -> VirtualNode {
let mut wrapper = VirtualNode::new("div").with_class("colist");
if let Some(style) = list.marker_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() {
wrapper
.children
.push(VirtualNode::new("div").with_class("title").with_text(title));
}
let mut table = VirtualNode::new("table");
for (index, item) in list.nested_blocks().enumerate() {
let num = index + 1;
let mut row = VirtualNode::new("tr");
let mut icon_cell = VirtualNode::new("td");
match icons {
IconsMode::Font => {
icon_cell.children.push(
VirtualNode::new("i")
.with_class("conum")
.with_attribute("data-value", num.to_string()),
);
icon_cell
.children
.push(VirtualNode::new("b").with_text(num.to_string()));
}
IconsMode::Image => {
icon_cell.children.push(
VirtualNode::new("img")
.with_attribute("src", format!("./images/icons/callouts/{num}.png"))
.with_attribute("alt", num.to_string()),
);
}
IconsMode::None => {}
}
row.children.push(icon_cell);
let mut text_cell = VirtualNode::new("td");
if let Some(text) = item
.nested_blocks()
.next()
.and_then(|b| b.rendered_content())
{
text_cell = text_cell.with_html_content(text);
}
row.children.push(text_cell);
table.children.push(row);
}
wrapper.children.push(table);
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 checklist_item_to_node<'a>(
item: &'a ListItem<'a>,
interactive: bool,
icons_font: bool,
) -> VirtualNode {
let mut node = list_item_to_node(item);
if let Some(checked) = item.checkbox()
&& let Some(principal) = node.children.first_mut()
{
prepend_checklist_marker(principal, checked, interactive, icons_font);
}
node
}
fn prepend_checklist_marker(
p: &mut VirtualNode,
checked: bool,
interactive: bool,
icons_font: bool,
) {
let mut children = checklist_marker_nodes(checked, interactive, icons_font);
if let Some(text) = p.text.take() {
let mut text_node = VirtualNode::new("text");
text_node.text = Some(text);
children.push(text_node);
}
children.append(&mut p.children);
p.children = children;
}
fn checklist_marker_nodes(checked: bool, interactive: bool, icons_font: bool) -> Vec<VirtualNode> {
let space = || {
let mut node = VirtualNode::new("text");
node.text = Some(" ".to_string());
node
};
if interactive {
let mut input = VirtualNode::new("input")
.with_attribute("type", "checkbox")
.with_attribute("data-item-complete", if checked { "1" } else { "0" });
if checked {
input = input.with_attribute("checked", "");
}
vec![input, space()]
} else if icons_font {
let icon = VirtualNode::new("i")
.with_class("fa")
.with_class(if checked {
"fa-check-square-o"
} else {
"fa-square-o"
});
vec![icon, space()]
} else {
let mut glyph = VirtualNode::new("text");
glyph.text = Some(if checked { "\u{2713} " } else { "\u{274f} " }.to_string());
vec![glyph]
}
}
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.resolved_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_html_content(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_html_content(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);
}
if let Some(title) = compound.title() {
node.children
.push(VirtualNode::new("div").with_class("title").with_text(title));
}
let mut content = VirtualNode::new("div").with_class("content");
for child in compound.nested_blocks() {
add_block_with_title(&mut content, child);
}
node.children.push(content);
node
}
fn is_collapsible<'a>(block: &'a Block<'a>) -> bool {
block.resolved_context().as_ref() == "example" && block.has_option("collapsible")
}
fn collapsible_to_node<'a>(block: &'a Block<'a>) -> VirtualNode {
let mut node = VirtualNode::new("details");
if block.has_option("open") {
node = node.with_attribute("open", "");
}
if let Some(id) = block.id() {
node = node.with_id(id);
}
for role in block.roles() {
node = node.with_class(role);
}
let summary_text = block.title().unwrap_or("Details");
node.children.push(
VirtualNode::new("summary")
.with_class("title")
.with_text(summary_text),
);
let mut content = VirtualNode::new("div").with_class("content");
match block.content_model() {
ContentModel::Compound => {
for child in block.nested_blocks() {
add_block_with_title(&mut content, child);
}
}
_ => {
if let Some(rendered) = block.rendered_content() {
content = content.with_html_content(rendered);
}
}
}
node.children.push(content);
node
}
fn is_sidebar<'a>(block: &'a Block<'a>) -> bool {
block.resolved_context().as_ref() == "sidebar"
}
fn is_open<'a>(block: &'a Block<'a>) -> bool {
matches!(block, Block::CompoundDelimited(_)) && block.resolved_context().as_ref() == "open"
}
fn sidebar_to_node<'a>(block: &'a Block<'a>) -> VirtualNode {
let mut node = VirtualNode::new("div").with_class("sidebarblock");
for role in block.roles() {
node = node.with_class(role);
}
if let Some(id) = block.id() {
node = node.with_id(id);
}
let mut content = VirtualNode::new("div").with_class("content");
match block.content_model() {
ContentModel::Compound => {
for child in block.nested_blocks() {
add_block_with_title(&mut content, child);
}
}
_ => {
if let Some(rendered) = block.rendered_content() {
content = content.with_html_content(rendered);
}
}
}
if let Some(title) = block.title() {
content.children.insert(
0,
VirtualNode::new("div").with_class("title").with_text(title),
);
}
node.children.push(content);
node
}
fn is_example<'a>(block: &'a Block<'a>) -> bool {
block.resolved_context().as_ref() == "example"
}
fn example_to_node<'a>(block: &'a Block<'a>) -> VirtualNode {
let mut node = VirtualNode::new("div").with_class("exampleblock");
for role in block.roles() {
node = node.with_class(role);
}
if let Some(id) = block.id() {
node = node.with_id(id);
}
if let Some(title) = block.title() {
let caption_text = match block.caption() {
Some(prefix) => format!("{prefix}{title}"),
None => title.to_string(),
};
node.children.push(
VirtualNode::new("div")
.with_class("title")
.with_text(caption_text),
);
}
let mut content = VirtualNode::new("div").with_class("content");
match block.content_model() {
ContentModel::Compound => {
for child in block.nested_blocks() {
add_block_with_title(&mut content, child);
}
}
_ => {
if let Some(rendered) = block.rendered_content() {
content = content.with_html_content(rendered);
}
}
}
node.children.push(content);
node
}
fn admonition_to_node<'a>(admonition: &'a AdmonitionBlock<'a>) -> VirtualNode {
let mut node = VirtualNode::new("div")
.with_class("admonitionblock")
.with_class(admonition.name());
for role in admonition.roles() {
node = node.with_class(role);
}
if let Some(id) = admonition.id() {
node = node.with_id(id);
}
let mut icon_cell = VirtualNode::new("td").with_class("icon");
if admonition.icons_font() {
icon_cell = icon_cell.with_child(
VirtualNode::new("i")
.with_class("fa")
.with_class(format!("icon-{}", admonition.name()))
.with_attribute("title", admonition.label()),
);
} else {
icon_cell = icon_cell.with_child(
VirtualNode::new("div")
.with_class("title")
.with_text(admonition.label()),
);
}
let mut content_cell = VirtualNode::new("td").with_class("content");
if let Some(title) = admonition.title() {
content_cell
.children
.push(VirtualNode::new("div").with_class("title").with_text(title));
}
match admonition.content_model() {
ContentModel::Compound => {
for child in admonition.nested_blocks() {
add_block_with_title(&mut content_cell, child);
}
}
_ => {
if let Some(content) = admonition.content() {
let rendered = content.rendered();
if rendered.contains('<') {
content_cell.children.extend(parse_html_content(rendered));
} else {
content_cell.text = Some(decode_html_entities(rendered));
}
}
}
}
let row = VirtualNode::new("tr")
.with_child(icon_cell)
.with_child(content_cell);
node.with_child(VirtualNode::new("table").with_child(row))
}
fn quote_to_node<'a>(quote: &'a QuoteBlock<'a>) -> VirtualNode {
let block_class = format!("{}block", quote.type_().name());
let mut node = VirtualNode::new("div").with_class(block_class);
for role in quote.roles() {
node = node.with_class(role);
}
if let Some(id) = quote.id() {
node = node.with_id(id);
}
if let Some(title) = quote.title() {
node.children
.push(VirtualNode::new("div").with_class("title").with_text(title));
}
match quote.type_() {
QuoteType::Verse => {
let rendered = quote
.content()
.map(|c| c.rendered().to_string())
.unwrap_or_default();
node.children.push(
VirtualNode::new("pre")
.with_class("content")
.with_text(rendered),
);
}
QuoteType::Quote => {
let mut blockquote = VirtualNode::new("blockquote");
match quote.content_model() {
ContentModel::Compound => {
for child in quote.blocks() {
add_block_with_title(&mut blockquote, child);
}
}
_ => {
if let Some(content) = quote.content() {
let rendered = content.rendered();
if rendered.contains('<') {
blockquote.children.extend(parse_html_content(rendered));
} else {
blockquote.text = Some(decode_html_entities(rendered));
}
}
}
}
node.children.push(blockquote);
}
}
if let Some(attribution_node) = quote_attribution_node(quote) {
node.children.push(attribution_node);
}
node
}
fn quote_attribution_node(quote: &QuoteBlock<'_>) -> Option<VirtualNode> {
let attribution = quote.attribution();
let citetitle = quote.citetitle();
if attribution.is_none() && citetitle.is_none() {
return None;
}
let mut node = VirtualNode::new("div").with_class("attribution");
if let Some(attribution) = attribution {
let lead = format!("— {attribution}");
node.children.extend(parse_html_content(&lead));
}
if let Some(citetitle) = citetitle {
node.children
.push(VirtualNode::new("cite").with_html_content(citetitle));
}
Some(node)
}
fn table_to_node<'a>(table: &'a TableBlock<'a>) -> VirtualNode {
let mut classes = vec![
"tableblock".to_string(),
frame_class(table.frame()).to_string(),
grid_class(table.grid()).to_string(),
];
let autowidth = table.columns().iter().any(TableColumn::is_autowidth);
if autowidth {
classes.push("fit-content".to_string());
} else if table.width().is_none() {
classes.push("stretch".to_string());
}
if let Some(stripes) = stripes_class(table.stripes()) {
classes.push(stripes.to_string());
}
if let Some(float) = table
.attrlist()
.and_then(|a| a.named_attribute("float"))
.map(|a| a.value())
{
classes.push(float.to_string());
}
let mut node = VirtualNode::new("table").with_classes(classes);
if let Some(id) = table.id() {
node = node.with_id(id);
}
for role in table.roles() {
node = node.with_class(role);
}
if let Some(width) = table.width() {
node = node.with_attribute("width", format!("{width}%"));
}
if let Some(title) = table.title() {
let caption_text = match table.caption() {
Some(caption) => format!("{caption}{title}"),
None => title.to_string(),
};
node.children.push(
VirtualNode::new("caption")
.with_class("title")
.with_text(caption_text),
);
}
if table.header_row().is_none() && table.body_rows().is_empty() && table.footer_row().is_none()
{
return node;
}
let mut colgroup = VirtualNode::new("colgroup");
for (column, pcwidth) in table.columns().iter().zip(column_pcwidths(table.columns())) {
let mut col = VirtualNode::new("col").with_attribute("colpcwidth", pcwidth.clone());
if column.is_autowidth() {
col = col.with_attribute("autowidth-option", "");
} else {
col = col.with_attribute("width", format!("{pcwidth}%"));
}
colgroup.children.push(col);
}
node.children.push(colgroup);
if let Some(header) = table.header_row() {
let mut thead = VirtualNode::new("thead");
thead.children.push(table_row_to_node(header, true, false));
node.children.push(thead);
}
if !table.body_rows().is_empty() {
let mut tbody = VirtualNode::new("tbody");
for row in table.body_rows() {
tbody.children.push(table_row_to_node(row, false, true));
}
node.children.push(tbody);
}
if let Some(footer) = table.footer_row() {
let mut tfoot = VirtualNode::new("tfoot");
tfoot.children.push(table_row_to_node(footer, false, true));
node.children.push(tfoot);
}
node
}
fn table_row_to_node(row: &TableRow<'_>, header_row: bool, wrap_in_paragraph: bool) -> VirtualNode {
let mut tr = VirtualNode::new("tr");
for cell in row.cells() {
let cell_tag = if header_row || cell.style() == ColumnStyle::Header {
"th"
} else {
"td"
};
let mut cell_node = VirtualNode::new(cell_tag).with_classes([
"tableblock".to_string(),
halign_class(cell.h_align()).to_string(),
valign_class(cell.v_align()).to_string(),
]);
if cell.colspan() > 1 {
cell_node = cell_node.with_attribute("colspan", cell.colspan().to_string());
}
if cell.rowspan() > 1 {
cell_node = cell_node.with_attribute("rowspan", cell.rowspan().to_string());
}
match cell.content() {
TableCellContent::Simple(content) => {
let rendered = content.rendered().to_string();
match cell.style() {
ColumnStyle::Literal => {
cell_node.children.push(
VirtualNode::new("div")
.with_class("literal")
.with_child(VirtualNode::new("pre").with_html_content(rendered)),
);
}
_ if !wrap_in_paragraph => {
match style_wrapper(cell.style()) {
Some(tag) => cell_node
.children
.push(VirtualNode::new(tag).with_html_content(rendered)),
None => cell_node = cell_node.with_html_content(rendered),
}
}
_ if rendered.is_empty() => {}
style => match style_wrapper(style) {
Some(tag) => {
cell_node.children.push(
VirtualNode::new("p")
.with_class("tableblock")
.with_child(VirtualNode::new(tag).with_html_content(rendered)),
);
}
None => {
for para in
split_cell_paragraphs(content.original().data(), content.rendered())
{
cell_node.children.push(
VirtualNode::new("p")
.with_class("tableblock")
.with_html_content(para),
);
}
}
},
}
}
TableCellContent::AsciiDoc(cell) => {
let mut content = VirtualNode::new("div").with_class("content");
if let Some(title) = cell.title() {
content
.children
.push(VirtualNode::new("h1").with_text(title));
}
let toc_mode = cell.toc_mode();
let toc_data = toc_mode.is_enabled().then(|| {
TocData::build(
cell.toc_levels(),
cell.toc_title(),
cell.toc_class(),
cell.blocks(),
)
});
if matches!(toc_mode, TocMode::Auto | TocMode::Left | TocMode::Right)
&& let Some(data) = &toc_data
{
content.children.push(toc_block("toc", false, data));
}
let _macro_scope = scoped_toc(
&MACRO_TOC,
match toc_mode {
TocMode::Macro => toc_data.clone(),
_ => None,
},
);
let _preamble_scope = scoped_toc(
&PREAMBLE_TOC,
match toc_mode {
TocMode::Preamble => toc_data,
_ => None,
},
);
if cell.is_inline() {
for block in cell.blocks() {
match block.rendered_content() {
Some(rendered) => {
content.children.extend(parse_html_content(rendered));
}
None => add_block_with_title(&mut content, block),
}
}
} else {
for block in cell.blocks() {
add_block_with_title(&mut content, block);
}
}
cell_node.children.push(content);
}
}
tr.children.push(cell_node);
}
tr
}
fn column_pcwidths(columns: &[TableColumn]) -> Vec<String> {
let n = columns.len();
if n == 0 {
return vec![];
}
let fixed_total: usize = columns
.iter()
.filter(|c| !c.is_autowidth())
.map(TableColumn::width)
.sum();
let (effective, base): (Vec<f64>, f64) = if columns.iter().any(TableColumn::is_autowidth) {
let autowidth_count = columns.iter().filter(|c| c.is_autowidth()).count();
let (share, base) = if fixed_total > 100 {
(0.0, fixed_total as f64)
} else {
(
truncate4((100.0 - fixed_total as f64) / autowidth_count as f64),
100.0,
)
};
let effective = columns
.iter()
.map(|c| {
if c.is_autowidth() {
share
} else {
c.width() as f64
}
})
.collect();
(effective, base)
} else {
let base = if fixed_total == 0 {
n as f64
} else {
fixed_total as f64
};
(columns.iter().map(|c| c.width() as f64).collect(), base)
};
let mut pct: Vec<f64> = effective
.iter()
.map(|w| truncate4(w * 100.0 / base))
.collect();
let total: f64 = pct.iter().sum();
if (total - 100.0).abs() > 1e-9 {
let last = n - 1;
pct[last] = round4(100.0 - total + pct[last]);
}
pct.iter().map(|w| format_pcwidth(*w)).collect()
}
fn split_cell_paragraphs(source: &str, rendered: &str) -> Vec<String> {
let source_lines: Vec<&str> = source.split('\n').collect();
let rendered_lines: Vec<&str> = rendered.split('\n').collect();
if source_lines.len() != rendered_lines.len() {
return rendered
.split("\n\n")
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect();
}
let mut paragraphs: Vec<String> = vec![];
let mut current: Vec<&str> = vec![];
for (src, rendered) in source_lines.iter().zip(rendered_lines.iter()) {
if src.trim().is_empty() {
if !current.is_empty() {
paragraphs.push(current.join("\n").trim().to_string());
current.clear();
}
} else {
current.push(rendered);
}
}
if !current.is_empty() {
paragraphs.push(current.join("\n").trim().to_string());
}
paragraphs.retain(|p| !p.is_empty());
paragraphs
}
fn truncate4(x: f64) -> f64 {
(x * 10000.0).trunc() / 10000.0
}
fn round4(x: f64) -> f64 {
(x * 10000.0).round() / 10000.0
}
fn format_pcwidth(x: f64) -> String {
let s = format!("{x:.4}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.to_string()
}
fn style_wrapper(style: ColumnStyle) -> Option<&'static str> {
match style {
ColumnStyle::Strong => Some("strong"),
ColumnStyle::Emphasis => Some("em"),
ColumnStyle::Monospace => Some("code"),
_ => None,
}
}
fn frame_class(frame: Frame) -> &'static str {
match frame {
Frame::All => "frame-all",
Frame::Ends => "frame-ends",
Frame::Sides => "frame-sides",
Frame::None => "frame-none",
}
}
fn grid_class(grid: Grid) -> &'static str {
match grid {
Grid::All => "grid-all",
Grid::Rows => "grid-rows",
Grid::Cols => "grid-cols",
Grid::None => "grid-none",
}
}
fn stripes_class(stripes: Stripes) -> Option<&'static str> {
match stripes {
Stripes::None => None,
Stripes::Even => Some("stripes-even"),
Stripes::Odd => Some("stripes-odd"),
Stripes::All => Some("stripes-all"),
Stripes::Hover => Some("stripes-hover"),
}
}
fn halign_class(align: HorizontalAlignment) -> &'static str {
match align {
HorizontalAlignment::Left => "halign-left",
HorizontalAlignment::Center => "halign-center",
HorizontalAlignment::Right => "halign-right",
}
}
fn valign_class(align: VerticalAlignment) -> &'static str {
match align {
VerticalAlignment::Top => "valign-top",
VerticalAlignment::Middle => "valign-middle",
VerticalAlignment::Bottom => "valign-bottom",
}
}
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() {
if is_toc_macro(child) {
if let Some(toc) = toc_macro_node(child) {
node.children.push(toc);
}
continue;
}
node.children.push(child.to_virtual_dom());
}
if let Some(data) = PREAMBLE_TOC.with(|p| p.borrow_mut().take()) {
node.children.push(toc_block("toc", false, &data));
}
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 titled_table_renders_captioned_title() {
let doc = Parser::default().parse(".A table with a title\n|===\n|a |b\n|===");
let vdom = doc.to_virtual_dom();
let table = &vdom.children[0];
assert_eq!(table.tag, "table");
let caption = &table.children[0];
assert_eq!(caption.tag, "caption");
assert!(caption.classes.contains(&"title".to_string()));
assert_eq!(
caption.text.as_deref(),
Some("Table 1. A table with a title")
);
}
#[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")
);
}
mod toc {
use crate::{document::TocMode, tests::prelude::*};
#[test]
fn no_toc_when_attribute_absent() {
let doc = Parser::default().parse("= Title\n\n== Section\n\ncontent");
assert_eq!(doc.toc_mode(), TocMode::Disabled);
assert_css(&doc, ".toc", 0);
}
#[test]
fn auto_toc_renders_at_top_of_body() {
let doc =
Parser::default().parse("= Title\n:toc:\n\n== First\n\nhi\n\n== Second\n\nbye");
assert_eq!(doc.toc_mode(), TocMode::Auto);
assert_css(&doc, "#toc", 1);
assert_css(&doc, ".toc", 1);
let vdom = doc.to_virtual_dom();
let toc = &vdom.children[0];
assert_eq!(toc.tag, "div");
assert_eq!(toc.id.as_deref(), Some("toc"));
assert!(toc.classes.contains(&"toc".to_string()));
let title = &toc.children[0];
assert_eq!(title.id.as_deref(), Some("toctitle"));
assert_eq!(title.text.as_deref(), Some("Table of Contents"));
assert!(title.classes.is_empty());
assert_css(&doc, "ul.sectlevel1 > li > a", 2);
let links = &toc.children[1];
assert_eq!(links.tag, "ul");
assert!(links.classes.contains(&"sectlevel1".to_string()));
assert_eq!(
links.children[0].children[0].attributes.get("href"),
Some(&"#_first".to_string())
);
assert_eq!(links.children[0].children[0].text.as_deref(), Some("First"));
}
#[test]
fn empty_toc_resolves_to_auto() {
for value in ["", " auto"] {
let doc =
Parser::default().parse(&format!("= Title\n:toc:{value}\n\n== Section\n\nhi"));
assert_eq!(doc.toc_mode(), TocMode::Auto, "value: {value:?}");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children[0].id.as_deref(), Some("toc"));
}
}
#[test]
fn left_and_right_render_like_auto_at_top() {
for (value, mode) in [("left", TocMode::Left), ("right", TocMode::Right)] {
let doc =
Parser::default().parse(&format!("= Title\n:toc: {value}\n\n== Section\n\nhi"));
assert_eq!(doc.toc_mode(), mode, "value: {value}");
assert_css(&doc, "#toc.toc", 1);
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children[0].id.as_deref(), Some("toc"));
}
}
#[test]
fn preamble_toc_renders_below_preamble() {
let doc = Parser::default()
.parse("= Title\n:toc: preamble\n\nintro para\n\n== Section\n\nhi");
assert_eq!(doc.toc_mode(), TocMode::Preamble);
assert_css(&doc, ".toc", 1);
assert_css(&doc, "#preamble > #toc.toc", 1);
let vdom = doc.to_virtual_dom();
assert_ne!(vdom.children[0].id.as_deref(), Some("toc"));
let preamble = vdom
.children
.iter()
.find(|c| c.id.as_deref() == Some("preamble"))
.expect("preamble present");
assert_eq!(
preamble.children.last().and_then(|c| c.id.as_deref()),
Some("toc")
);
assert_css(&doc, "#toctitle.title", 0);
}
#[test]
fn preamble_toc_absent_without_preamble() {
let doc = Parser::default().parse("= Title\n:toc: preamble\n\n== Section\n\nhi");
assert_eq!(doc.toc_mode(), TocMode::Preamble);
assert_css(&doc, ".toc", 0);
}
#[test]
fn auto_toc_nesting_honors_default_toclevels() {
let doc = Parser::default()
.parse("= Title\n:toc:\n\n== Level 1\n\n=== Level 2\n\n==== Level 3\n\ncontent");
assert_eq!(doc.toc_levels(), 2);
assert_css(&doc, "ul.sectlevel1", 1);
assert_css(&doc, "ul.sectlevel2", 1);
assert_css(&doc, "ul.sectlevel3", 0);
}
#[test]
fn toclevels_controls_toc_depth() {
let doc = Parser::default().parse(
"= Title\n:toc:\n:toclevels: 3\n\n== L1\n\n=== L2\n\n==== L3\n\n===== L4\n\nx",
);
assert_eq!(doc.toc_levels(), 3);
assert_css(&doc, "ul.sectlevel1", 1);
assert_css(&doc, "ul.sectlevel2", 1);
assert_css(&doc, "ul.sectlevel3", 1);
assert_css(&doc, "ul.sectlevel4", 0);
}
#[test]
fn toclevels_zero_is_coerced_to_one() {
let doc =
Parser::default().parse("= Title\n:toc:\n:toclevels: 0\n\n== L1\n\n=== L2\n\nx");
assert_eq!(doc.toc_levels(), 1);
assert_css(&doc, "ul.sectlevel1", 1);
assert_css(&doc, "ul.sectlevel2", 0);
}
#[test]
fn invalid_toclevels_falls_back_to_default() {
let doc = Parser::default()
.parse("= Title\n:toc:\n:toclevels: huge\n\n== L1\n\n=== L2\n\n==== L3\n\nx");
assert_eq!(doc.toc_levels(), 2);
assert_css(&doc, "ul.sectlevel2", 1);
assert_css(&doc, "ul.sectlevel3", 0);
}
#[test]
fn custom_toc_title() {
let doc = Parser::default()
.parse("= Title\n:toc:\n:toc-title: Table of Adventures\n\n== Section\n\nhi");
assert_eq!(doc.toc_title(), "Table of Adventures");
let vdom = doc.to_virtual_dom();
let title = &vdom.children[0].children[0];
assert_eq!(title.id.as_deref(), Some("toctitle"));
assert_eq!(title.text.as_deref(), Some("Table of Adventures"));
}
#[test]
fn empty_toc_title_renders_empty() {
let doc = Parser::default().parse("= Title\n:toc:\n:toc-title:\n\n== Section\n\nhi");
assert_eq!(doc.toc_title(), "");
let vdom = doc.to_virtual_dom();
assert_eq!(vdom.children[0].children[0].text.as_deref(), Some(""));
}
#[test]
fn custom_toc_class() {
let doc = Parser::default()
.parse("= Title\n:toc:\n:toc-class: floating-toc\n\n== Section\n\nhi");
assert_eq!(doc.toc_class(), "floating-toc");
assert_css(&doc, "#toc.floating-toc", 1);
assert_css(&doc, "#toc.toc", 0);
}
#[test]
fn toc_defaults_when_attributes_unset() {
let doc = Parser::default().parse("= Title\n:toc:\n\n== Section\n\nhi");
assert_eq!(doc.toc_levels(), 2);
assert_eq!(doc.toc_title(), "Table of Contents");
assert_eq!(doc.toc_class(), "toc");
}
#[test]
fn macro_toc_renders_at_macro_not_at_top() {
let doc = Parser::default()
.parse("= Title\n:toc: macro\n\npreamble\n\ntoc::[]\n\n== Section\n\nhi");
assert_eq!(doc.toc_mode(), TocMode::Macro);
assert_css(&doc, ".toc", 1);
let vdom = doc.to_virtual_dom();
assert_ne!(vdom.children[0].id.as_deref(), Some("toc"));
assert_css(&doc, "#preamble .toc", 1);
assert_css(&doc, "#toctitle.title", 1);
}
#[test]
fn macro_toc_uses_explicit_id() {
let doc = Parser::default()
.parse("= Title\n:toc: macro\n\n[#my-toc]\ntoc::[]\n\n== Section\n\nhi");
assert_css(&doc, "#my-toc.toc", 1);
assert_css(&doc, "#my-toctitle.title", 1);
}
#[test]
fn macro_placement_without_macro_renders_no_toc() {
let doc = Parser::default().parse("= Title\n:toc: macro\n\n== Section\n\nhi");
assert_eq!(doc.toc_mode(), TocMode::Macro);
assert_css(&doc, ".toc", 0);
}
#[test]
fn toc_macro_renders_nothing_when_toc_not_enabled() {
let doc = Parser::default().parse("= Title\n\ntoc::[]\n\n== Section\n\nhi");
assert_eq!(doc.toc_mode(), TocMode::Disabled);
assert_css(&doc, ".toc", 0);
let dom_text = to_dom_text(&doc);
assert!(
!dom_text.contains("toc::"),
"unexpected literal macro text in: {dom_text}"
);
}
fn to_dom_text(doc: &crate::Document) -> String {
fn collect(node: &super::super::VirtualNode, out: &mut String) {
if let Some(text) = &node.text {
out.push_str(text);
}
for child in &node.children {
collect(child, out);
}
}
let mut out = String::new();
collect(&doc.to_virtual_dom(), &mut out);
out
}
}
}