//! 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. //! //! 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. use std::collections::HashMap; 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, }; 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, vars: HashMap, /// `color_dic`-style override: vimwiki colour-tag names → CSS /// values (`"red"` / `"#ffe119"` / `"oklch(…)"`). Unspecified /// names fall through to the default `class="color-"` /// rendering. Matches vimwiki's `color_dic`. colors: HashMap, /// vimwiki `color_tag_template`: the HTML emitted for a colour span whose /// name resolves in `colors`. `__STYLE__` expands to the inline style /// (`color:`) and `__CONTENT__` to the rendered inner HTML. The /// default matches upstream's ``. color_template: 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 `toc_header`: the heading text that identifies the TOC /// section. A heading matching this value (case-insensitive) is wrapped /// in `
` on export, matching upstream so vimwiki's /// stylesheet applies. toc_header: 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, /// 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` → `
`. text_ignore_newline: bool, /// vimwiki `list_ignore_newline` (default `true`): a soft break in a list /// item renders as a space; `false` → `
`. list_ignore_newline: bool, } 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(), color_template: "__CONTENT__".to_string(), header_numbering: 0, header_numbering_sym: String::new(), toc_header: String::new(), valid_html_tags: Vec::new(), emoji_enable: false, text_ignore_newline: true, list_ignore_newline: true, } } /// Set vimwiki `text_ignore_newline` / `list_ignore_newline`. When `false`, /// a soft line break in a paragraph / list item renders as `
` /// 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) -> 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 } /// 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 automatically; additional `{{key}}` placeholders /// resolve via `with_var` / `with_vars`. Anything unresolved passes /// through verbatim. pub fn with_template(mut self, template: impl Into) -> 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, value: impl Into) -> Self { self.vars.insert(key.into(), value.into()); self } /// Replace the entire template variable map. pub fn with_vars(mut self, vars: HashMap) -> 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-"`. pub fn with_colors(mut self, colors: HashMap) -> Self { self.colors = colors; self } /// Override the colour-span template (vimwiki `color_tag_template`). /// `__STYLE__` is replaced with the resolved `color:` style and /// `__CONTENT__` with the rendered inner HTML. An empty string keeps the /// built-in default. pub fn with_color_template(mut self, template: impl Into) -> Self { let template = template.into(); if !template.is_empty() { self.color_template = template; } 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) -> Self { self.header_numbering = level; self.header_numbering_sym = sym.into(); self } /// Set the vimwiki TOC heading text. When a heading matches this value /// (case-insensitive) it gets `class="toc"` in HTML export. pub fn with_toc_header(mut self, header: impl Into) -> Self { self.toc_header = header.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 1–6, /// 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` (1–6) 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::>() .join("."); format!("{num}{} ", self.sym) } } 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())?; } 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); // Stack of (level, anchor) for the headings enclosing the current one, // so each heading can carry vimwiki's hierarchical id (the parent // anchors joined by `-`, e.g. `Title-Section`). let mut ancestors: Vec<(u8, String)> = Vec::new(); for block in &doc.children { if let BlockNode::Heading(n) = block { let level = n.level.clamp(1, 6); let number = numberer.prefix(level); let anchor = crate::ast::inline_text(&n.children); while ancestors.last().is_some_and(|(l, _)| *l >= level) { ancestors.pop(); } let mut hier = String::new(); for (_, a) in &ancestors { hier.push_str(a); hier.push('-'); } hier.push_str(&anchor); ancestors.push((level, anchor)); self.render_heading(n, &number, &hier, w)?; } else { 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), BlockNode::Error(n) => self.render_error(n, w), } } fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> { // `id="…"` (bare tag name, matching vimwiki) lets `[[Page#name]]` jump // to the rendered tag. w.write_all(b"
")?; for (i, name) in n.tags.iter().enumerate() { if i > 0 { w.write_all(b" ")?; } w.write_all(b"")?; write_escaped(name, w)?; w.write_all(b"")?; } w.write_all(b"
\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. `hier_id` is the hierarchical anchor id /// (parent anchors joined by `-`); pass `""` to default it to the flat id. fn render_heading( &self, n: &HeadingNode, number: &str, hier_id: &str, w: &mut dyn Write, ) -> io::Result<()> { let level = n.level.clamp(1, 6); let anchor = crate::ast::inline_text(&n.children); // Inside a heading, render TODO/DONE/… as plain text rather than a // keyword badge — a section title that *is* a keyword (`== TODO ==`) // should look like a header, not an inline tag. (The keyword text is // still part of `anchor` above, so the id stays correct.) let body = flatten_keywords(&n.children); // The TOC section heading (vimwiki `toc_header`) is wrapped in a // `
`, matching upstream so vimwiki's `.toc { … }` // stylesheet rules apply. Kept in its simple form. let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header); if is_toc { w.write_all(b"
")?; write!(w, "")?; if !number.is_empty() { write_escaped(number, w)?; } self.render_inlines(&body, w)?; write!(w, "
")?; return writeln!(w); } // Centered headings keep their `class="centered"` form. if n.centered { write!(w, "")?; if !number.is_empty() { write_escaped(number, w)?; } self.render_inlines(&body, w)?; write!(w, "")?; return writeln!(w); } // Regular heading: vimwiki's structure — a wrapping `
` carrying the // hierarchical id, `class="header"` on the heading, and an in-heading // self-anchor linking to that id. The heading keeps the flat id so // existing intra-page TOC / `#anchor` links still resolve. let hier = if hier_id.is_empty() { &anchor } else { hier_id }; w.write_all(b"")?; writeln!(w) } fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"

