use super::document::{BlockMeta, Document};
use super::hooks::{escape_attr, escape_text, RenderHooks};
use super::node::{Block, Fold, Inline};
use super::url::Url;
pub fn render_document<H: RenderHooks>(doc: &Document, hooks: &H) -> String {
let mut out = String::new();
debug_assert_eq!(
doc.blocks.len(),
doc.block_meta.len(),
"Document invariant: blocks.len() == block_meta.len()"
);
for (i, block) in doc.blocks.iter().enumerate() {
let meta = doc.block_meta.get(i).copied().unwrap_or_default();
render_block(hooks, &mut out, block, &meta);
}
out
}
pub fn render_blocks<H: RenderHooks + ?Sized>(hooks: &H, out: &mut String, blocks: &[Block]) {
for block in blocks {
render_block(hooks, out, block, &BlockMeta::default());
}
}
fn render_block<H: RenderHooks + ?Sized>(
hooks: &H,
out: &mut String,
block: &Block,
meta: &BlockMeta,
) {
match block {
Block::Heading {
level,
children,
id,
} => {
let mut content = String::new();
render_inlines(hooks, &mut content, children);
hooks.render_heading(out, *level, id.as_deref(), meta.source_line, &content);
out.push('\n');
}
Block::Paragraph(children) => {
out.push_str("<p");
push_source_line_attr(out, meta.source_line);
out.push('>');
render_inlines(hooks, out, children);
out.push_str("</p>\n");
}
Block::Callout {
kind,
fold,
title,
children,
} => {
out.push_str(r#"<div class="callout" data-type=""#);
out.push_str(kind.as_slug());
out.push_str(r#"""#);
push_source_line_attr(out, meta.source_line);
if let Some(fold_state) = fold {
let fold_attr = match fold_state {
Fold::Open => "open",
Fold::Closed => "closed",
};
out.push_str(r#" data-fold=""#);
out.push_str(fold_attr);
out.push_str(r#"""#);
}
out.push_str(">\n");
let display_title = title
.as_deref()
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.map(|t| escape_text(t))
.unwrap_or_else(|| kind.default_title().to_string());
out.push_str(r#" <div class="callout-title">"#);
out.push_str(&display_title);
out.push_str("</div>\n");
out.push_str(r#" <div class="callout-content">"#);
out.push('\n');
render_blocks(hooks, out, children);
out.push_str("</div>\n");
out.push_str("</div>\n");
}
Block::List {
ordered,
start,
items,
item_source_lines,
} => {
debug_assert!(
item_source_lines.is_empty() || item_source_lines.len() == items.len(),
"Block::List invariant: item_source_lines.len() ({}) must equal items.len() ({}) when populated",
item_source_lines.len(),
items.len()
);
if *ordered {
out.push_str("<ol");
if let Some(n) = start {
out.push_str(" start=\"");
out.push_str(&n.to_string());
out.push('"');
}
push_source_line_attr(out, meta.source_line);
out.push_str(">\n");
} else {
out.push_str("<ul");
push_source_line_attr(out, meta.source_line);
out.push_str(">\n");
}
for (idx, item_blocks) in items.iter().enumerate() {
out.push_str("<li");
let item_line = item_source_lines.get(idx).copied().flatten();
push_source_line_attr(out, item_line);
out.push('>');
if let [Block::Paragraph(inlines)] = item_blocks.as_slice() {
render_inlines(hooks, out, inlines);
} else {
out.push('\n');
render_blocks(hooks, out, item_blocks);
}
out.push_str("</li>\n");
}
if *ordered {
out.push_str("</ol>\n");
} else {
out.push_str("</ul>\n");
}
}
Block::CodeBlock { lang, value } => {
out.push_str("<pre");
push_source_line_attr(out, meta.source_line);
out.push('>');
match lang {
Some(l) => {
out.push_str(r#"<code class="language-"#);
out.push_str(&escape_attr(l));
out.push_str(r#"">"#);
}
None => out.push_str("<code>"),
}
out.push_str(&escape_text(value));
out.push_str("</code></pre>\n");
}
Block::Table {
header,
rows,
header_source_line,
row_source_lines,
} => {
debug_assert!(
row_source_lines.is_empty() || row_source_lines.len() == rows.len(),
"Block::Table invariant: row_source_lines.len() ({}) must equal rows.len() ({}) when populated",
row_source_lines.len(),
rows.len()
);
out.push_str("<table");
push_source_line_attr(out, meta.source_line);
out.push_str(">\n<thead>\n<tr");
push_source_line_attr(out, *header_source_line);
out.push('>');
for cell in header {
out.push_str("<th>");
render_inlines(hooks, out, cell);
out.push_str("</th>");
}
out.push_str("</tr>\n</thead>\n");
if !rows.is_empty() {
out.push_str("<tbody>\n");
for (idx, row) in rows.iter().enumerate() {
out.push_str("<tr");
let row_line = row_source_lines.get(idx).copied().flatten();
push_source_line_attr(out, row_line);
out.push('>');
for cell in row {
out.push_str("<td>");
render_inlines(hooks, out, cell);
out.push_str("</td>");
}
out.push_str("</tr>\n");
}
out.push_str("</tbody>\n");
}
out.push_str("</table>\n");
}
Block::BlockQuote(children) => {
out.push_str("<blockquote");
push_source_line_attr(out, meta.source_line);
out.push_str(">\n");
render_blocks(hooks, out, children);
out.push_str("</blockquote>\n");
}
Block::Shortcode(sc) => {
hooks.render_shortcode(out, sc, meta.source_line);
out.push('\n');
}
Block::ThematicBreak => {
out.push_str("<hr");
push_source_line_attr(out, meta.source_line);
out.push_str(" />\n");
}
Block::Figure {
image,
caption,
width,
align,
class_names,
img_style,
} => {
let mut class_value = String::from("moss-image");
if let Some(a) = align {
class_value.push(' ');
class_value.push_str(a);
}
for cn in class_names {
if cn.is_empty() {
continue;
}
class_value.push(' ');
class_value.push_str(cn);
}
out.push_str(r#"<figure class=""#);
out.push_str(&escape_attr(&class_value));
out.push('"');
if let Some(w) = width {
if w.ends_with('%') {
out.push_str(r#" style="width:"#);
out.push_str(&escape_attr(w));
out.push('"');
} else {
out.push_str(r#" data-width=""#);
out.push_str(&escape_attr(w));
out.push('"');
}
}
push_source_line_attr(out, meta.source_line);
out.push('>');
match image {
Inline::Image {
src, alt, title, ..
} => match src {
Url::Resolved(r) => {
hooks.render_image_styled(out, r, alt, title.as_deref(), img_style.as_deref());
}
Url::Unresolved(s) => {
debug_assert!(
false,
"Url::Unresolved({s:?}) reached Block::Figure renderer — visit_urls_mut missing or buggy"
);
out.push_str(r#"<img src=""#);
out.push_str(&escape_attr(s));
out.push_str(r#"" alt=""#);
out.push_str(&escape_attr(alt));
out.push_str(r#"" />"#);
}
},
_ => {
render_inline(hooks, out, image);
}
}
if let Some(cap_inlines) = caption {
if !cap_inlines.is_empty() {
out.push_str("<figcaption>");
render_inlines(hooks, out, cap_inlines);
out.push_str("</figcaption>");
}
}
out.push_str("</figure>\n");
}
Block::LinkCard { url, children } => {
let resolved = match url {
Url::Resolved(r) => r,
Url::Unresolved(s) => {
debug_assert!(
false,
"Url::Unresolved({s:?}) reached Block::LinkCard renderer — visit_urls_mut missing or buggy"
);
out.push_str(r#"<a href=""#);
out.push_str(&escape_attr(s));
out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
render_blocks(hooks, out, children);
out.push_str("</a>");
return;
}
};
use super::url::UrlKind;
let is_external = matches!(resolved.kind, UrlKind::External | UrlKind::AssetNewtab);
if is_external {
out.push_str(r#"<a href=""#);
out.push_str(&escape_attr(&resolved.href));
out.push_str(
r#"" class="moss-grid-card link-preview" target="_blank" rel="noopener">"#,
);
} else {
out.push_str(r#"<a href=""#);
out.push_str(&escape_attr(&resolved.href));
out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
}
render_blocks(hooks, out, children);
out.push_str("</a>");
}
Block::Other(html) => {
out.push_str(html);
}
}
}
fn push_source_line_attr(out: &mut String, source_line: Option<usize>) {
if let Some(n) = source_line {
use std::fmt::Write as _;
let _ = write!(out, r#" data-source-line="{}""#, n);
}
}
pub(super) fn render_inlines<H: RenderHooks + ?Sized>(
hooks: &H,
out: &mut String,
inlines: &[Inline],
) {
for inline in inlines {
render_inline(hooks, out, inline);
}
}
fn render_inline<H: RenderHooks + ?Sized>(hooks: &H, out: &mut String, inline: &Inline) {
match inline {
Inline::Text(t) => out.push_str(&escape_text(t)),
Inline::Link {
url,
title: _title,
children,
is_wikilink,
} => {
let resolved = match url {
Url::Resolved(r) => r,
Url::Unresolved(s) => {
debug_assert!(
false,
"Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
);
out.push_str(r#"<a href=""#);
out.push_str(&escape_attr(s));
out.push_str(r#"">"#);
render_inlines(hooks, out, children);
out.push_str("</a>");
return;
}
};
let mut content = String::new();
render_inlines(hooks, &mut content, children);
hooks.render_link(out, resolved, *is_wikilink, &content);
}
Inline::Image {
src, alt, title, ..
} => {
let resolved = match src {
Url::Resolved(r) => r,
Url::Unresolved(s) => {
debug_assert!(
false,
"Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
);
out.push_str(r#"<img src=""#);
out.push_str(&escape_attr(s));
out.push_str(r#"" alt=""#);
out.push_str(&escape_attr(alt));
out.push_str(r#"" />"#);
return;
}
};
hooks.render_image(out, resolved, alt, title.as_deref());
}
Inline::Emphasis(children) => {
out.push_str("<em>");
render_inlines(hooks, out, children);
out.push_str("</em>");
}
Inline::Strong(children) => {
out.push_str("<strong>");
render_inlines(hooks, out, children);
out.push_str("</strong>");
}
Inline::Code(c) => {
out.push_str("<code>");
out.push_str(&escape_text(c));
out.push_str("</code>");
}
Inline::LineBreak => out.push_str("<br />\n"),
Inline::Other(html) => {
match super::math_text::math_node_parts(html) {
Some((tex, display)) => hooks.render_math(out, &tex, display, html),
None => out.push_str(html),
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::hooks::DefaultHooks;
use super::super::node::Inline;
use super::super::url::{Url, UrlKind};
use super::*;
fn render(blocks: Vec<Block>) -> String {
let doc = Document::from_blocks(blocks);
render_document(&doc, &DefaultHooks::new())
}
#[test]
fn renders_empty_document_to_empty_string() {
assert_eq!(render(vec![]), "");
}
#[test]
fn renders_paragraph() {
let html = render(vec![Block::Paragraph(vec![Inline::Text("hi".into())])]);
assert_eq!(html, "<p>hi</p>\n");
}
#[test]
fn renders_heading_with_id() {
let html = render(vec![Block::Heading {
level: 2,
children: vec![Inline::Text("Setup".into())],
id: Some("setup".into()),
}]);
assert_eq!(html, "<h2 id=\"setup\">Setup<a class=\"moss-heading-anchor\" href=\"#setup\" aria-label=\"Permalink to this section\"><span aria-hidden=\"true\">#</span></a></h2>\n");
}
#[test]
fn renders_resolved_link_internal() {
let html = render(vec![Block::Paragraph(vec![Inline::Link {
url: Url::resolved("docs/", UrlKind::Internal),
title: None,
children: vec![Inline::Text("Docs".into())],
is_wikilink: false,
}])]);
assert_eq!(html, "<p><a href=\"docs/\">Docs</a></p>\n");
}
#[test]
fn renders_resolved_link_wikilink_carries_class() {
let html = render(vec![Block::Paragraph(vec![Inline::Link {
url: Url::resolved("../docs/", UrlKind::Wikilink),
title: None,
children: vec![Inline::Text("Docs".into())],
is_wikilink: false,
}])]);
assert!(html.contains(r#"class="wikilink""#), "got: {html}");
}
#[test]
fn renders_link_with_is_wikilink_flag_emits_class() {
let html = render(vec![Block::Paragraph(vec![Inline::Link {
url: Url::resolved("../docs/", UrlKind::Internal),
title: None,
children: vec![Inline::Text("Docs".into())],
is_wikilink: true,
}])]);
assert!(
html.contains(r#"class="wikilink""#),
"is_wikilink: true should produce class=\"wikilink\"; got: {html}"
);
}
#[test]
fn renders_resolved_image() {
let html = render(vec![Block::Paragraph(vec![Inline::Image {
src: Url::resolved("cat.jpg", UrlKind::Asset),
alt: "Cat".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
}])]);
assert_eq!(html, "<p><img src=\"cat.jpg\" alt=\"Cat\" /></p>\n");
}
#[test]
fn renders_emphasis_and_strong() {
let html = render(vec![Block::Paragraph(vec![
Inline::Emphasis(vec![Inline::Text("em".into())]),
Inline::Text(" ".into()),
Inline::Strong(vec![Inline::Text("strong".into())]),
])]);
assert_eq!(html, "<p><em>em</em> <strong>strong</strong></p>\n");
}
#[test]
fn renders_inline_code_with_escaping() {
let html = render(vec![Block::Paragraph(vec![Inline::Code("a<b>c".into())])]);
assert_eq!(html, "<p><code>a<b>c</code></p>\n");
}
#[test]
fn renders_unordered_list_tight() {
let html = render(vec![Block::List {
ordered: false,
start: None,
items: vec![
vec![Block::Paragraph(vec![Inline::Text("one".into())])],
vec![Block::Paragraph(vec![Inline::Text("two".into())])],
],
item_source_lines: vec![],
}]);
assert_eq!(html, "<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n");
}
#[test]
fn renders_ordered_list() {
let html = render(vec![Block::List {
ordered: true,
start: None,
items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
item_source_lines: vec![],
}]);
assert!(html.starts_with("<ol>"));
}
#[test]
fn render_ordered_list_emits_start_attribute_when_non_default() {
let html = render(vec![Block::List {
ordered: true,
start: Some(3),
items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
item_source_lines: vec![],
}]);
assert!(
html.starts_with(r#"<ol start="3">"#),
"expected start attr immediately after <ol, got: {html}"
);
}
#[test]
fn render_ordered_list_omits_start_when_default_1() {
let html = render(vec![Block::List {
ordered: true,
start: None,
items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
item_source_lines: vec![],
}]);
assert!(html.starts_with("<ol>"), "expected bare <ol>, got: {html}");
assert!(
!html.contains("start="),
"ordered list with default start should not emit start attr, got: {html}"
);
}
#[test]
fn render_unordered_list_emits_no_start() {
let html = render(vec![Block::List {
ordered: false,
start: Some(5),
items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
item_source_lines: vec![],
}]);
assert!(html.starts_with("<ul>"), "expected bare <ul>, got: {html}");
assert!(
!html.contains("start="),
"unordered list must never carry start attr, got: {html}"
);
}
#[test]
fn renders_code_block_with_lang() {
let html = render(vec![Block::CodeBlock {
lang: Some("rust".into()),
value: "fn main() {}".into(),
}]);
assert_eq!(
html,
"<pre><code class=\"language-rust\">fn main() {}</code></pre>\n"
);
}
#[test]
fn renders_code_block_without_lang() {
let html = render(vec![Block::CodeBlock {
lang: None,
value: "bare".into(),
}]);
assert_eq!(html, "<pre><code>bare</code></pre>\n");
}
#[test]
fn renders_thematic_break() {
let html = render(vec![Block::ThematicBreak]);
assert_eq!(html, "<hr />\n");
}
use super::super::node::{CalloutKind, Fold};
#[test]
fn renders_basic_callout_with_title() {
let html = render(vec![Block::Callout {
kind: CalloutKind::Note,
fold: None,
title: Some("Heads up".into()),
children: vec![Block::Paragraph(vec![Inline::Text("Body.".into())])],
}]);
assert!(
html.contains(r#"<div class="callout" data-type="note">"#),
"expected callout div with data-type, got: {html}"
);
assert!(
html.contains(r#"<div class="callout-title">Heads up</div>"#),
"expected inline title slot, got: {html}"
);
assert!(
html.contains(r#"<div class="callout-content">"#),
"expected content slot, got: {html}"
);
assert!(html.contains("<p>Body.</p>"), "body must render: {html}");
}
#[test]
fn renders_callout_falls_back_to_default_title() {
let html = render(vec![Block::Callout {
kind: CalloutKind::Warning,
fold: None,
title: None,
children: vec![],
}]);
assert!(
html.contains(r#"<div class="callout-title">Warning</div>"#),
"expected capitalized fallback title, got: {html}"
);
}
#[test]
fn renders_foldable_callout_with_data_fold_attribute() {
let html_open = render(vec![Block::Callout {
kind: CalloutKind::Tip,
fold: Some(Fold::Open),
title: Some("Open".into()),
children: vec![],
}]);
assert!(
html_open.contains(r#"data-type="tip""#) && html_open.contains(r#"data-fold="open""#),
"expected data-fold='open' attribute, got: {html_open}"
);
let html_closed = render(vec![Block::Callout {
kind: CalloutKind::Tip,
fold: Some(Fold::Closed),
title: None,
children: vec![],
}]);
assert!(
html_closed.contains(r#"data-fold="closed""#),
"expected data-fold='closed' attribute, got: {html_closed}"
);
}
#[test]
fn callout_alias_renders_canonical_data_type_slug() {
let html = render(vec![Block::Callout {
kind: CalloutKind::Abstract,
fold: None,
title: Some("TL;DR".into()),
children: vec![],
}]);
assert!(
html.contains(r#"data-type="abstract""#),
"expected canonical slug 'abstract', got: {html}"
);
}
#[test]
fn callout_title_is_html_escaped() {
let html = render(vec![Block::Callout {
kind: CalloutKind::Warning,
fold: None,
title: Some(r#"Use <script> & "quotes""#.into()),
children: vec![],
}]);
assert!(
html.contains("Use <script> &"),
"title must escape lt/gt/amp, got: {html}"
);
assert!(
!html.contains("<div class=\"callout-title\">Use <script>"),
"unescaped angle brackets leaked, got: {html}"
);
}
#[test]
fn renders_blockquote_with_paragraph() {
let html = render(vec![Block::BlockQuote(vec![Block::Paragraph(vec![
Inline::Text("q".into()),
])])]);
assert_eq!(html, "<blockquote>\n<p>q</p>\n</blockquote>\n");
}
#[test]
fn renders_table() {
let html = render(vec![Block::Table {
header: vec![vec![Inline::Text("A".into())]],
rows: vec![vec![vec![Inline::Text("1".into())]]],
header_source_line: None,
row_source_lines: vec![],
}]);
assert!(html.contains("<thead>"));
assert!(html.contains("<tbody>"));
assert!(html.contains("<th>A</th>"));
assert!(html.contains("<td>1</td>"));
}
#[test]
fn renders_other_block_passes_html_through() {
let html = render(vec![Block::Other("<custom></custom>".into())]);
assert_eq!(html, "<custom></custom>");
}
#[test]
fn text_escapes_lt_gt_amp() {
let html = render(vec![Block::Paragraph(vec![Inline::Text("a<b>c&d".into())])]);
assert_eq!(html, "<p>a<b>c&d</p>\n");
}
#[test]
fn round_trips_parse_to_render_for_canonical_doc() {
let md = "# Title\n\npara with [link](docs/) and *em*.\n";
let mut doc = super::super::parser::parse(md);
super::super::visit::visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
_ => {}
});
let html = render_document(&doc, &DefaultHooks::new());
assert!(html.contains(r##"<h1 id="title">Title<a class="moss-heading-anchor" href="#title" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h1>"##), "got: {html}");
assert!(html.contains(r#"<a href="docs/">link</a>"#));
assert!(html.contains("<em>em</em>"));
}
#[test]
fn figure_renders_with_caption() {
let html = render(vec![Block::Figure {
image: Inline::Image {
src: Url::resolved("logo.png", UrlKind::Asset),
alt: "A logo".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
},
caption: Some(vec![Inline::Text("A logo".into())]),
width: None,
align: None,
class_names: Vec::new(),
img_style: None,
}]);
assert!(
html.starts_with(r#"<figure class="moss-image">"#),
"expected figure wrap, got: {html}"
);
assert!(html.contains(r#"src="logo.png""#), "got: {html}");
assert!(html.contains(r#"alt="A logo""#), "got: {html}");
assert!(
html.contains("<figcaption>A logo</figcaption>"),
"got: {html}"
);
assert!(html.ends_with("</figure>\n"), "got: {html}");
}
#[test]
fn figure_renders_without_caption_when_none() {
let html = render(vec![Block::Figure {
image: Inline::Image {
src: Url::resolved("x.png", UrlKind::Asset),
alt: String::new(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
},
caption: None,
width: None,
align: None,
class_names: Vec::new(),
img_style: None,
}]);
assert!(html.contains("<figure"), "got: {html}");
assert!(
!html.contains("<figcaption"),
"expected no figcaption, got: {html}"
);
assert!(html.contains("</figure>"), "got: {html}");
}
#[test]
fn figure_renders_no_figcaption_for_empty_caption_vec() {
let html = render(vec![Block::Figure {
image: Inline::Image {
src: Url::resolved("x.png", UrlKind::Asset),
alt: "x".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
},
caption: Some(vec![]),
width: None,
align: None,
class_names: Vec::new(),
img_style: None,
}]);
assert!(!html.contains("<figcaption"), "got: {html}");
}
#[test]
fn figure_percent_width_emits_inline_style() {
let html = render(vec![Block::Figure {
image: Inline::Image {
src: Url::resolved("pic.jpg", UrlKind::Asset),
alt: "alt".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
},
caption: None,
width: Some("55%".to_string()),
align: None,
class_names: vec![],
img_style: None,
}]);
assert!(
html.contains(r#"<figure class="moss-image" style="width:55%""#),
"got: {html}"
);
assert!(
!html.contains("data-width="),
"percent must not emit data-width: {html}"
);
}
#[test]
fn figure_named_width_still_emits_data_width() {
let html = render(vec![Block::Figure {
image: Inline::Image {
src: Url::resolved("pic.jpg", UrlKind::Asset),
alt: "alt".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
},
caption: None,
width: Some("wide".to_string()),
align: None,
class_names: vec![],
img_style: None,
}]);
assert!(html.contains(r#"data-width="wide""#), "got: {html}");
assert!(
!html.contains("style=\"width"),
"named token must not emit style: {html}"
);
}
#[test]
fn figure_caption_escapes_html_unsafe_chars() {
let html = render(vec![Block::Figure {
image: Inline::Image {
src: Url::resolved("p.jpg", UrlKind::Asset),
alt: "a<b>c".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
},
caption: Some(vec![Inline::Text("a<b>c".into())]),
width: None,
align: None,
class_names: Vec::new(),
img_style: None,
}]);
assert!(
html.contains("<figcaption>a<b>c</figcaption>"),
"got: {html}"
);
}
#[test]
fn figure_end_to_end_from_parser_to_render() {
let md = "\n";
let mut doc = super::super::parser::parse(md);
super::super::visit::visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
_ => {}
});
let html = render_document(&doc, &DefaultHooks::new());
assert!(
html.contains(r#"<figure class="moss-image">"#),
"expected figure, got: {html}"
);
assert!(html.contains(r#"src="photo.jpg""#), "got: {html}");
assert!(
html.contains("<figcaption>A photo</figcaption>"),
"got: {html}"
);
}
#[test]
fn paragraph_with_image_and_text_does_not_become_figure() {
let md = " plain text\n";
let mut doc = super::super::parser::parse(md);
super::super::visit::visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
_ => {}
});
let html = render_document(&doc, &DefaultHooks::new());
assert!(
!html.contains("<figure"),
"image+text must not be wrapped in figure, got: {html}"
);
assert!(html.contains("plain text"), "got: {html}");
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "visit_urls_mut missing")]
fn unresolved_url_in_link_panics_in_debug() {
let _ = render(vec![Block::Paragraph(vec![Inline::Link {
url: Url::unresolved("docs/"),
title: None,
children: vec![],
is_wikilink: false,
}])]);
}
fn render_with_meta(blocks: Vec<Block>, meta: Vec<BlockMeta>) -> String {
let doc = Document::from_blocks_with_meta(blocks, meta);
render_document(&doc, &DefaultHooks::new())
}
#[test]
fn paragraph_emits_data_source_line_when_meta_set() {
let html = render_with_meta(
vec![Block::Paragraph(vec![Inline::Text("hi".into())])],
vec![BlockMeta {
source_line: Some(7),
}],
);
assert_eq!(html, "<p data-source-line=\"7\">hi</p>\n");
}
#[test]
fn heading_emits_data_source_line_through_hook() {
let html = render_with_meta(
vec![Block::Heading {
level: 2,
children: vec![Inline::Text("Setup".into())],
id: Some("setup".into()),
}],
vec![BlockMeta {
source_line: Some(3),
}],
);
assert!(
html.contains(r##"<h2 id="setup" data-source-line="3">Setup<a class="moss-heading-anchor" href="#setup" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##),
"got: {html}"
);
}
#[test]
fn list_blockquote_codeblock_table_hr_emit_data_source_line() {
let blocks = vec![
Block::BlockQuote(vec![Block::Paragraph(vec![Inline::Text("q".into())])]),
Block::List {
ordered: false,
start: None,
items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
item_source_lines: vec![],
},
Block::List {
ordered: true,
start: None,
items: vec![vec![Block::Paragraph(vec![Inline::Text("b".into())])]],
item_source_lines: vec![],
},
Block::CodeBlock {
lang: Some("rust".into()),
value: "x".into(),
},
Block::Table {
header: vec![vec![Inline::Text("H".into())]],
rows: vec![vec![vec![Inline::Text("c".into())]]],
header_source_line: None,
row_source_lines: vec![],
},
Block::ThematicBreak,
];
let meta = vec![
BlockMeta {
source_line: Some(1),
},
BlockMeta {
source_line: Some(2),
},
BlockMeta {
source_line: Some(3),
},
BlockMeta {
source_line: Some(4),
},
BlockMeta {
source_line: Some(5),
},
BlockMeta {
source_line: Some(6),
},
];
let html = render_with_meta(blocks, meta);
assert!(
html.contains(r#"<blockquote data-source-line="1">"#),
"blockquote missing: {html}"
);
assert!(
html.contains(r#"<ul data-source-line="2">"#),
"ul missing: {html}"
);
assert!(
html.contains(r#"<ol data-source-line="3">"#),
"ol missing: {html}"
);
assert!(
html.contains(r#"<pre data-source-line="4">"#),
"pre missing: {html}"
);
assert!(
html.contains(r#"<table data-source-line="5">"#),
"table missing: {html}"
);
assert!(
html.contains(r#"<hr data-source-line="6" />"#),
"hr missing: {html}"
);
}
#[test]
fn list_emits_per_li_data_source_line_when_parser_tracks() {
let blocks = vec![Block::List {
ordered: false,
start: None,
items: vec![
vec![Block::Paragraph(vec![Inline::Text("one".into())])],
vec![Block::Paragraph(vec![Inline::Text("two".into())])],
vec![Block::Paragraph(vec![Inline::Text("three".into())])],
],
item_source_lines: vec![Some(10), Some(11), Some(12)],
}];
let meta = vec![BlockMeta {
source_line: Some(10),
}];
let html = render_with_meta(blocks, meta);
assert!(
html.contains(r#"<ul data-source-line="10">"#),
"ul opener missing: {html}"
);
assert!(
html.contains(r#"<li data-source-line="10">one</li>"#),
"li 10 missing: {html}"
);
assert!(
html.contains(r#"<li data-source-line="11">two</li>"#),
"li 11 missing: {html}"
);
assert!(
html.contains(r#"<li data-source-line="12">three</li>"#),
"li 12 missing: {html}"
);
}
#[test]
fn list_omits_li_data_source_line_when_parser_did_not_track() {
let blocks = vec![Block::List {
ordered: false,
start: None,
items: vec![
vec![Block::Paragraph(vec![Inline::Text("a".into())])],
vec![Block::Paragraph(vec![Inline::Text("b".into())])],
],
item_source_lines: vec![],
}];
let html = render_with_meta(blocks, vec![BlockMeta::default()]);
assert_eq!(html, "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n");
}
#[test]
fn table_emits_per_tr_data_source_line_when_parser_tracks() {
let blocks = vec![Block::Table {
header: vec![vec![Inline::Text("H".into())]],
rows: vec![
vec![vec![Inline::Text("1".into())]],
vec![vec![Inline::Text("2".into())]],
vec![vec![Inline::Text("3".into())]],
],
header_source_line: Some(5),
row_source_lines: vec![Some(7), Some(8), Some(9)],
}];
let meta = vec![BlockMeta {
source_line: Some(5),
}];
let html = render_with_meta(blocks, meta);
assert!(
html.contains(r#"<table data-source-line="5">"#),
"table opener missing: {html}"
);
assert!(html.contains(r#"<thead>"#), "thead missing: {html}");
assert!(
html.contains(r#"<tr data-source-line="5"><th>H</th>"#),
"head tr missing: {html}"
);
assert!(
html.contains(r#"<tr data-source-line="7"><td>1</td>"#),
"body tr 7 missing: {html}"
);
assert!(
html.contains(r#"<tr data-source-line="8"><td>2</td>"#),
"body tr 8 missing: {html}"
);
assert!(
html.contains(r#"<tr data-source-line="9"><td>3</td>"#),
"body tr 9 missing: {html}"
);
}
#[test]
fn table_omits_tr_data_source_line_when_parser_did_not_track() {
let blocks = vec![Block::Table {
header: vec![vec![Inline::Text("A".into())]],
rows: vec![vec![vec![Inline::Text("1".into())]]],
header_source_line: None,
row_source_lines: vec![],
}];
let html = render_with_meta(blocks, vec![BlockMeta::default()]);
assert!(
!html.contains("data-source-line"),
"no annotation expected: {html}"
);
assert!(html.contains("<thead>"));
assert!(html.contains("<tr><th>A</th></tr>"));
assert!(html.contains("<tr><td>1</td></tr>"));
}
#[test]
fn figure_emits_data_source_line_on_outer_tag() {
let blocks = vec![Block::Figure {
image: Inline::Image {
src: Url::resolved("p.jpg", UrlKind::Asset),
alt: "A".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
},
caption: Some(vec![Inline::Text("A".into())]),
width: None,
align: None,
class_names: Vec::new(),
img_style: None,
}];
let meta = vec![BlockMeta {
source_line: Some(9),
}];
let html = render_with_meta(blocks, meta);
assert!(
html.contains(r#"<figure class="moss-image" data-source-line="9">"#),
"got: {html}"
);
}
#[test]
fn no_data_source_line_when_meta_none() {
let html = render(vec![
Block::Paragraph(vec![Inline::Text("hi".into())]),
Block::ThematicBreak,
]);
assert!(
!html.contains("data-source-line"),
"default render must NOT emit data-source-line, got: {html}"
);
}
#[test]
fn end_to_end_parse_with_config_emits_data_source_line() {
let md = "# Title\n\nfirst paragraph\n\n## Sub\n\nsecond paragraph\n";
let config = super::super::parser::ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
};
let mut doc = super::super::parser::parse_with_config(md, &config);
super::super::visit::visit_urls_mut(&mut doc, |u| match u {
Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
_ => {}
});
let html = render_document(&doc, &DefaultHooks::new());
assert!(
html.contains(r##"<h1 id="title" data-source-line="1">Title<a class="moss-heading-anchor" href="#title" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h1>"##),
"H1 should carry data-source-line=1: {html}"
);
assert!(
html.contains(r#"<p data-source-line="3">first paragraph</p>"#),
"first paragraph should carry data-source-line=3: {html}"
);
assert!(
html.contains(r##"<h2 id="sub" data-source-line="5">Sub<a class="moss-heading-anchor" href="#sub" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##),
"H2 should carry data-source-line=5: {html}"
);
assert!(
html.contains(r#"<p data-source-line="7">second paragraph</p>"#),
"second paragraph should carry data-source-line=7: {html}"
);
}
}