use super::ast::{Align, Block, Inline, List, Table};
pub(super) fn render_html(blocks: &[Block]) -> String {
blocks
.iter()
.map(render_block)
.collect::<Vec<_>>()
.join("\n\n")
.trim()
.to_string()
}
fn render_block(block: &Block) -> String {
match block {
Block::Heading { level, content } => {
let inner = render_inlines(content);
if *level >= 3 {
format!("<b><i>{inner}</i></b>")
} else {
format!("<b>{inner}</b>")
}
}
Block::Paragraph(content) => render_inlines(content),
Block::List(list) => render_list(list, 0),
Block::Table(table) => render_table(table),
Block::Code { lang, text } => match lang {
Some(l) => format!(
"<pre><code class=\"language-{}\">{}</code></pre>",
escape(l),
escape(text)
),
None => format!("<pre><code>{}</code></pre>", escape(text)),
},
Block::Quote(inner) => format!("<blockquote>{}</blockquote>", render_html(inner)),
Block::Math(expr) => format!("<pre>{}</pre>", escape(expr)),
Block::Divider => "──────────".to_string(),
Block::Details {
summary,
blocks,
open: _,
} => {
let summary_html = render_inlines(summary);
let body = render_html(blocks);
format!(
"<b>▸ {summary_html}</b>\n{}",
body.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
)
}
}
}
fn render_list(list: &List, depth: usize) -> String {
let pad = " ".repeat(depth);
let mut lines = Vec::new();
for (idx, item) in list.items.iter().enumerate() {
let bullet = match item.task {
Some(true) => "☑".to_string(),
Some(false) => "☐".to_string(),
None if list.ordered => format!("{}.", idx + 1),
None => "•".to_string(),
};
lines.push(format!("{pad}{bullet} {}", render_inlines(&item.content)));
for child in &item.children {
match child {
Block::List(inner) => lines.push(render_list(inner, depth + 1)),
other => lines.push(format!("{pad} {}", render_block(other))),
}
}
}
lines.join("\n")
}
const NARROW_TABLE_WIDTH: usize = 40;
fn render_table(table: &Table) -> String {
let cols = table.header.len();
let header: Vec<String> = table.header.iter().map(|c| plain(c)).collect();
let rows: Vec<Vec<String>> = table
.rows
.iter()
.map(|r| r.iter().map(|c| plain(c)).collect())
.collect();
let mut width = vec![0usize; cols];
for (i, h) in header.iter().enumerate() {
width[i] = width[i].max(h.chars().count());
}
for row in &rows {
for (i, c) in row.iter().enumerate().take(cols) {
width[i] = width[i].max(c.chars().count());
}
}
let grid_width = width.iter().sum::<usize>() + cols.saturating_sub(1) * 3;
if grid_width <= NARROW_TABLE_WIDTH {
return render_grid(table, &header, &rows, &width);
}
if cols <= 2 {
return render_key_value(table);
}
render_cards(table)
}
fn render_grid(table: &Table, header: &[String], rows: &[Vec<String>], width: &[usize]) -> String {
let cols = width.len();
let fmt = |cells: &[String]| -> String {
width
.iter()
.enumerate()
.map(|(i, w)| {
let cell = cells.get(i).map(String::as_str).unwrap_or("");
let align = table.align.get(i).copied().unwrap_or(Align::None);
let padded = pad_cell(cell, *w, align);
if i + 1 < cols {
format!("{} | ", padded)
} else {
padded
}
})
.collect()
};
let sep: String = width
.iter()
.map(|w| "-".repeat(*w))
.collect::<Vec<_>>()
.join("-+-");
let mut lines = vec![fmt(header), sep];
for row in rows {
lines.push(fmt(row));
}
format!("<pre>{}</pre>", escape(&lines.join("\n")))
}
fn render_key_value(table: &Table) -> String {
table
.rows
.iter()
.map(|row| match row.get(1) {
Some(val) => format!(
"<b>{}</b>: {}",
row.first().map(|c| render_inlines(c)).unwrap_or_default(),
render_inlines(val)
),
None => format!(
"<b>{}</b>",
row.first().map(|c| render_inlines(c)).unwrap_or_default()
),
})
.collect::<Vec<_>>()
.join("\n")
}
fn render_cards(table: &Table) -> String {
table
.rows
.iter()
.map(|row| {
let mut card = vec![format!(
"<b>{}</b>",
row.first().map(|c| render_inlines(c)).unwrap_or_default()
)];
for (i, cell) in row.iter().enumerate().skip(1) {
let label = table.header.get(i).map(|h| plain(h)).unwrap_or_default();
card.push(format!("{label}: {}", render_inlines(cell)));
}
card.join("\n")
})
.collect::<Vec<_>>()
.join("\n\n")
}
fn pad_cell(s: &str, width: usize, align: Align) -> String {
let len = s.chars().count();
if len >= width {
return s.to_string();
}
let total = width - len;
match align {
Align::Right => format!("{}{}", " ".repeat(total), s),
Align::Center => {
let left = total / 2;
format!("{}{}{}", " ".repeat(left), s, " ".repeat(total - left))
}
Align::Left | Align::None => format!("{}{}", s, " ".repeat(total)),
}
}
fn render_inlines(inlines: &[Inline]) -> String {
let mut s = String::new();
for i in inlines {
render_inline(i, &mut s);
}
s
}
fn render_inline(inline: &Inline, s: &mut String) {
match inline {
Inline::Text(t) => s.push_str(&escape(t)),
Inline::Bold(c) => wrap(s, "<b>", c, "</b>"),
Inline::Italic(c) => wrap(s, "<i>", c, "</i>"),
Inline::Strike(c) => wrap(s, "<s>", c, "</s>"),
Inline::Code(t) | Inline::Math(t) => {
s.push_str("<code>");
s.push_str(&escape(t));
s.push_str("</code>");
}
Inline::Link { content, url } => {
s.push_str(&format!("<a href=\"{}\">", escape(url)));
for c in content {
render_inline(c, s);
}
s.push_str("</a>");
}
}
}
fn wrap(s: &mut String, open: &str, content: &[Inline], close: &str) {
s.push_str(open);
for c in content {
render_inline(c, s);
}
s.push_str(close);
}
fn plain(inlines: &[Inline]) -> String {
let mut s = String::new();
for i in inlines {
plain_one(i, &mut s);
}
s
}
fn plain_one(inline: &Inline, s: &mut String) {
match inline {
Inline::Text(t) | Inline::Code(t) | Inline::Math(t) => s.push_str(t),
Inline::Bold(c) | Inline::Italic(c) | Inline::Strike(c) => {
for x in c {
plain_one(x, s);
}
}
Inline::Link { content, .. } => {
for x in content {
plain_one(x, s);
}
}
}
}
fn escape(t: &str) -> String {
t.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}