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:
@@ -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>")?,
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -3048,6 +3048,7 @@ pub mod export_ops {
|
||||
name,
|
||||
DiaryDate::today_utc(),
|
||||
&cfg.html,
|
||||
Some(cfg.file_extension.as_str()),
|
||||
std::collections::HashMap::new(),
|
||||
)?;
|
||||
let out_path = output_path_for(&cfg.html, name);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode, HeadingNode, InlineNode};
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode, HeadingNode, InlineNode, LinkKind};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
|
||||
@@ -188,12 +188,18 @@ pub fn is_excluded(patterns: &[String], name: &str) -> bool {
|
||||
/// Pure HTML render pass. The caller has already loaded the template
|
||||
/// (or falls back). Wires up `{{date}}`, `{{root_path}}`, `{{toc}}`
|
||||
/// plus arbitrary extras passed via `extra_vars`.
|
||||
///
|
||||
/// `wiki_extension` is the wiki's source file extension (e.g. `".wiki"`).
|
||||
/// When set, it is stripped from wiki/interwiki link targets so a link
|
||||
/// written with the extension (`[[todo.wiki]]`) exports to `todo.html`,
|
||||
/// matching how the LSP resolves the same link for in-editor navigation.
|
||||
pub fn render_page_html(
|
||||
doc: &DocumentNode,
|
||||
template: Option<String>,
|
||||
name: &str,
|
||||
today: DiaryDate,
|
||||
cfg: &HtmlConfig,
|
||||
wiki_extension: Option<&str>,
|
||||
extra_vars: HashMap<String, String>,
|
||||
) -> std::io::Result<String> {
|
||||
let date_str = match doc.metadata.date.as_deref() {
|
||||
@@ -211,11 +217,30 @@ pub fn render_page_html(
|
||||
vars.insert("root_path".into(), root_path);
|
||||
vars.insert("toc".into(), toc);
|
||||
vars.insert("css".into(), cfg.css_name.clone());
|
||||
// vimwiki names the stylesheet placeholder `%wiki_css%`; alias it so stock
|
||||
// templates resolve. nuwiki's own templates use `{{css}}`.
|
||||
vars.insert("wiki_css".into(), cfg.css_name.clone());
|
||||
for (k, v) in extra_vars {
|
||||
vars.insert(k, v);
|
||||
}
|
||||
|
||||
let mut r = HtmlRenderer::new();
|
||||
if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) {
|
||||
// Strip the wiki extension from page links before the default resolver
|
||||
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
|
||||
// than `todo.wiki.html`. Only wiki/interwiki targets are touched;
|
||||
// file:/local: paths keep their literal extension.
|
||||
let ext = ext.to_string();
|
||||
r = r.with_link_resolver(move |target| {
|
||||
let mut t = target.clone();
|
||||
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
|
||||
if let Some(p) = t.path.take() {
|
||||
t.path = Some(crate::index::strip_wiki_extension(&p, Some(&ext)).to_string());
|
||||
}
|
||||
}
|
||||
nuwiki_core::render::html::default_link_resolver(&t)
|
||||
});
|
||||
}
|
||||
if let Some(t) = template {
|
||||
r = r.with_template(t);
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ fn render_page_html_substitutes_title_content_and_extras() {
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
extras,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -260,6 +261,7 @@ fn render_page_html_uses_metadata_date_when_set() {
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -276,6 +278,7 @@ fn render_page_html_root_path_for_nested_pages() {
|
||||
"diary/2026-05-11",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -294,12 +297,64 @@ fn render_page_html_extra_var_overrides_default() {
|
||||
"H",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
extras,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("D=OVERRIDE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_substitutes_vimwiki_percent_template() {
|
||||
// A stock vimwiki template (%title%/%content%/%root_path%/%wiki_css%/%date%)
|
||||
// must render fully — every placeholder resolved, no literal `%…%` left.
|
||||
let ast = parse("%title My Page\n= One =\nbody text\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let template = "<title>%title%</title>\
|
||||
<link href=\"%root_path%%wiki_css%\">\
|
||||
<body>%content%</body><footer>%date%</footer>";
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some(template.into()),
|
||||
"diary/2026-05-11",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<title>My Page</title>"), "title: {html}");
|
||||
assert!(html.contains("body text"), "content: {html}");
|
||||
assert!(
|
||||
html.contains("href=\"../style.css\""),
|
||||
"root_path+css: {html}"
|
||||
);
|
||||
assert!(html.contains("<footer>2026-05-11</footer>"), "date: {html}");
|
||||
assert!(!html.contains('%'), "placeholder left behind: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_strips_wiki_extension_from_links() {
|
||||
// A link written with the extension (`[[todo.wiki]]`) must export to
|
||||
// `todo.html`, not `todo.wiki.html` — matching in-editor navigation.
|
||||
let ast = parse("[[todo.wiki|Tasks]] and [[posts/index|Posts]]\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("{{content}}".into()),
|
||||
"index",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
Some(".wiki"),
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("href=\"todo.html\""), "stripped: {html}");
|
||||
assert!(!html.contains("todo.wiki.html"), "not double-ext: {html}");
|
||||
// A link without the extension is unaffected.
|
||||
assert!(html.contains("href=\"posts/index.html\""), "plain: {html}");
|
||||
}
|
||||
|
||||
// ===== fallback_template + DEFAULT_CSS =====
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user