fix(export): render vimwiki-compatible HTML for templates, links, and tasks

Stock vimwiki templates and stylesheets rendered broken pages because the
HTML output diverged from vimwiki conventions on three fronts:

- Templates: the renderer only substituted nuwiki's `{{key}}` placeholders,
  so vimwiki's `%title%`/`%content%`/`%root_path%`/`%date%`/`%wiki_css%`
  passed through literally — dropping the body and breaking asset links.
  Both delimiter styles are now recognised; `%wiki_css%` aliases the css var.
- Links: `[[todo.wiki]]` exported to `todo.wiki.html`. render_page_html now
  takes the wiki file extension and strips it from wiki/interwiki targets
  (via index::strip_wiki_extension) before the `.html` URL is built, matching
  in-editor navigation. file:/local: links keep their literal extension.
- Tasks: checkbox items used bespoke `task-*` classes plus an `<input>`,
  which double-rendered against vimwiki stylesheets. Emit `done0..done4`
  (`[ ] [.] [o] [O] [X]`) and `rejected` (`[-]`) classes with no input — the
  stylesheet draws the box.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 21:19:55 -03:00
parent 7f6d811a47
commit e90bbab39e
5 changed files with 142 additions and 20 deletions
+26 -16
View File
@@ -11,6 +11,11 @@
//! `{{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};
@@ -107,14 +112,17 @@ impl Renderer for HtmlRenderer {
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.
// 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 {
let needle = format!("{{{{{k}}}}}");
out = out.replace(&needle, v);
out = out.replace(&format!("{{{{{k}}}}}"), v);
out = out.replace(&format!("%{k}%"), v);
}
w.write_all(out.as_bytes())?;
} else {
@@ -239,18 +247,20 @@ impl HtmlRenderer {
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"),
// 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}\"><input type=\"checkbox\" disabled{checked}> "
)?;
write!(w, "<li class=\"{class}\">")?;
}
None => w.write_all(b"<li>")?,
}
+34 -3
View File
@@ -112,9 +112,23 @@ fn math_block_emits_div() {
fn unordered_list_with_checkbox() {
let out = render("- [X] done\n- not done\n");
assert!(out.contains("<ul>"));
assert!(out.contains("<input type=\"checkbox\" disabled checked>"));
assert!(out.contains("done</li>"));
assert!(out.contains("not done</li>"));
// vimwiki-compatible: class on the <li>, no <input> (the stylesheet draws it).
assert!(out.contains("<li class=\"done4\">done</li>"), "{out}");
assert!(out.contains("<li>not done</li>"), "{out}");
assert!(!out.contains("<input"), "no input element: {out}");
}
#[test]
fn checkbox_states_use_vimwiki_done_classes() {
let out = render("- [ ] a\n- [.] b\n- [o] c\n- [O] d\n- [X] e\n- [-] f\n");
assert!(out.contains("<li class=\"done0\">a</li>"), "{out}");
assert!(out.contains("<li class=\"done1\">b</li>"), "{out}");
assert!(out.contains("<li class=\"done2\">c</li>"), "{out}");
assert!(out.contains("<li class=\"done3\">d</li>"), "{out}");
assert!(out.contains("<li class=\"done4\">e</li>"), "{out}");
assert!(out.contains("<li class=\"rejected\">f</li>"), "{out}");
assert!(!out.contains("task-"), "no legacy task-* classes: {out}");
assert!(!out.contains("<input"), "no input elements: {out}");
}
#[test]
@@ -284,6 +298,23 @@ fn template_with_missing_title_substitutes_empty_string() {
assert!(out.contains("<h1>Heading</h1>"));
}
#[test]
fn template_substitutes_vimwiki_percent_placeholders() {
// Stock vimwiki templates use `%title%` / `%content%` / `%root_path%`
// rather than nuwiki's `{{…}}`. Both must resolve.
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
let renderer = HtmlRenderer::new()
.with_template(
"<title>%title%</title><link href=\"%root_path%style.css\"><body>%content%</body>",
)
.with_var("root_path", "../");
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>"), "title: {out}");
assert!(out.contains("<body><h1>Hello</h1>"), "content: {out}");
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
assert!(!out.contains('%'), "no placeholder left: {out}");
}
// ===== End-to-end smoke =====
#[test]