Files
nuwiki/crates/nuwiki-core/src/render/html.rs
T

596 lines
21 KiB
Rust
Raw Normal View History

2026-05-10 19:12:01 +00:00
//! 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, TagNode, TextNode, TransclusionNode, WikiLinkNode,
2026-05-10 19:12:01 +00:00
};
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<dyn Fn(&LinkTarget) -> String + Send + Sync>;
#[derive(Clone)]
pub struct HtmlRenderer {
link_resolver: LinkResolver,
template: Option<String>,
}
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<F>(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<String>) -> 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::Tag(n) => self.render_tag(n, w),
2026-05-10 19:12:01 +00:00
BlockNode::Error(n) => self.render_error(n, w),
}
}
fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
// `id="tag-…"` lets `[[Page#tag-name]]` jump to the rendered tag.
w.write_all(b"<div class=\"tags\">")?;
for (i, name) in n.tags.iter().enumerate() {
if i > 0 {
w.write_all(b" ")?;
}
w.write_all(b"<span class=\"tag\" id=\"tag-")?;
write_escaped(name, w)?;
w.write_all(b"\">")?;
write_escaped(name, w)?;
w.write_all(b"</span>")?;
}
w.write_all(b"</div>\n")
}
2026-05-10 19:12:01 +00:00
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, "<h{level}{class}>")?;
self.render_inlines(&n.children, w)?;
writeln!(w, "</h{level}>")
}
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<p>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</p>\n")
}
fn render_hr(&self, _n: &HorizontalRuleNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<hr>\n")
}
fn render_blockquote(&self, n: &BlockquoteNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<blockquote>\n")?;
for child in &n.children {
self.render_block(child, w)?;
}
w.write_all(b"</blockquote>\n")
}
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
match &n.language {
Some(lang) => {
w.write_all(b"<pre><code class=\"language-")?;
write_escaped(lang, w)?;
w.write_all(b"\">")?;
}
None => w.write_all(b"<pre><code>")?,
}
write_escaped(&n.content, w)?;
w.write_all(b"</code></pre>\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,
"<div class=\"math-block\" data-env=\"{}\">\\begin{{{env}}}",
escape(env)
)?,
None => w.write_all(b"<div class=\"math-block\">\\[\n")?,
}
write_escaped(&n.content, w)?;
match &n.environment {
Some(env) => write!(w, "\n\\end{{{env}}}</div>\n", env = escape(env))?,
None => w.write_all(b"\n\\]</div>\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, "</{tag}>")
}
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,
"<li class=\"{class}\"><input type=\"checkbox\" disabled{checked}> "
)?;
}
None => w.write_all(b"<li>")?,
}
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"</li>\n")
}
fn render_definition_list(&self, n: &DefinitionListNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<dl>\n")?;
for item in &n.items {
self.render_definition_item(item, w)?;
}
w.write_all(b"</dl>\n")
}
fn render_definition_item(&self, n: &DefinitionItemNode, w: &mut dyn Write) -> io::Result<()> {
if let Some(term) = &n.term {
w.write_all(b"<dt>")?;
self.render_inlines(term, w)?;
w.write_all(b"</dt>\n")?;
}
for def in &n.definitions {
w.write_all(b"<dd>")?;
self.render_inlines(def, w)?;
w.write_all(b"</dd>\n")?;
}
Ok(())
}
fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<table>\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"<thead>\n")?;
in_thead = true;
}
} else {
if in_thead {
w.write_all(b"</thead>\n")?;
in_thead = false;
}
if !in_tbody {
w.write_all(b"<tbody>\n")?;
in_tbody = true;
}
}
self.render_table_row(row, w)?;
}
if in_thead {
w.write_all(b"</thead>\n")?;
}
if in_tbody {
w.write_all(b"</tbody>\n")?;
}
w.write_all(b"</table>\n")
}
fn render_table_row(&self, n: &TableRowNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<tr>")?;
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"</tr>\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, "</{tag}>")
}
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, "<!--{safe}-->")
}
fn render_error(&self, n: &ErrorNode, w: &mut dyn Write) -> io::Result<()> {
write!(w, "<span class=\"parse-error\" title=\"")?;
write_escaped(&n.message, w)?;
w.write_all(b"\">")?;
write_escaped(&n.raw, w)?;
w.write_all(b"</span>")
}
// ===== 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"<strong>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</strong>")
}
fn render_italic(&self, n: &ItalicNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<em>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</em>")
}
fn render_bold_italic(&self, n: &BoldItalicNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<strong><em>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</em></strong>")
}
fn render_strikethrough(&self, n: &StrikethroughNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<del>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</del>")
}
fn render_code(&self, n: &CodeNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<code>")?;
write_escaped(&n.content, w)?;
w.write_all(b"</code>")
}
fn render_superscript(&self, n: &SuperscriptNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<sup>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</sup>")
}
fn render_subscript(&self, n: &SubscriptNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<sub>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</sub>")
}
fn render_math_inline(&self, n: &MathInlineNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<span class=\"math-inline\">\\(")?;
write_escaped(&n.content, w)?;
w.write_all(b"\\)</span>")
}
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, "<span class=\"{class}\">{label}</span>")
}
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
write!(w, "<span class=\"color-")?;
write_escaped(&n.color, w)?;
w.write_all(b"\">")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</span>")
}
fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> {
let href = (self.link_resolver)(&n.target);
w.write_all(b"<a href=\"")?;
write_escaped(&href, w)?;
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"</a>")
}
fn render_external_link(&self, n: &ExternalLinkNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<a href=\"")?;
write_escaped(&n.url, w)?;
w.write_all(b"\">")?;
match &n.description {
Some(desc) => self.render_inlines(desc, w)?,
None => write_escaped(&n.url, w)?,
}
w.write_all(b"</a>")
}
fn render_transclusion(&self, n: &TransclusionNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<img src=\"")?;
write_escaped(&n.url, w)?;
w.write_all(b"\" alt=\"")?;
write_escaped(n.alt.as_deref().unwrap_or(""), w)?;
w.write_all(b"\"")?;
// Emit any extra attrs verbatim; both key and value are escaped.
let mut keys: Vec<&String> = 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"<a href=\"")?;
write_escaped(&n.url, w)?;
w.write_all(b"\">")?;
write_escaped(&n.url, w)?;
w.write_all(b"</a>")
}
}
// ===== 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"&lt;",
b'>' => b"&gt;",
b'&' => b"&amp;",
b'"' => b"&quot;",
b'\'' => b"&#39;",
_ => 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("&lt;"),
'>' => out.push_str("&gt;"),
'&' => out.push_str("&amp;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
c => out.push(c),
}
}
out
}