Files
nuwiki/crates/nuwiki-core/src/render/html.rs
T
gffranco e6e3f70dd2
CI / cargo fmt --check (push) Failing after 22s
CI / cargo clippy (push) Successful in 22s
CI / cargo test (push) Successful in 36s
CI / editor keymaps (push) Successful in 1m26s
feat(render): list_ignore_newline / text_ignore_newline = false → <br>
Implements the deferred half of these options: a soft line break inside a
paragraph / list item can render as <br /> instead of a space.

- Parser: soft breaks now emit a dedicated InlineNode::SoftBreak node
  (parse_inline_seq K::Newline + the list-continuation synthetic join) instead
  of collapsing to Text(" "). Added SoftBreakNode + the variant.
- All inline consumers treat SoftBreak as whitespace: AST visitor (no-op),
  text extraction (index.rs + lib.rs → space), nav/semantic_tokens span_of_inline,
  semantic emit (skip like Text), parser span_of_inline.
- Renderer: render_inlines_break renders top-level SoftBreaks as the
  context-specific string; render_paragraph uses text_ignore_newline,
  render_list_item uses list_ignore_newline; default render_inline → space.
  with_newline_handling builder; export.rs wires both from HtmlConfig.

Default (true/true = upstream default) behaviour is unchanged (space). Updated
3 core tests + 1 parser test that asserted the old Text(" ") shape to treat
SoftBreak as whitespace. New test render_page_html_ignore_newline_controls_soft_breaks.

Gap doc: P3 Config item closed — only the deferred markdown cluster remains.
Full rust suite + clippy clean; Neovim 307, Vim 301/18/21.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:06:38 +00:00

1066 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<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,
}
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,
}
}
/// 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
}
/// 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.
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)
}
}
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);
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)?;
}
}
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="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<()> {
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)?;
}
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)?;
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) => {
// 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",
};
write!(w, "<li class=\"{class}\">")?;
}
None => w.write_all(b"<li>")?,
}
let sb = if self.list_ignore_newline { " " } else { "<br />" };
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"</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);
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() {
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)?;
}
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<()> {
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)?;
}
}
}
w.write_all(b"</tr>\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, "</{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(())
}
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" "),
}
}
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"<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.
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"),
Keyword::Stopped => ("STOPPED", "stopped"),
};
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"\">")?;
}
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
}
// ===== 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.
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,
})
}
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
}