//! HTML renderer. //! //! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is //! configured with a link resolver (callback that turns a `LinkTarget` into //! a URL string) and, optionally, an enclosing template with `{{title}}` / //! `{{content}}` placeholders (SPEC.md §6.8). //! //! Without a template the output is just the body markup, so callers are //! free to embed it in their own page shell. With a template the rendered //! body replaces `{{content}}` and the document's `metadata.title` //! replaces `{{title}}`. use std::io::{self, Write}; use std::sync::Arc; use crate::ast::{ BlockNode, BlockquoteNode, BoldItalicNode, BoldNode, CheckboxState, CodeNode, ColorNode, CommentNode, DefinitionItemNode, DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, HeadingNode, HorizontalRuleNode, InlineNode, ItalicNode, Keyword, KeywordNode, LinkKind, LinkTarget, ListItemNode, ListNode, MathBlockNode, MathInlineNode, ParagraphNode, PreformattedNode, RawUrlNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode, TableNode, TableRowNode, TextNode, TransclusionNode, WikiLinkNode, }; use super::Renderer; /// Boxed link-resolution callback. Held in an `Arc` so the renderer is /// cheap to clone and stays `Send + Sync`. pub type LinkResolver = Arc String + Send + Sync>; #[derive(Clone)] pub struct HtmlRenderer { link_resolver: LinkResolver, template: Option, } impl Default for HtmlRenderer { fn default() -> Self { Self::new() } } impl HtmlRenderer { pub fn new() -> Self { Self { link_resolver: Arc::new(default_link_resolver), template: None, } } /// Override the link resolver. The callback is invoked for every /// wikilink and is expected to return a URL string. pub fn with_link_resolver(mut self, f: F) -> Self where F: Fn(&LinkTarget) -> String + Send + Sync + 'static, { self.link_resolver = Arc::new(f); self } /// Wrap the rendered body in a template. `{{title}}` and `{{content}}` /// are substituted; everything else passes through verbatim. pub fn with_template(mut self, template: impl Into) -> Self { self.template = Some(template.into()); self } } impl Renderer for HtmlRenderer { fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> { if let Some(template) = &self.template { let mut body = Vec::new(); self.render_body(doc, &mut body)?; let body_str = std::str::from_utf8(&body) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; let title = doc.metadata.title.as_deref().unwrap_or(""); // Replace `{{content}}` first so its substituted body, which may // legitimately contain the literal `{{title}}`, isn't itself // rewritten in the next pass. let with_content = template.replace("{{content}}", body_str); let final_doc = with_content.replace("{{title}}", title); w.write_all(final_doc.as_bytes())?; } else { self.render_body(doc, w)?; } Ok(()) } } impl HtmlRenderer { fn render_body(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> { for block in &doc.children { self.render_block(block, w)?; } Ok(()) } fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> { match block { BlockNode::Heading(n) => self.render_heading(n, w), BlockNode::Paragraph(n) => self.render_paragraph(n, w), BlockNode::HorizontalRule(n) => self.render_hr(n, w), BlockNode::Blockquote(n) => self.render_blockquote(n, w), BlockNode::Preformatted(n) => self.render_preformatted(n, w), BlockNode::MathBlock(n) => self.render_math_block(n, w), BlockNode::List(n) => self.render_list(n, w), BlockNode::DefinitionList(n) => self.render_definition_list(n, w), BlockNode::Table(n) => self.render_table(n, w), BlockNode::Comment(n) => self.render_comment(n, w), BlockNode::Error(n) => self.render_error(n, w), } } fn render_heading(&self, n: &HeadingNode, w: &mut dyn Write) -> io::Result<()> { let level = n.level.clamp(1, 6); let class = if n.centered { " class=\"centered\"" } else { "" }; write!(w, "")?; self.render_inlines(&n.children, w)?; writeln!(w, "") } fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"

")?; self.render_inlines(&n.children, w)?; w.write_all(b"

