ee0309b3c2
named link commands, mouse maps
Parity audit Cluster 1 — small wins.
WikiConfig (server) extended with 14 vimwiki globals:
- `index` (`g:vimwiki_index`), wired into `:VimwikiIndex` so users
with a custom index page stem (e.g. `home`) get the right file.
- `diary_frequency`, `diary_start_week_day`, `diary_caption_level`,
`diary_sort`, `diary_header` — diary keys (weekly/monthly/yearly
semantics land in Cluster 4; the keys parse now so config
migration doesn't need to wait).
- `maxhi`, `listsyms`, `listsyms_propagate`, `list_margin`,
`links_space_char`, `nested_syntaxes`, `auto_toc` — list +
highlight knobs the renderer/handlers can consult.
All keys threaded through `RawWiki` → `WikiConfig::from(RawWiki)`,
defaults centralised in `wiki_defaults()` so `empty()`, `from_root()`,
the legacy single-wiki path, and the `RawWiki` impl stay in sync.
Lua front-door (`lua/nuwiki/config.lua`) now documents the multi-wiki
`wikis = {…}` shape with every accepted per-wiki key — users who
prefer Lua tables no longer have to round-trip through
`init_options` raw JSON.
HTML renderer (`crates/nuwiki-core/src/render/html.rs`):
- New `table_spans()` pre-pass resolves vimwiki's `>` (col_span) and
`\\/` (row_span) continuation markers into proper HTML
`colspan="N"` / `rowspan="N"` attributes on the lead cell. Continuation
cells emit nothing, matching what browsers expect.
- The old behaviour (emitting `class="col-span"` / `class="row-span"`
as visual markers) is gone — replaced with real merging so the
rendered HTML matches the source semantics.
Named ex-commands:
- `:VimwikiNextLink` / `:VimwikiPrevLink` — were keymapped only
(`<Tab>`/`<S-Tab>`); now have explicit `:` commands in both editor
paths, dispatching to new `nuwiki.commands.link_next` / `link_prev`
pure-Vim/Lua helpers.
- `:VimwikiBaddLink` — adds the link target's file to the buffer
list without switching focus. Goes through
`textDocument/definition` and runs `:badd <fname>` on the resolved
URI.
Mouse maps (opt-in via `mappings.mouse = true` in Lua or
`g:nuwiki_mouse_mappings = 1` in Vim):
- `<2-LeftMouse>` follows, `<S-2-LeftMouse>` / `<C-2-LeftMouse>`
follow in split/vsplit, `<MiddleMouse>` adds to buflist,
`<RightMouse>` goes back. Mirrors upstream vimwiki's defaults.
Tests: 7 new in `parity_cluster_1.rs` covering the per-wiki config
defaults + raw JSON parse, the legacy-single-wiki path, colspan
folding into a lead cell with no leftover class markers, rowspan
across rows, no-attrs when the table has no spans, and a sanity
paragraph render. Total 421 Rust tests pass; clippy clean; both
keymap harnesses still green (20 nvim + 12 vim).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
742 lines
26 KiB
Rust
742 lines
26 KiB
Rust
//! 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 (SPEC.md §6.8 + §12.8).
|
|
//!
|
|
//! 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 (Phase 17 ships
|
|
//! `{{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.
|
|
|
|
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 SPEC §12.11 `color_dic`.
|
|
colors: HashMap<String, String>,
|
|
}
|
|
|
|
impl Default for HtmlRenderer {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl HtmlRenderer {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
link_resolver: Arc::new(default_link_resolver),
|
|
template: None,
|
|
vars: HashMap::new(),
|
|
colors: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
impl Renderer for HtmlRenderer {
|
|
fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> {
|
|
if let Some(template) = &self.template {
|
|
let mut body = Vec::new();
|
|
self.render_body(doc, &mut body)?;
|
|
let body_str = std::str::from_utf8(&body)
|
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
|
let title = doc.metadata.title.as_deref().unwrap_or("");
|
|
// Replace `{{content}}` first so its substituted body, which may
|
|
// legitimately contain the literal `{{title}}`, isn't itself
|
|
// rewritten in the next pass.
|
|
let mut out = template.replace("{{content}}", body_str);
|
|
out = out.replace("{{title}}", title);
|
|
for (k, v) in &self.vars {
|
|
let needle = format!("{{{{{k}}}}}");
|
|
out = out.replace(&needle, 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<()> {
|
|
for block in &doc.children {
|
|
self.render_block(block, w)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
|
|
match block {
|
|
BlockNode::Heading(n) => self.render_heading(n, w),
|
|
BlockNode::Paragraph(n) => self.render_paragraph(n, w),
|
|
BlockNode::HorizontalRule(n) => self.render_hr(n, w),
|
|
BlockNode::Blockquote(n) => self.render_blockquote(n, w),
|
|
BlockNode::Preformatted(n) => self.render_preformatted(n, w),
|
|
BlockNode::MathBlock(n) => self.render_math_block(n, w),
|
|
BlockNode::List(n) => self.render_list(n, w),
|
|
BlockNode::DefinitionList(n) => self.render_definition_list(n, w),
|
|
BlockNode::Table(n) => self.render_table(n, w),
|
|
BlockNode::Comment(n) => self.render_comment(n, w),
|
|
BlockNode::Tag(n) => self.render_tag(n, w),
|
|
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")
|
|
}
|
|
|
|
fn render_heading(&self, n: &HeadingNode, w: &mut dyn Write) -> io::Result<()> {
|
|
let level = n.level.clamp(1, 6);
|
|
let class = if n.centered {
|
|
" class=\"centered\""
|
|
} else {
|
|
""
|
|
};
|
|
write!(w, "<h{level}{class}>")?;
|
|
self.render_inlines(&n.children, w)?;
|
|
writeln!(w, "</h{level}>")
|
|
}
|
|
|
|
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<p>")?;
|
|
self.render_inlines(&n.children, w)?;
|
|
w.write_all(b"</p>\n")
|
|
}
|
|
|
|
fn render_hr(&self, _n: &HorizontalRuleNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<hr>\n")
|
|
}
|
|
|
|
fn render_blockquote(&self, n: &BlockquoteNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<blockquote>\n")?;
|
|
for child in &n.children {
|
|
self.render_block(child, w)?;
|
|
}
|
|
w.write_all(b"</blockquote>\n")
|
|
}
|
|
|
|
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
|
|
match &n.language {
|
|
Some(lang) => {
|
|
w.write_all(b"<pre><code class=\"language-")?;
|
|
write_escaped(lang, w)?;
|
|
w.write_all(b"\">")?;
|
|
}
|
|
None => w.write_all(b"<pre><code>")?,
|
|
}
|
|
write_escaped(&n.content, w)?;
|
|
w.write_all(b"</code></pre>\n")
|
|
}
|
|
|
|
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
|
|
// No actual TeX rendering — caller is expected to run MathJax / KaTeX
|
|
// over the wrapped node. The delimiters match vimwiki convention.
|
|
match &n.environment {
|
|
Some(env) => writeln!(
|
|
w,
|
|
"<div class=\"math-block\" data-env=\"{}\">\\begin{{{env}}}",
|
|
escape(env)
|
|
)?,
|
|
None => w.write_all(b"<div class=\"math-block\">\\[\n")?,
|
|
}
|
|
write_escaped(&n.content, w)?;
|
|
match &n.environment {
|
|
Some(env) => write!(w, "\n\\end{{{env}}}</div>\n", env = escape(env))?,
|
|
None => w.write_all(b"\n\\]</div>\n")?,
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn render_list(&self, n: &ListNode, w: &mut dyn Write) -> io::Result<()> {
|
|
let tag = if n.ordered { "ol" } else { "ul" };
|
|
writeln!(w, "<{tag}>")?;
|
|
for item in &n.items {
|
|
self.render_list_item(item, w)?;
|
|
}
|
|
writeln!(w, "</{tag}>")
|
|
}
|
|
|
|
fn render_list_item(&self, n: &ListItemNode, w: &mut dyn Write) -> io::Result<()> {
|
|
match n.checkbox {
|
|
Some(state) => {
|
|
let (checked, class) = match state {
|
|
CheckboxState::Empty => ("", "task-empty"),
|
|
CheckboxState::Quarter => ("", "task-quarter"),
|
|
CheckboxState::Half => ("", "task-half"),
|
|
CheckboxState::ThreeQuarters => ("", "task-three-quarters"),
|
|
CheckboxState::Done => (" checked", "task-done"),
|
|
CheckboxState::Rejected => ("", "task-rejected"),
|
|
};
|
|
write!(
|
|
w,
|
|
"<li class=\"{class}\"><input type=\"checkbox\" disabled{checked}> "
|
|
)?;
|
|
}
|
|
None => w.write_all(b"<li>")?,
|
|
}
|
|
self.render_inlines(&n.children, w)?;
|
|
if let Some(sublist) = &n.sublist {
|
|
w.write_all(b"\n")?;
|
|
self.render_list(sublist, w)?;
|
|
}
|
|
w.write_all(b"</li>\n")
|
|
}
|
|
|
|
fn render_definition_list(&self, n: &DefinitionListNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<dl>\n")?;
|
|
for item in &n.items {
|
|
self.render_definition_item(item, w)?;
|
|
}
|
|
w.write_all(b"</dl>\n")
|
|
}
|
|
|
|
fn render_definition_item(&self, n: &DefinitionItemNode, w: &mut dyn Write) -> io::Result<()> {
|
|
if let Some(term) = &n.term {
|
|
w.write_all(b"<dt>")?;
|
|
self.render_inlines(term, w)?;
|
|
w.write_all(b"</dt>\n")?;
|
|
}
|
|
for def in &n.definitions {
|
|
w.write_all(b"<dd>")?;
|
|
self.render_inlines(def, w)?;
|
|
w.write_all(b"</dd>\n")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> {
|
|
// 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, 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>],
|
|
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 } => {
|
|
self.render_table_cell(cell, n.is_header, colspan, rowspan, w)?;
|
|
}
|
|
}
|
|
}
|
|
w.write_all(b"</tr>\n")
|
|
}
|
|
|
|
fn render_table_cell(
|
|
&self,
|
|
n: &TableCellNode,
|
|
is_header: bool,
|
|
colspan: u32,
|
|
rowspan: u32,
|
|
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}\"")?;
|
|
}
|
|
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(())
|
|
}
|
|
|
|
fn render_inline(&self, n: &InlineNode, w: &mut dyn Write) -> io::Result<()> {
|
|
match n {
|
|
InlineNode::Text(t) => self.render_text(t, w),
|
|
InlineNode::Bold(t) => self.render_bold(t, w),
|
|
InlineNode::Italic(t) => self.render_italic(t, w),
|
|
InlineNode::BoldItalic(t) => self.render_bold_italic(t, w),
|
|
InlineNode::Strikethrough(t) => self.render_strikethrough(t, w),
|
|
InlineNode::Code(t) => self.render_code(t, w),
|
|
InlineNode::Superscript(t) => self.render_superscript(t, w),
|
|
InlineNode::Subscript(t) => self.render_subscript(t, w),
|
|
InlineNode::MathInline(t) => self.render_math_inline(t, w),
|
|
InlineNode::Keyword(t) => self.render_keyword(t, w),
|
|
InlineNode::Color(t) => self.render_color(t, w),
|
|
InlineNode::WikiLink(t) => self.render_wikilink(t, w),
|
|
InlineNode::ExternalLink(t) => self.render_external_link(t, w),
|
|
InlineNode::Transclusion(t) => self.render_transclusion(t, w),
|
|
InlineNode::RawUrl(t) => self.render_raw_url(t, w),
|
|
}
|
|
}
|
|
|
|
fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> {
|
|
write_escaped(&n.content, w)
|
|
}
|
|
|
|
fn render_bold(&self, n: &BoldNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<strong>")?;
|
|
self.render_inlines(&n.children, w)?;
|
|
w.write_all(b"</strong>")
|
|
}
|
|
|
|
fn render_italic(&self, n: &ItalicNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<em>")?;
|
|
self.render_inlines(&n.children, w)?;
|
|
w.write_all(b"</em>")
|
|
}
|
|
|
|
fn render_bold_italic(&self, n: &BoldItalicNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<strong><em>")?;
|
|
self.render_inlines(&n.children, w)?;
|
|
w.write_all(b"</em></strong>")
|
|
}
|
|
|
|
fn render_strikethrough(&self, n: &StrikethroughNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<del>")?;
|
|
self.render_inlines(&n.children, w)?;
|
|
w.write_all(b"</del>")
|
|
}
|
|
|
|
fn render_code(&self, n: &CodeNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<code>")?;
|
|
write_escaped(&n.content, w)?;
|
|
w.write_all(b"</code>")
|
|
}
|
|
|
|
fn render_superscript(&self, n: &SuperscriptNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<sup>")?;
|
|
self.render_inlines(&n.children, w)?;
|
|
w.write_all(b"</sup>")
|
|
}
|
|
|
|
fn render_subscript(&self, n: &SubscriptNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<sub>")?;
|
|
self.render_inlines(&n.children, w)?;
|
|
w.write_all(b"</sub>")
|
|
}
|
|
|
|
fn render_math_inline(&self, n: &MathInlineNode, w: &mut dyn Write) -> io::Result<()> {
|
|
w.write_all(b"<span class=\"math-inline\">\\(")?;
|
|
write_escaped(&n.content, w)?;
|
|
w.write_all(b"\\)</span>")
|
|
}
|
|
|
|
fn render_keyword(&self, n: &KeywordNode, w: &mut dyn Write) -> io::Result<()> {
|
|
let (label, class) = match n.keyword {
|
|
Keyword::Todo => ("TODO", "keyword keyword-todo"),
|
|
Keyword::Done => ("DONE", "keyword keyword-done"),
|
|
Keyword::Started => ("STARTED", "keyword keyword-started"),
|
|
Keyword::Fixme => ("FIXME", "keyword keyword-fixme"),
|
|
Keyword::Fixed => ("FIXED", "keyword keyword-fixed"),
|
|
Keyword::Xxx => ("XXX", "keyword keyword-xxx"),
|
|
};
|
|
write!(w, "<span class=\"{class}\">{label}</span>")
|
|
}
|
|
|
|
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
|
|
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 conventions in SPEC.md §9:
|
|
/// wiki pages become `path.html`; interwiki links land in sibling
|
|
/// directories; diary entries land under `diary/`; file/local schemes
|
|
/// use their literal path; anchor-only links collapse to a fragment.
|
|
pub fn default_link_resolver(target: &LinkTarget) -> String {
|
|
if matches!(target.kind, LinkKind::AnchorOnly) {
|
|
return match &target.anchor {
|
|
Some(a) => format!("#{a}"),
|
|
None => String::new(),
|
|
};
|
|
}
|
|
|
|
let path = target.path.as_deref().unwrap_or("");
|
|
let mut out = String::new();
|
|
|
|
match target.kind {
|
|
LinkKind::Wiki => {
|
|
if target.is_absolute {
|
|
out.push('/');
|
|
}
|
|
out.push_str(path);
|
|
if target.is_directory {
|
|
out.push('/');
|
|
} else if !path.is_empty() {
|
|
out.push_str(".html");
|
|
}
|
|
}
|
|
LinkKind::Interwiki => {
|
|
if let Some(idx) = target.wiki_index {
|
|
out.push_str(&format!("../wiki{idx}/"));
|
|
} else if let Some(name) = &target.wiki_name {
|
|
out.push_str(&format!("../wn-{name}/"));
|
|
}
|
|
out.push_str(path);
|
|
if !target.is_directory {
|
|
out.push_str(".html");
|
|
}
|
|
}
|
|
LinkKind::Diary => {
|
|
out.push_str("diary/");
|
|
out.push_str(path);
|
|
out.push_str(".html");
|
|
}
|
|
LinkKind::File => {
|
|
out.push_str(path);
|
|
}
|
|
LinkKind::Local => {
|
|
out.push_str("file://");
|
|
if target.is_absolute && !path.starts_with('/') {
|
|
out.push('/');
|
|
}
|
|
out.push_str(path);
|
|
}
|
|
LinkKind::Raw => {
|
|
out.push_str(path);
|
|
}
|
|
LinkKind::AnchorOnly => unreachable!(),
|
|
}
|
|
|
|
if let Some(anchor) = &target.anchor {
|
|
out.push('#');
|
|
out.push_str(anchor);
|
|
}
|
|
out
|
|
}
|
|
|
|
// ===== HTML escaping =====
|
|
|
|
fn write_escaped(s: &str, w: &mut dyn Write) -> io::Result<()> {
|
|
let bytes = s.as_bytes();
|
|
let mut last = 0;
|
|
for (i, &b) in bytes.iter().enumerate() {
|
|
let replacement: &[u8] = match b {
|
|
b'<' => b"<",
|
|
b'>' => b">",
|
|
b'&' => b"&",
|
|
b'"' => b""",
|
|
b'\'' => b"'",
|
|
_ => continue,
|
|
};
|
|
if i > last {
|
|
w.write_all(&bytes[last..i])?;
|
|
}
|
|
w.write_all(replacement)?;
|
|
last = i + 1;
|
|
}
|
|
if last < bytes.len() {
|
|
w.write_all(&bytes[last..])?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn escape(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
for c in s.chars() {
|
|
match c {
|
|
'<' => out.push_str("<"),
|
|
'>' => out.push_str(">"),
|
|
'&' => out.push_str("&"),
|
|
'"' => out.push_str("""),
|
|
'\'' => out.push_str("'"),
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
out
|
|
}
|