")?; let sb = if self.text_ignore_newline { " " } else { "
" }; self.render_inlines_break(&n.children, sb, 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<()> { // vimwiki drops the verbatim fence text into the `
` tag as raw
        // attributes (`{{{python` → `
`), with no inner ``.
        // We mirror that so highlighters wired for vimwiki output still apply.
        if n.attrs.is_empty() {
            w.write_all(b"
")?;
        } else {
            write!(w, "
", n.attrs)?;
        }
        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) => { // 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 ``, // 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", }; write!(w, "
  • ")?; } None => w.write_all(b"
  • ")?, } let sb = if self.list_ignore_newline { " " } else { "
    " }; self.render_inlines_break(&n.children, sb, 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<()> { // 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); w.write_all(b"\n")?; let mut in_thead = false; let mut in_tbody = false; for (row_idx, row) in n.rows.iter().enumerate() { 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, row_idx, &layout, &n.alignments, 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, row_idx: usize, layout: &[Vec], alignments: &[crate::ast::TableAlign], w: &mut dyn Write, ) -> io::Result<()> { w.write_all(b"")?; 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)?; } } } w.write_all(b"\n") } fn render_table_cell( &self, n: &TableCellNode, is_header: bool, colspan: u32, rowspan: u32, align: crate::ast::TableAlign, 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, ">")?; 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(()) } /// Like [`render_inlines`] but renders top-level soft breaks as `soft_break` /// (a space or `
    `). 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(()) } 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 `
    ` support. InlineNode::SoftBreak(_) => w.write_all(b" "), } } 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) } } 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<()> { // 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. let class = match n.keyword { Keyword::Todo => "todo", Keyword::Done => "done", Keyword::Started => "started", Keyword::Fixme => "fixme", Keyword::Fixed => "fixed", Keyword::Xxx => "xxx", Keyword::Stopped => "stopped", }; write!(w, "{}", n.keyword.label()) } fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> { // Render the inner content up front so it can be slotted into the // colour template. let mut content = Vec::new(); self.render_inlines(&n.children, &mut content)?; let content = String::from_utf8_lossy(&content); if let Some(css) = self.colors.get(&n.color) { // Known colour name: expand `color_tag_template` with the resolved // inline style and rendered content. let mut style = b"color:".to_vec(); write_escaped(css, &mut style)?; let style = String::from_utf8_lossy(&style); let html = self .color_template .replace("__STYLE__", &style) .replace("__CONTENT__", &content); w.write_all(html.as_bytes()) } else { // Unknown name: fall back to a class hook the stylesheet can target // (the template only models the inline-style case). w.write_all(b"")?; w.write_all(content.as_bytes())?; 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"") } } // ===== 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> { 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> = (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 } // ===== Default link resolver ===== /// Default `LinkResolver`. Mirrors the vimwiki link conventions: /// 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. /// Return a copy of `nodes` with every `Keyword` replaced by its literal text. /// Used for heading content so a keyword in a title renders as plain text (with /// the heading's own styling) rather than as an inline keyword badge. fn flatten_keywords(nodes: &[InlineNode]) -> Vec { nodes .iter() .map(|n| match n { InlineNode::Keyword(k) => InlineNode::Text(TextNode { span: k.span, content: k.keyword.label().to_string(), }), InlineNode::Bold(b) => InlineNode::Bold(BoldNode { span: b.span, children: flatten_keywords(&b.children), }), InlineNode::Italic(i) => InlineNode::Italic(ItalicNode { span: i.span, children: flatten_keywords(&i.children), }), InlineNode::BoldItalic(b) => InlineNode::BoldItalic(BoldItalicNode { span: b.span, children: flatten_keywords(&b.children), }), InlineNode::Strikethrough(s) => InlineNode::Strikethrough(StrikethroughNode { span: s.span, children: flatten_keywords(&s.children), }), InlineNode::Superscript(s) => InlineNode::Superscript(SuperscriptNode { span: s.span, children: flatten_keywords(&s.children), }), InlineNode::Subscript(s) => InlineNode::Subscript(SubscriptNode { span: s.span, children: flatten_keywords(&s.children), }), InlineNode::Color(c) => InlineNode::Color(ColorNode { span: c.span, color: c.color.clone(), children: flatten_keywords(&c.children), }), other => other.clone(), }) .collect() } 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 `` / `` 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 { 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, }) } 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 }