\n") } fn render_hr(&self, _n: &HorizontalRuleNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"
\n") } fn render_blockquote(&self, n: &BlockquoteNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"
\n")?; for child in &n.children { self.render_block(child, w)?; } w.write_all(b"
\n") } fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> { match &n.language { Some(lang) => { w.write_all(b"
")?;
            }
            None => w.write_all(b"
")?,
        }
        write_escaped(&n.content, w)?;
        w.write_all(b"
\n") } fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> { // No actual TeX rendering — caller is expected to run MathJax / KaTeX // over the wrapped node. The delimiters match vimwiki convention. match &n.environment { Some(env) => writeln!( w, "
\\begin{{{env}}}", escape(env) )?, None => w.write_all(b"
\\[\n")?, } write_escaped(&n.content, w)?; match &n.environment { Some(env) => write!(w, "\n\\end{{{env}}}
\n", env = escape(env))?, None => w.write_all(b"\n\\]
\n")?, } Ok(()) } fn render_list(&self, n: &ListNode, w: &mut dyn Write) -> io::Result<()> { let tag = if n.ordered { "ol" } else { "ul" }; writeln!(w, "<{tag}>")?; for item in &n.items { self.render_list_item(item, w)?; } writeln!(w, "") } fn render_list_item(&self, n: &ListItemNode, w: &mut dyn Write) -> io::Result<()> { match n.checkbox { Some(state) => { let (checked, class) = match state { CheckboxState::Empty => ("", "task-empty"), CheckboxState::Quarter => ("", "task-quarter"), CheckboxState::Half => ("", "task-half"), CheckboxState::ThreeQuarters => ("", "task-three-quarters"), CheckboxState::Done => (" checked", "task-done"), CheckboxState::Rejected => ("", "task-rejected"), }; write!( w, "
  • " )?; } None => w.write_all(b"
  • ")?, } self.render_inlines(&n.children, w)?; if let Some(sublist) = &n.sublist { w.write_all(b"\n")?; self.render_list(sublist, w)?; } w.write_all(b"
  • \n") } fn render_definition_list(&self, n: &DefinitionListNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"
    \n")?; for item in &n.items { self.render_definition_item(item, w)?; } w.write_all(b"
    \n") } fn render_definition_item(&self, n: &DefinitionItemNode, w: &mut dyn Write) -> io::Result<()> { if let Some(term) = &n.term { w.write_all(b"
    ")?; self.render_inlines(term, w)?; w.write_all(b"
    \n")?; } for def in &n.definitions { w.write_all(b"
    ")?; self.render_inlines(def, w)?; w.write_all(b"
    \n")?; } Ok(()) } fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"\n")?; let mut in_thead = false; let mut in_tbody = false; for row in &n.rows { if row.is_header { if !in_thead { w.write_all(b"\n")?; in_thead = true; } } else { if in_thead { w.write_all(b"\n")?; in_thead = false; } if !in_tbody { w.write_all(b"\n")?; in_tbody = true; } } self.render_table_row(row, w)?; } if in_thead { w.write_all(b"\n")?; } if in_tbody { w.write_all(b"\n")?; } w.write_all(b"
    \n") } fn render_table_row(&self, n: &TableRowNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"")?; for cell in &n.cells { // col_span / row_span here are *spec flags* meaning "this cell is // a continuation marker", which HTML expresses via the *previous* // cell's colspan/rowspan attrs. Computing those attrs requires // forward state on the renderer side; for now we emit class // markers so styling can show the span visually. self.render_table_cell(cell, n.is_header, w)?; } w.write_all(b"\n") } fn render_table_cell( &self, n: &TableCellNode, is_header: bool, w: &mut dyn Write, ) -> io::Result<()> { let tag = if is_header { "th" } else { "td" }; let class = match (n.col_span, n.row_span) { (true, _) => " class=\"col-span\"", (_, true) => " class=\"row-span\"", _ => "", }; write!(w, "<{tag}{class}>")?; self.render_inlines(&n.children, w)?; write!(w, "") } fn render_comment(&self, n: &CommentNode, w: &mut dyn Write) -> io::Result<()> { // Emit as an HTML comment so it survives round-trips through view-source // but isn't rendered to the reader. `--` inside a comment is illegal // in HTML, so collapse runs. let safe = n.content.replace("--", "- -"); writeln!(w, "") } fn render_error(&self, n: &ErrorNode, w: &mut dyn Write) -> io::Result<()> { write!(w, "")?; write_escaped(&n.raw, w)?; w.write_all(b"") } // ===== Inline ===== fn render_inlines(&self, nodes: &[InlineNode], w: &mut dyn Write) -> io::Result<()> { for n in nodes { self.render_inline(n, w)?; } Ok(()) } fn render_inline(&self, n: &InlineNode, w: &mut dyn Write) -> io::Result<()> { match n { InlineNode::Text(t) => self.render_text(t, w), InlineNode::Bold(t) => self.render_bold(t, w), InlineNode::Italic(t) => self.render_italic(t, w), InlineNode::BoldItalic(t) => self.render_bold_italic(t, w), InlineNode::Strikethrough(t) => self.render_strikethrough(t, w), InlineNode::Code(t) => self.render_code(t, w), InlineNode::Superscript(t) => self.render_superscript(t, w), InlineNode::Subscript(t) => self.render_subscript(t, w), InlineNode::MathInline(t) => self.render_math_inline(t, w), InlineNode::Keyword(t) => self.render_keyword(t, w), InlineNode::Color(t) => self.render_color(t, w), InlineNode::WikiLink(t) => self.render_wikilink(t, w), InlineNode::ExternalLink(t) => self.render_external_link(t, w), InlineNode::Transclusion(t) => self.render_transclusion(t, w), InlineNode::RawUrl(t) => self.render_raw_url(t, w), } } fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> { write_escaped(&n.content, w) } fn render_bold(&self, n: &BoldNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"")?; self.render_inlines(&n.children, w)?; w.write_all(b"") } fn render_italic(&self, n: &ItalicNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"")?; self.render_inlines(&n.children, w)?; w.write_all(b"") } fn render_bold_italic(&self, n: &BoldItalicNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"")?; self.render_inlines(&n.children, w)?; w.write_all(b"") } fn render_strikethrough(&self, n: &StrikethroughNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"")?; self.render_inlines(&n.children, w)?; w.write_all(b"") } fn render_code(&self, n: &CodeNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"")?; write_escaped(&n.content, w)?; w.write_all(b"") } fn render_superscript(&self, n: &SuperscriptNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"")?; self.render_inlines(&n.children, w)?; w.write_all(b"") } fn render_subscript(&self, n: &SubscriptNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"")?; self.render_inlines(&n.children, w)?; w.write_all(b"") } fn render_math_inline(&self, n: &MathInlineNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"\\(")?; write_escaped(&n.content, w)?; w.write_all(b"\\)") } fn render_keyword(&self, n: &KeywordNode, w: &mut dyn Write) -> io::Result<()> { let (label, class) = match n.keyword { Keyword::Todo => ("TODO", "keyword keyword-todo"), Keyword::Done => ("DONE", "keyword keyword-done"), Keyword::Started => ("STARTED", "keyword keyword-started"), Keyword::Fixme => ("FIXME", "keyword keyword-fixme"), Keyword::Fixed => ("FIXED", "keyword keyword-fixed"), Keyword::Xxx => ("XXX", "keyword keyword-xxx"), }; write!(w, "{label}") } fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> { write!(w, "")?; self.render_inlines(&n.children, w)?; w.write_all(b"") } fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> { let href = (self.link_resolver)(&n.target); w.write_all(b"")?; match &n.description { Some(desc) => self.render_inlines(desc, w)?, None => { let label = n .target .path .clone() .or_else(|| n.target.anchor.clone()) .unwrap_or_else(|| href.clone()); write_escaped(&label, w)?; } } w.write_all(b"") } fn render_external_link(&self, n: &ExternalLinkNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"")?; match &n.description { Some(desc) => self.render_inlines(desc, w)?, None => write_escaped(&n.url, w)?, } w.write_all(b"") } fn render_transclusion(&self, n: &TransclusionNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"\"")?; = n.attrs.keys().collect(); keys.sort(); for k in keys { let v = &n.attrs[k]; w.write_all(b" ")?; write_escaped(k, w)?; w.write_all(b"=\"")?; write_escaped(v, w)?; w.write_all(b"\"")?; } w.write_all(b">") } fn render_raw_url(&self, n: &RawUrlNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"")?; write_escaped(&n.url, w)?; w.write_all(b"") } } // ===== Default link resolver ===== /// Default `LinkResolver`. Mirrors the conventions in SPEC.md §9: /// wiki pages become `path.html`; interwiki links land in sibling /// directories; diary entries land under `diary/`; file/local schemes /// use their literal path; anchor-only links collapse to a fragment. pub fn default_link_resolver(target: &LinkTarget) -> String { if matches!(target.kind, LinkKind::AnchorOnly) { return match &target.anchor { Some(a) => format!("#{a}"), None => String::new(), }; } let path = target.path.as_deref().unwrap_or(""); let mut out = String::new(); match target.kind { LinkKind::Wiki => { if target.is_absolute { out.push('/'); } out.push_str(path); if target.is_directory { out.push('/'); } else if !path.is_empty() { out.push_str(".html"); } } LinkKind::Interwiki => { if let Some(idx) = target.wiki_index { out.push_str(&format!("../wiki{idx}/")); } else if let Some(name) = &target.wiki_name { out.push_str(&format!("../wn-{name}/")); } out.push_str(path); if !target.is_directory { out.push_str(".html"); } } LinkKind::Diary => { out.push_str("diary/"); out.push_str(path); out.push_str(".html"); } LinkKind::File => { out.push_str(path); } LinkKind::Local => { out.push_str("file://"); if target.is_absolute && !path.starts_with('/') { out.push('/'); } out.push_str(path); } LinkKind::Raw => { out.push_str(path); } LinkKind::AnchorOnly => unreachable!(), } if let Some(anchor) = &target.anchor { out.push('#'); out.push_str(anchor); } out } // ===== HTML escaping ===== fn write_escaped(s: &str, w: &mut dyn Write) -> io::Result<()> { let bytes = s.as_bytes(); let mut last = 0; for (i, &b) in bytes.iter().enumerate() { let replacement: &[u8] = match b { b'<' => b"<", b'>' => b">", b'&' => b"&", b'"' => b""", b'\'' => b"'", _ => continue, }; if i > last { w.write_all(&bytes[last..i])?; } w.write_all(replacement)?; last = i + 1; } if last < bytes.len() { w.write_all(&bytes[last..])?; } Ok(()) } fn escape(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_str("""), '\'' => out.push_str("'"), c => out.push(c), } } out }