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

1066 lines
38 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 substitution
//! placeholders.
2026-05-10 19:12:01 +00:00
//!
//! Substitution model: `{{content}}` and `{{title}}` are computed from
//! the rendered body and the document's metadata; `with_var(k, v)` /
//! `with_vars(map)` add arbitrary key→value pairs (`{{date}}`,
//! `{{root_path}}`, `{{toc}}`). Order: `{{content}}` is
//! substituted first so a body that happens to contain a literal
//! `{{title}}` isn't itself rewritten in the next pass.
//!
//! Both delimiter styles are recognised for every placeholder: nuwiki's
//! native `{{key}}` and vimwiki's `%key%` (`%title%`, `%content%`,
//! `%root_path%`, `%date%`, `%wiki_css%`, …). This lets stock vimwiki
//! templates render unchanged.
2026-05-10 19:12:01 +00:00
use std::collections::HashMap;
2026-05-10 19:12:01 +00:00
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>,
vars: HashMap<String, String>,
/// `color_dic`-style override: vimwiki colour-tag names → CSS
/// values (`"red"` / `"#ffe119"` / `"oklch(…)"`). Unspecified
/// names fall through to the default `class="color-<name>"`
/// rendering. Matches vimwiki's `color_dic`.
colors: HashMap<String, String>,
/// vimwiki `html_header_numbering`: the heading level at which automatic
/// section numbering begins (`0` = off, the default). When `>= 1`, every
/// heading at that level or deeper is prefixed with a dotted section
/// number (`1`, `1.1`, `1.2`, …); shallower headings stay unnumbered.
header_numbering: u8,
/// vimwiki `html_header_numbering_sym`: a symbol appended after the
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
header_numbering_sym: String,
/// vimwiki `valid_html_tags`: inline HTML tag names passed through to
/// export verbatim (instead of escaping `<`/`>`). Empty = escape all.
/// `span` is always allowed so colour spans render.
valid_html_tags: Vec<String>,
/// vimwiki `emoji_enable` (default `false` in the renderer; set from
/// config): substitute `:alias:` shortcodes with their emoji glyph.
emoji_enable: bool,
/// vimwiki `text_ignore_newline` (default `true`): a soft break in a
/// paragraph renders as a space; `false` → `<br />`.
text_ignore_newline: bool,
/// vimwiki `list_ignore_newline` (default `true`): a soft break in a list
/// item renders as a space; `false` → `<br />`.
list_ignore_newline: bool,
2026-05-10 19:12:01 +00:00
}
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,
vars: HashMap::new(),
colors: HashMap::new(),
header_numbering: 0,
header_numbering_sym: String::new(),
valid_html_tags: Vec::new(),
emoji_enable: false,
text_ignore_newline: true,
list_ignore_newline: true,
2026-05-10 19:12:01 +00:00
}
}
/// Set vimwiki `text_ignore_newline` / `list_ignore_newline`. When `false`,
/// a soft line break in a paragraph / list item renders as `<br />`
/// instead of a space.
pub fn with_newline_handling(mut self, text_ignore: bool, list_ignore: bool) -> Self {
self.text_ignore_newline = text_ignore;
self.list_ignore_newline = list_ignore;
self
}
/// Set the inline HTML tags allowed through export unescaped (vimwiki
/// `valid_html_tags`). `span` is always added so colour spans render.
pub fn with_valid_html_tags(mut self, mut tags: Vec<String>) -> Self {
if !tags.iter().any(|t| t.eq_ignore_ascii_case("span")) {
tags.push("span".to_string());
}
self.valid_html_tags = tags;
self
}
/// Enable `:alias:` → emoji substitution during export (vimwiki
/// `emoji_enable`).
pub fn with_emoji(mut self, enable: bool) -> Self {
self.emoji_enable = enable;
self
}
2026-05-10 19:12:01 +00:00
/// 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 automatically; additional `{{key}}` placeholders
/// resolve via `with_var` / `with_vars`. Anything unresolved passes
/// through verbatim.
2026-05-10 19:12:01 +00:00
pub fn with_template(mut self, template: impl Into<String>) -> Self {
self.template = Some(template.into());
self
}
/// Add a single template variable. Re-use to overwrite.
pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.vars.insert(key.into(), value.into());
self
}
/// Replace the entire template variable map.
pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
self.vars = vars;
self
}
/// Supply a `color_dic`: vimwiki colour-tag names mapped to their
/// CSS values. A name listed here is rendered as `style="color:V"`;
/// missing names fall through to `class="color-<name>"`.
pub fn with_colors(mut self, colors: HashMap<String, String>) -> Self {
self.colors = colors;
self
}
/// Enable vimwiki-style HTML section numbering. `level` is the heading
/// level at which numbering starts (`0` disables it); `sym` is appended
/// after the number. Mirrors `html_header_numbering` /
/// `html_header_numbering_sym`.
pub fn with_header_numbering(mut self, level: u8, sym: impl Into<String>) -> Self {
self.header_numbering = level;
self.header_numbering_sym = sym.into();
self
}
}
/// Running state for vimwiki-style HTML section numbering
/// (`html_header_numbering`). Headings are visited in document order; each
/// at or below the start level bumps its depth's counter, resets deeper
/// counters, and yields a dotted prefix (`1`, `1.1`, `2`, …).
struct HeadingNumberer {
/// Heading level numbering starts at (`0` disables it).
start: u8,
/// Symbol appended after the dotted number.
sym: String,
/// Per-depth counters, indexed by `level - start` (levels clamp to 16,
/// so the depth is always `0..=5`).
counters: [u32; 6],
}
impl HeadingNumberer {
fn new(start: u8, sym: &str) -> Self {
Self {
start,
sym: sym.to_string(),
counters: [0; 6],
}
}
/// Advance to a heading at `level` (16) and return its numeric prefix,
/// including the trailing symbol and a separating space (e.g. `"1.2. "`).
/// Returns an empty string when numbering is off or the heading is
/// shallower than the start level.
fn prefix(&mut self, level: u8) -> String {
if self.start == 0 || level < self.start {
return String::new();
}
let depth = (level - self.start) as usize;
self.counters[depth] += 1;
for c in self.counters.iter_mut().skip(depth + 1) {
*c = 0;
}
let num = self.counters[..=depth]
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(".");
format!("{num}{} ", self.sym)
}
2026-05-10 19:12:01 +00:00
}
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("");
// Substitute `content` before `title` so a body that legitimately
// contains a literal title placeholder isn't rewritten. Both the
// native `{{key}}` and vimwiki's `%key%` delimiters are accepted so
// stock vimwiki templates render unchanged.
let mut out = template.replace("{{content}}", body_str);
out = out.replace("%content%", body_str);
out = out.replace("{{title}}", title);
out = out.replace("%title%", title);
for (k, v) in &self.vars {
out = out.replace(&format!("{{{{{k}}}}}"), v);
out = out.replace(&format!("%{k}%"), v);
}
w.write_all(out.as_bytes())?;
2026-05-10 19:12:01 +00:00
} else {
self.render_body(doc, w)?;
}
Ok(())
}
}
impl HtmlRenderer {
fn render_body(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> {
// Section numbering runs over top-level headings in document order,
// mirroring vimwiki's line-by-line scan. Nested headings (in lists,
// quotes) go through `render_block` and are left unnumbered, matching
// upstream which only numbers document-level headers.
let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym);
2026-05-10 19:12:01 +00:00
for block in &doc.children {
if let BlockNode::Heading(n) = block {
let number = numberer.prefix(n.level.clamp(1, 6));
self.render_heading(n, &number, w)?;
} else {
self.render_block(block, w)?;
}
2026-05-10 19:12:01 +00:00
}
Ok(())
}
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
match block {
BlockNode::Heading(n) => self.render_heading(n, "", w),
2026-05-10 19:12:01 +00:00
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")
}
/// Render a heading. `number` is the optional section-number prefix
/// (already including its trailing symbol and space, e.g. `"1.2. "`);
/// pass `""` for no numbering.
fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> {
2026-05-10 19:12:01 +00:00
let level = n.level.clamp(1, 6);
let class = if n.centered {
" class=\"centered\""
} else {
""
};
write!(w, "<h{level}{class}>")?;
if !number.is_empty() {
write_escaped(number, w)?;
}
2026-05-10 19:12:01 +00:00
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>")?;
let sb = if self.text_ignore_newline { " " } else { "<br />" };
self.render_inlines_break(&n.children, sb, w)?;
2026-05-10 19:12:01 +00:00
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}>")?;
2026-05-10 19:12:01 +00:00
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) => {
// vimwiki-compatible checkbox classes: `[ ]`→done0, `[.]`→done1,
// `[o]`→done2, `[O]`→done3, `[X]`→done4, `[-]`→rejected. The
// checkbox glyph/progress fill is drawn entirely by the
// stylesheet (`li.doneN::before`); vimwiki emits no `<input>`,
// so neither do we — an input would double-render against it.
let class = match state {
CheckboxState::Empty => "done0",
CheckboxState::Quarter => "done1",
CheckboxState::Half => "done2",
CheckboxState::ThreeQuarters => "done3",
CheckboxState::Done => "done4",
CheckboxState::Rejected => "rejected",
2026-05-10 19:12:01 +00:00
};
write!(w, "<li class=\"{class}\">")?;
2026-05-10 19:12:01 +00:00
}
None => w.write_all(b"<li>")?,
}
let sb = if self.list_ignore_newline { " " } else { "<br />" };
self.render_inlines_break(&n.children, sb, w)?;
2026-05-10 19:12:01 +00:00
if let Some(sublist) = &n.sublist {
w.write_all(b"\n")?;
self.render_list(sublist, w)?;
2026-05-10 19:12:01 +00:00
}
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<()> {
// Pre-compute per-cell rowspan/colspan + suppression so we can
// emit valid HTML colspan/rowspan attributes instead of CSS
// class markers. `col_span` cells get folded into the colspan
// of the lead cell to their left; `row_span` cells get folded
// into the rowspan of the lead cell directly above.
let layout = table_spans(n);
2026-05-10 19:12:01 +00:00
w.write_all(b"<table>\n")?;
let mut in_thead = false;
let mut in_tbody = false;
for (row_idx, row) in n.rows.iter().enumerate() {
2026-05-10 19:12:01 +00:00
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, row_idx, &layout, &n.alignments, w)?;
2026-05-10 19:12:01 +00:00
}
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,
row_idx: usize,
layout: &[Vec<CellLayout>],
alignments: &[crate::ast::TableAlign],
w: &mut dyn Write,
) -> io::Result<()> {
2026-05-10 19:12:01 +00:00
w.write_all(b"<tr>")?;
for (col_idx, cell) in n.cells.iter().enumerate() {
let info = layout
.get(row_idx)
.and_then(|r| r.get(col_idx))
.copied()
.unwrap_or(CellLayout::Lead {
colspan: 1,
rowspan: 1,
});
match info {
CellLayout::Skip => continue,
CellLayout::Lead { colspan, rowspan } => {
let align = alignments
.get(col_idx)
.copied()
.unwrap_or(crate::ast::TableAlign::Default);
self.render_table_cell(cell, n.is_header, colspan, rowspan, align, w)?;
}
}
2026-05-10 19:12:01 +00:00
}
w.write_all(b"</tr>\n")
}
fn render_table_cell(
&self,
n: &TableCellNode,
is_header: bool,
colspan: u32,
rowspan: u32,
align: crate::ast::TableAlign,
2026-05-10 19:12:01 +00:00
w: &mut dyn Write,
) -> io::Result<()> {
let tag = if is_header { "th" } else { "td" };
write!(w, "<{tag}")?;
if colspan > 1 {
write!(w, " colspan=\"{colspan}\"")?;
}
if rowspan > 1 {
write!(w, " rowspan=\"{rowspan}\"")?;
}
let align_attr = match align {
crate::ast::TableAlign::Left => Some("left"),
crate::ast::TableAlign::Right => Some("right"),
crate::ast::TableAlign::Center => Some("center"),
crate::ast::TableAlign::Default => None,
};
if let Some(a) = align_attr {
write!(w, " style=\"text-align: {a};\"")?;
}
write!(w, ">")?;
2026-05-10 19:12:01 +00:00
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(())
}
/// Like [`render_inlines`] but renders top-level soft breaks as `soft_break`
/// (a space or `<br />`). Used by paragraphs / list items, which are the
/// only blocks that contain `SoftBreak` nodes.
fn render_inlines_break(
&self,
nodes: &[InlineNode],
soft_break: &str,
w: &mut dyn Write,
) -> io::Result<()> {
for n in nodes {
match n {
InlineNode::SoftBreak(_) => w.write_all(soft_break.as_bytes())?,
_ => self.render_inline(n, w)?,
}
}
Ok(())
}
2026-05-10 19:12:01 +00:00
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),
// Default rendering (e.g. inside a heading): a space. Paragraphs
// and list items use render_inlines_break for `<br />` support.
InlineNode::SoftBreak(_) => w.write_all(b" "),
2026-05-10 19:12:01 +00:00
}
}
fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> {
let content = if self.emoji_enable {
substitute_emoji(&n.content)
} else {
std::borrow::Cow::Borrowed(n.content.as_str())
};
if self.valid_html_tags.is_empty() {
write_escaped(&content, w)
} else {
write_escaped_allowing(&content, &self.valid_html_tags, w)
}
2026-05-10 19:12:01 +00:00
}
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<()> {
// One class per keyword so stylesheets can colour them independently
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
// vimwiki `todo` class so stock vimwiki CSS still styles it.
2026-05-10 19:12:01 +00:00
let (label, class) = match n.keyword {
Keyword::Todo => ("TODO", "todo"),
Keyword::Done => ("DONE", "done"),
Keyword::Started => ("STARTED", "started"),
Keyword::Fixme => ("FIXME", "fixme"),
Keyword::Fixed => ("FIXED", "fixed"),
Keyword::Xxx => ("XXX", "xxx"),
2026-05-28 22:05:23 -03:00
Keyword::Stopped => ("STOPPED", "stopped"),
2026-05-10 19:12:01 +00:00
};
write!(w, "<span class=\"{class}\">{label}</span>")
}
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
if let Some(css) = self.colors.get(&n.color) {
w.write_all(b"<span style=\"color:")?;
write_escaped(css, w)?;
w.write_all(b"\">")?;
} else {
w.write_all(b"<span class=\"color-")?;
write_escaped(&n.color, w)?;
w.write_all(b"\">")?;
}
2026-05-10 19:12:01 +00:00
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>")
}
}
// ===== Table colspan/rowspan layout =====
/// Per-cell render decision: emit a lead cell with the computed spans,
/// or skip (this cell is a continuation marker absorbed by its lead).
#[derive(Debug, Clone, Copy)]
enum CellLayout {
Lead { colspan: u32, rowspan: u32 },
Skip,
}
/// Walk every cell in the table and resolve `col_span` / `row_span`
/// markers into HTML `colspan="N"` / `rowspan="N"` attributes on the
/// preceding lead cell.
///
/// Rules:
/// - `col_span: true` (`>` in vimwiki source) bumps the colspan of
/// the lead cell to its *left* in the same row.
/// - `row_span: true` (`\\/` in vimwiki source) bumps the rowspan
/// of the lead cell *above* in the same column.
/// - Markers that don't have a valid lead cell (first column /
/// first row) render as a plain empty cell so the document still
/// parses cleanly.
fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
let rows = table.rows.len();
if rows == 0 {
return Vec::new();
}
let cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
let mut layout: Vec<Vec<CellLayout>> = (0..rows)
.map(|_| {
(0..cols)
.map(|_| CellLayout::Lead {
colspan: 1,
rowspan: 1,
})
.collect()
})
.collect();
for (ri, row) in table.rows.iter().enumerate() {
for (ci, cell) in row.cells.iter().enumerate() {
if cell.col_span {
// Find the lead cell to the left in this row (skipping
// already-skipped continuation slots) and bump it.
let mut k = ci;
while k > 0 {
k -= 1;
match layout[ri][k] {
CellLayout::Lead {
ref mut colspan, ..
} => {
*colspan += 1;
layout[ri][ci] = CellLayout::Skip;
break;
}
CellLayout::Skip => continue,
}
}
} else if cell.row_span {
// Walk up in the same column for the lead cell.
let mut k = ri;
while k > 0 {
k -= 1;
match layout[k][ci] {
CellLayout::Lead {
ref mut rowspan, ..
} => {
*rowspan += 1;
layout[ri][ci] = CellLayout::Skip;
break;
}
CellLayout::Skip => continue,
}
}
}
}
}
layout
}
2026-05-10 19:12:01 +00:00
// ===== Default link resolver =====
/// Default `LinkResolver`. Mirrors the vimwiki link conventions:
2026-05-10 19:12:01 +00:00
/// 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 =====
/// Escape `s` for HTML, but pass through inline tags whose name is in
/// `allowed` (vimwiki `valid_html_tags`) verbatim — mirroring upstream's
/// render-time `s:safe_html_line` regex rather than parsing raw HTML.
fn write_escaped_allowing(s: &str, allowed: &[String], w: &mut dyn Write) -> io::Result<()> {
let bytes = s.as_bytes();
let mut i = 0;
let mut last = 0;
while i < bytes.len() {
if bytes[i] == b'<' {
if let Some(end) = match_allowed_tag(bytes, i, allowed) {
write_escaped(&s[last..i], w)?;
w.write_all(&bytes[i..end])?;
i = end;
last = end;
continue;
}
}
i += 1;
}
write_escaped(&s[last..], w)
}
/// If `bytes[lt]` (`<`) begins a `<tag…>` / `</tag>` whose name is in
/// `allowed`, return the index just past the closing `>`. Otherwise `None`.
fn match_allowed_tag(bytes: &[u8], lt: usize, allowed: &[String]) -> Option<usize> {
let mut p = lt + 1;
if bytes.get(p) == Some(&b'/') {
p += 1;
}
let name_begin = p;
while p < bytes.len() && (bytes[p] as char).is_ascii_alphabetic() {
p += 1;
}
if p == name_begin {
return None;
}
let name = std::str::from_utf8(&bytes[name_begin..p]).ok()?;
if !allowed.iter().any(|t| t.eq_ignore_ascii_case(name)) {
return None;
}
// Scan to the closing `>` (bail on a nested `<` — not a well-formed tag).
while p < bytes.len() && bytes[p] != b'>' {
if bytes[p] == b'<' {
return None;
}
p += 1;
}
if p >= bytes.len() {
return None;
}
Some(p + 1)
}
/// Replace `:alias:` emoji shortcodes with their glyph (vimwiki
/// `emoji_enable`). Only a curated common subset is supported; unknown
/// shortcodes are left untouched.
fn substitute_emoji(s: &str) -> std::borrow::Cow<'_, str> {
if !s.contains(':') {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
let mut changed = false;
while let Some(open) = rest.find(':') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
if let Some(close) = after.find(':') {
let alias = &after[..close];
if !alias.is_empty()
&& alias
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '+' || c == '-')
{
if let Some(glyph) = emoji_for(alias) {
out.push_str(glyph);
rest = &after[close + 1..];
changed = true;
continue;
}
}
}
// Not a shortcode — keep the `:` and continue past it.
out.push(':');
rest = after;
}
out.push_str(rest);
if changed {
std::borrow::Cow::Owned(out)
} else {
std::borrow::Cow::Borrowed(s)
}
}
/// A curated subset of GitHub-style emoji shortcodes.
fn emoji_for(alias: &str) -> Option<&'static str> {
Some(match alias {
"smile" => "😄",
"smiley" => "😃",
"grin" => "😁",
"laughing" | "satisfied" => "😆",
"wink" => "😉",
"blush" => "😊",
"heart" => "❤️",
"broken_heart" => "💔",
"thumbsup" | "+1" => "👍",
"thumbsdown" | "-1" => "👎",
"ok_hand" => "👌",
"wave" => "👋",
"clap" => "👏",
"fire" => "🔥",
"star" => "",
"sparkles" => "",
"zap" => "",
"boom" => "💥",
"tada" => "🎉",
"rocket" => "🚀",
"bulb" => "💡",
"bug" => "🐛",
"warning" => "⚠️",
"white_check_mark" | "check" => "",
"x" => "",
"question" => "",
"exclamation" => "",
"eyes" => "👀",
"100" => "💯",
"pray" => "🙏",
"muscle" => "💪",
"coffee" => "",
"book" => "📖",
"memo" | "pencil" => "📝",
"lock" => "🔒",
"key" => "🔑",
"bell" => "🔔",
"hourglass" => "",
"calendar" => "📅",
_ => return None,
})
}
2026-05-10 19:12:01 +00:00
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
}