//! RENDER stage (spec §2.1, §2.4): resolved element tree → HTML fragment. //! //! A tree walk emitting HTML into a buffer. Two sub-concerns get care (spec §2.4): //! 1. Footnotes use a two-pass layout — definitions are collected up front, references //! are numbered in order of first appearance during the walk, and a back-linked //! notes section is emitted at page end. //! 2. Syntax highlighting happens HERE, not at parse time — it is an output concern //! and its cost must be cache-skippable (spec §4.2). Emit CSS classes, not inline //! styles, so themes live in the stylesheet (spec §3.2). //! //! v0.2 renders: headings (always anchored, with tags), paragraphs, plain lists //! (unordered/ordered + checkboxes), source/example blocks, horizontal rules, tables //! (with header band from the rule row), footnotes, and inline markup. Real syntect //! tokenizing remains a `
` passthrough for now (see [`SyntectHighlighter`]).
use std::collections::HashMap;
use crate::model::{Checkbox, Element, LinkTarget, ListKind, Object, Section, TableRow};
use crate::resolve::ResolvedDoc;
use crate::util::{plain_text, slugify};
/// A rendered HTML fragment (content only — no page chrome; spec §2.4).
#[derive(Debug, Clone)]
pub struct Html(pub String);
/// Pluggable highlighter so tree-sitter can replace syntect per-language later
/// without touching the renderer (spec §3.2, R6).
pub trait Highlighter {
fn highlight(&self, code: &str, lang: Option<&str>) -> Html;
}
/// Default v1 highlighter. For now this is a plain `` passthrough that
/// escapes the code and tags it with a `language-*` class; real syntect tokenizing
/// to CSS-class spans is deferred (spec §3.2, §4.2).
pub struct SyntectHighlighter;
impl Highlighter for SyntectHighlighter {
fn highlight(&self, code: &str, lang: Option<&str>) -> Html {
let class = match lang {
Some(l) => format!(" class=\"language-{}\"", escape_attr(l)),
None => String::new(),
};
Html(format!(
"{}
\n",
class,
escape_html(code)
))
}
}
/// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4).
struct Renderer<'a> {
hl: &'a dyn Highlighter,
/// Block footnote definitions, keyed by label (collected before the walk).
block_defs: HashMap>,
/// Inline footnote definitions discovered at reference sites.
inline_defs: HashMap>,
/// Reference keys in order of first appearance — drives numbering and note order.
order: Vec,
}
/// Render a resolved document to an HTML fragment.
pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
let mut r = Renderer {
hl: highlighter,
block_defs: HashMap::new(),
inline_defs: HashMap::new(),
order: Vec::new(),
};
r.collect_defs(&doc.document.root);
let mut out = String::new();
r.render_section(&doc.document.root, &mut out);
r.emit_footnotes(&mut out);
Html(out)
}
impl Renderer<'_> {
/// First footnote pass: gather every block definition in the tree by label.
fn collect_defs(&mut self, section: &Section) {
collect_defs_in(§ion.content, &mut self.block_defs);
for child in §ion.children {
self.collect_defs(child);
}
}
fn render_section(&mut self, section: &Section, out: &mut String) {
if let Some(h) = §ion.heading {
let level = h.level.clamp(1, 6);
let anchor = h
.custom_id
.clone()
.or_else(|| h.id.clone())
.unwrap_or_else(|| slugify(&plain_text(&h.title)));
out.push_str(&format!("", level, escape_attr(&anchor)));
self.render_objects(&h.title, out);
for tag in &h.tags {
out.push_str(&format!(" {}", escape_html(tag)));
}
out.push_str(&format!(" \n", level));
}
for element in §ion.content {
self.render_element(element, out);
}
for child in §ion.children {
self.render_section(child, out);
}
}
fn render_element(&mut self, element: &Element, out: &mut String) {
match element {
Element::Paragraph(objs) => {
out.push_str("");
self.render_objects(objs, out);
out.push_str("
\n");
}
Element::List(list) => {
let tag = match list.kind {
ListKind::Ordered => "ol",
_ => "ul",
};
out.push_str(&format!("<{}>\n", tag));
for item in &list.items {
out.push_str("");
if let Some(cb) = &item.checkbox {
let checked = matches!(cb, Checkbox::On);
out.push_str(&format!(
" ",
if checked { " checked" } else { "" }
));
}
match item.content.as_slice() {
[Element::Paragraph(objs)] => self.render_objects(objs, out),
els => {
for el in els {
self.render_element(el, out);
}
}
}
out.push_str(" \n");
}
out.push_str(&format!("{}>\n", tag));
}
Element::Table(table) => self.render_table(table, out),
Element::SrcBlock { lang, code, .. } => {
let Html(h) = self.hl.highlight(code, lang.as_deref());
out.push_str(&h);
}
Element::ExampleBlock(code) => {
out.push_str(&format!("{}\n", escape_html(code)));
}
Element::HorizontalRule => out.push_str("
\n"),
// Definitions are emitted in the footnotes section, not inline.
Element::FootnoteDefinition { .. } => {}
// Out of scope (non-HTML export, generic drawers, stray keywords, comments):
// emitted as nothing rather than crashing.
_ => {}
}
}
/// Rows before the first rule row become the ``; the rest are the ``.
fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
let rule_at = table
.rows
.iter()
.position(|r| matches!(r, TableRow::Rule));
out.push_str("\n");
let mut wrote_body = false;
let mut in_body = rule_at.is_none();
for (idx, row) in table.rows.iter().enumerate() {
match row {
TableRow::Rule => {
if Some(idx) == rule_at {
in_body = true;
}
continue;
}
TableRow::Cells(cells) => {
let (open, cell_tag) = if in_body {
if !wrote_body {
wrote_body = true;
("\n", "td")
} else {
(" ", "td")
}
} else {
("\n", "th")
};
out.push_str(open);
for cell in cells {
out.push_str(&format!("<{}>", cell_tag));
self.render_objects(cell, out);
out.push_str(&format!("{}>", cell_tag));
}
out.push_str(" \n");
// Close the header band right after its last row.
if !in_body
&& rule_at.map(|r| idx + 1 == r).unwrap_or(false)
{
out.push_str("\n");
}
}
}
}
if wrote_body {
out.push_str(" \n");
}
out.push_str("
\n");
}
fn render_objects(&mut self, objs: &[Object], out: &mut String) {
for obj in objs {
self.render_object(obj, out);
}
}
fn render_object(&mut self, obj: &Object, out: &mut String) {
match obj {
Object::Text(t) => out.push_str(&escape_html(t)),
Object::Bold(inner) => self.wrap(out, "strong", inner),
Object::Italic(inner) => self.wrap(out, "em", inner),
Object::Underline(inner) => self.wrap(out, "u", inner),
Object::StrikeThrough(inner) => self.wrap(out, "del", inner),
Object::Verbatim(s) => {
out.push_str(&format!("{}", escape_html(s)))
}
Object::Code(s) => out.push_str(&format!("{}", escape_html(s))),
Object::Link(link) => {
let href = link_href(&link.target);
out.push_str(&format!("", escape_attr(&href)));
match &link.description {
Some(desc) => self.render_objects(desc, out),
None => out.push_str(&escape_html(&link_text(&link.target))),
}
out.push_str("");
}
Object::FootnoteRef { label, inline } => {
let key = if label.is_empty() {
format!("__anon{}", self.order.len() + 1)
} else {
label.clone()
};
if let Some(objs) = inline {
self.inline_defs.insert(key.clone(), objs.clone());
}
if !self.order.contains(&key) {
self.order.push(key.clone());
}
let num = self.order.iter().position(|l| l == &key).unwrap() + 1;
out.push_str(&format!(
"{n}",
n = num
));
}
Object::LineBreak => out.push_str("
\n"),
// Timestamps, entities: out of scope for now.
_ => {}
}
}
fn wrap(&mut self, out: &mut String, tag: &str, inner: &[Object]) {
out.push_str(&format!("<{}>", tag));
self.render_objects(inner, out);
out.push_str(&format!("{}>", tag));
}
/// Second footnote pass: emit the numbered, back-linked notes section (spec §2.4).
fn emit_footnotes(&mut self, out: &mut String) {
if self.order.is_empty() {
return;
}
let order = self.order.clone();
let inline_defs = self.inline_defs.clone();
let block_defs = self.block_defs.clone();
out.push_str("\n
\n\n");
for (idx, label) in order.iter().enumerate() {
let n = idx + 1;
out.push_str(&format!("- "));
if let Some(objs) = inline_defs.get(label) {
self.render_objects(objs, out);
} else if let Some(els) = block_defs.get(label) {
for el in els {
self.render_element(el, out);
}
}
out.push_str(&format!(
" ↩
\n"
));
}
out.push_str("
\n \n");
}
}
fn collect_defs_in(elements: &[Element], defs: &mut HashMap>) {
for el in elements {
if let Element::FootnoteDefinition { label, content } = el {
defs.entry(label.clone()).or_insert_with(|| content.clone());
}
}
}
/// Best-effort URL for a link target. After RESOLVE, internal targets have been
/// rewritten to `External` with their final URL; anything still internal here is an
/// unresolved link, rendered to a plausible anchor so the page stays self-consistent.
fn link_href(target: &LinkTarget) -> String {
match target {
LinkTarget::External(s) => s.clone(),
LinkTarget::CustomId(id) => format!("#{}", id),
LinkTarget::Id(id) => format!("#{}", id),
LinkTarget::Heading(text) => format!("#{}", slugify(text)),
LinkTarget::File { path, .. } => path.to_string(),
}
}
fn link_text(target: &LinkTarget) -> String {
match target {
LinkTarget::External(s) => s.clone(),
LinkTarget::CustomId(id) | LinkTarget::Id(id) => id.clone(),
LinkTarget::Heading(text) => text.clone(),
LinkTarget::File { path, .. } => path.to_string(),
}
}
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
out
}
fn escape_attr(s: &str) -> String {
let mut out = escape_html(s);
out = out.replace('"', """);
out
}