use super::document::{BlockMeta, Document};
use super::footnotes::{self, FootnoteCtx};
use super::hooks::{escape_attr, escape_text, RenderHooks};
use super::node::{Block, ColumnAlignment, Fold, Inline};
use super::url::Url;
#[derive(Clone, Copy, PartialEq, Eq)]
enum CellAlign {
Left,
Center,
Right,
}
impl CellAlign {
fn class_attr(self) -> &'static str {
match self {
CellAlign::Left => "",
CellAlign::Center => " class=\"moss-col-center\"",
CellAlign::Right => " class=\"moss-col-right\"",
}
}
}
fn cell_reads_as_number(s: &str) -> bool {
let chars: Vec<char> = s.trim().chars().collect();
if chars.is_empty() {
return false;
}
let mut i = 0;
if matches!(chars.get(i), Some('+' | '-')) {
i += 1;
}
if matches!(chars.get(i), Some('¥' | '$' | '€' | '£')) {
i += 1;
}
let mut saw_digit = false;
while let Some(&c) = chars.get(i) {
if c.is_ascii_digit() {
saw_digit = true;
i += 1;
} else if c == ',' || c == '.' {
i += 1;
} else {
break;
}
}
if !saw_digit {
return false;
}
if matches!(chars.get(i), Some('%' | '‰')) {
i += 1;
}
let tail: String = chars[i..].iter().collect();
let tail = tail.trim();
tail.is_empty() || (tail.chars().count() <= 3 && !tail.chars().any(|c| c.is_ascii_digit()))
}
fn column_reads_as_numeric(rows: &[Vec<Vec<Inline>>], col: usize) -> bool {
let mut non_empty = 0usize;
let mut numeric = 0usize;
for row in rows {
let Some(cell) = row.get(col) else { continue };
let text = crate::ast::plain_text::inlines_to_plain_text(cell);
if text.trim().is_empty() {
continue;
}
non_empty += 1;
if cell_reads_as_number(&text) {
numeric += 1;
}
}
non_empty > 0 && numeric * 5 >= non_empty * 4
}
fn resolve_column_alignment(
header: &[Vec<Inline>],
rows: &[Vec<Vec<Inline>>],
alignments: &[ColumnAlignment],
) -> Vec<CellAlign> {
(0..header.len())
.map(
|col| match alignments.get(col).copied().unwrap_or(ColumnAlignment::None) {
ColumnAlignment::Left => CellAlign::Left,
ColumnAlignment::Center => CellAlign::Center,
ColumnAlignment::Right => CellAlign::Right,
ColumnAlignment::None => {
if column_reads_as_numeric(rows, col) {
CellAlign::Right
} else {
CellAlign::Left
}
}
},
)
.collect()
}
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()"
);
let mut fnotes = FootnoteCtx::for_document(&doc.blocks);
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, &mut fnotes);
}
footnotes::render_section(hooks, &mut out, &doc.blocks, &mut fnotes);
out
}
pub fn render_block_with_meta<H: RenderHooks + ?Sized>(
hooks: &H,
out: &mut String,
block: &Block,
meta: &BlockMeta,
fnotes: &mut FootnoteCtx,
) {
render_block(hooks, out, block, meta, fnotes);
}
pub fn render_blocks<H: RenderHooks + ?Sized>(hooks: &H, out: &mut String, blocks: &[Block]) {
render_blocks_with(hooks, out, blocks, &mut FootnoteCtx::default());
}
pub(super) fn render_blocks_with<H: RenderHooks + ?Sized>(
hooks: &H,
out: &mut String,
blocks: &[Block],
fnotes: &mut FootnoteCtx,
) {
for block in blocks {
render_block(hooks, out, block, &BlockMeta::default(), fnotes);
}
}
fn render_block<H: RenderHooks + ?Sized>(
hooks: &H,
out: &mut String,
block: &Block,
meta: &BlockMeta,
fnotes: &mut FootnoteCtx,
) {
match block {
Block::Heading {
level,
children,
id,
} => {
let mut content = String::new();
render_inlines(hooks, &mut content, children, fnotes);
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, fnotes);
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");
let body = render_children_to_string(hooks, children, fnotes);
if !hoist_emptied(children, &body) {
out.push_str(r#" <div class="callout-content">"#);
out.push('\n');
out.push_str(&body);
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()
);
let mut body = String::new();
let mut kept = 0usize;
for (idx, item_blocks) in items.iter().enumerate() {
let mut item = String::new();
let tight = matches!(item_blocks.as_slice(), [Block::Paragraph(_)]);
if let [Block::Paragraph(inlines)] = item_blocks.as_slice() {
render_inlines(hooks, &mut item, inlines, fnotes);
} else {
render_blocks_with(hooks, &mut item, item_blocks, fnotes);
}
if hoist_emptied(item_blocks, &item) {
continue;
}
if !tight {
item.insert(0, '\n');
}
kept += 1;
body.push_str("<li");
let item_line = item_source_lines.get(idx).copied().flatten();
push_source_line_attr(&mut body, item_line);
body.push('>');
body.push_str(&item);
body.push_str("</li>\n");
}
if items.is_empty() || kept > 0 {
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");
}
out.push_str(&body);
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,
alignments,
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()
);
let col_align = resolve_column_alignment(header, rows, alignments);
let cell_class = |col: usize| -> &'static str {
col_align.get(col).copied().map_or("", CellAlign::class_attr)
};
out.push_str("<div class=\"moss-table-scroll\" tabindex=\"0\">\n");
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 (col, cell) in header.iter().enumerate() {
out.push_str("<th");
out.push_str(cell_class(col));
out.push('>');
render_inlines(hooks, out, cell, fnotes);
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 (col, cell) in row.iter().enumerate() {
out.push_str("<td");
out.push_str(cell_class(col));
out.push('>');
render_inlines(hooks, out, cell, fnotes);
out.push_str("</td>");
}
out.push_str("</tr>\n");
}
out.push_str("</tbody>\n");
}
out.push_str("</table>\n");
out.push_str("</div>\n");
}
Block::BlockQuote(children) => {
let body = render_children_to_string(hooks, children, fnotes);
if !hoist_emptied(children, &body) {
out.push_str("<blockquote");
push_source_line_attr(out, meta.source_line);
out.push_str(">\n");
out.push_str(&body);
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('>');
let caption_owns_text = caption.as_ref().is_some_and(|c| !c.is_empty());
match image {
Inline::Image {
src, alt, title, ..
} => match src {
Url::Resolved(r) => {
let alt = if caption_owns_text { "" } else { alt.as_str() };
hooks.render_image(
out,
r,
alt,
title.as_deref(),
img_style.as_deref(),
width.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, fnotes);
}
}
if let Some(cap_inlines) = caption {
if !cap_inlines.is_empty() {
out.push_str("<figcaption>");
render_inlines(hooks, out, cap_inlines, fnotes);
out.push_str("</figcaption>");
}
}
out.push_str("</figure>\n");
}
Block::LinkCard { url, children } => {
let body = render_children_to_string(hooks, children, fnotes);
if hoist_emptied(children, &body) {
return;
}
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">"#);
out.push_str(&body);
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">"#);
}
out.push_str(&body);
out.push_str("</a>");
}
Block::FootnoteDefinition { label, children } => {
if footnotes::is_hoisted(label, children, fnotes) {
return;
}
render_blocks_with(hooks, out, children, fnotes);
}
Block::Other(html) => {
out.push_str(html);
}
}
}
fn render_children_to_string<H: RenderHooks + ?Sized>(
hooks: &H,
children: &[Block],
fnotes: &mut FootnoteCtx,
) -> String {
let mut buf = String::new();
render_blocks_with(hooks, &mut buf, children, fnotes);
buf
}
fn hoist_emptied(children: &[Block], rendered: &str) -> bool {
!children.is_empty() && rendered.is_empty()
}
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],
fnotes: &mut FootnoteCtx,
) {
for inline in inlines {
render_inline(hooks, out, inline, fnotes);
}
}
fn render_inline<H: RenderHooks + ?Sized>(
hooks: &H,
out: &mut String,
inline: &Inline,
fnotes: &mut FootnoteCtx,
) {
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, fnotes);
out.push_str("</a>");
return;
}
};
let mut content = String::new();
render_inlines(hooks, &mut content, children, fnotes);
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(), None, None);
}
Inline::Emphasis(children) => {
out.push_str("<em>");
render_inlines(hooks, out, children, fnotes);
out.push_str("</em>");
}
Inline::Strong(children) => {
out.push_str("<strong>");
render_inlines(hooks, out, children, fnotes);
out.push_str("</strong>");
}
Inline::Code(c) => {
out.push_str("<code>");
out.push_str(&escape_text(c));
out.push_str("</code>");
}
Inline::Strikethrough(children) => {
out.push_str("<del>");
render_inlines(hooks, out, children, fnotes);
out.push_str("</del>");
}
Inline::FootnoteRef(label) => footnotes::render_marker(hooks, out, label, fnotes),
Inline::TaskMarker(checked) => {
out.push_str(if *checked {
"<input type=\"checkbox\" disabled checked /> "
} else {
"<input type=\"checkbox\" disabled /> "
});
}
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)]
#[path = "render_tests.rs"]
mod tests;