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
|
//! `{{date}}`, `{{root_path}}`, `{{toc}}`). Order: `{{content}}` is
|
||||||
//! substituted first so a body that happens to contain a literal
|
//! substituted first so a body that happens to contain a literal
|
||||||
//! `{{title}}` isn't itself rewritten in the next pass.
|
//! `{{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::collections::HashMap;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
@@ -107,14 +112,17 @@ impl Renderer for HtmlRenderer {
|
|||||||
let body_str = std::str::from_utf8(&body)
|
let body_str = std::str::from_utf8(&body)
|
||||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
let title = doc.metadata.title.as_deref().unwrap_or("");
|
let title = doc.metadata.title.as_deref().unwrap_or("");
|
||||||
// Replace `{{content}}` first so its substituted body, which may
|
// Substitute `content` before `title` so a body that legitimately
|
||||||
// legitimately contain the literal `{{title}}`, isn't itself
|
// contains a literal title placeholder isn't rewritten. Both the
|
||||||
// rewritten in the next pass.
|
// native `{{key}}` and vimwiki's `%key%` delimiters are accepted so
|
||||||
|
// stock vimwiki templates render unchanged.
|
||||||
let mut out = template.replace("{{content}}", body_str);
|
let mut out = template.replace("{{content}}", body_str);
|
||||||
|
out = out.replace("%content%", body_str);
|
||||||
out = out.replace("{{title}}", title);
|
out = out.replace("{{title}}", title);
|
||||||
|
out = out.replace("%title%", title);
|
||||||
for (k, v) in &self.vars {
|
for (k, v) in &self.vars {
|
||||||
let needle = format!("{{{{{k}}}}}");
|
out = out.replace(&format!("{{{{{k}}}}}"), v);
|
||||||
out = out.replace(&needle, v);
|
out = out.replace(&format!("%{k}%"), v);
|
||||||
}
|
}
|
||||||
w.write_all(out.as_bytes())?;
|
w.write_all(out.as_bytes())?;
|
||||||
} else {
|
} else {
|
||||||
@@ -239,18 +247,20 @@ impl HtmlRenderer {
|
|||||||
fn render_list_item(&self, n: &ListItemNode, w: &mut dyn Write) -> io::Result<()> {
|
fn render_list_item(&self, n: &ListItemNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
match n.checkbox {
|
match n.checkbox {
|
||||||
Some(state) => {
|
Some(state) => {
|
||||||
let (checked, class) = match state {
|
// vimwiki-compatible checkbox classes: `[ ]`→done0, `[.]`→done1,
|
||||||
CheckboxState::Empty => ("", "task-empty"),
|
// `[o]`→done2, `[O]`→done3, `[X]`→done4, `[-]`→rejected. The
|
||||||
CheckboxState::Quarter => ("", "task-quarter"),
|
// checkbox glyph/progress fill is drawn entirely by the
|
||||||
CheckboxState::Half => ("", "task-half"),
|
// stylesheet (`li.doneN::before`); vimwiki emits no `<input>`,
|
||||||
CheckboxState::ThreeQuarters => ("", "task-three-quarters"),
|
// so neither do we — an input would double-render against it.
|
||||||
CheckboxState::Done => (" checked", "task-done"),
|
let class = match state {
|
||||||
CheckboxState::Rejected => ("", "task-rejected"),
|
CheckboxState::Empty => "done0",
|
||||||
|
CheckboxState::Quarter => "done1",
|
||||||
|
CheckboxState::Half => "done2",
|
||||||
|
CheckboxState::ThreeQuarters => "done3",
|
||||||
|
CheckboxState::Done => "done4",
|
||||||
|
CheckboxState::Rejected => "rejected",
|
||||||
};
|
};
|
||||||
write!(
|
write!(w, "<li class=\"{class}\">")?;
|
||||||
w,
|
|
||||||
"<li class=\"{class}\"><input type=\"checkbox\" disabled{checked}> "
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
None => w.write_all(b"<li>")?,
|
None => w.write_all(b"<li>")?,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,9 +112,23 @@ fn math_block_emits_div() {
|
|||||||
fn unordered_list_with_checkbox() {
|
fn unordered_list_with_checkbox() {
|
||||||
let out = render("- [X] done\n- not done\n");
|
let out = render("- [X] done\n- not done\n");
|
||||||
assert!(out.contains("<ul>"));
|
assert!(out.contains("<ul>"));
|
||||||
assert!(out.contains("<input type=\"checkbox\" disabled checked>"));
|
// vimwiki-compatible: class on the <li>, no <input> (the stylesheet draws it).
|
||||||
assert!(out.contains("done</li>"));
|
assert!(out.contains("<li class=\"done4\">done</li>"), "{out}");
|
||||||
assert!(out.contains("not done</li>"));
|
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]
|
#[test]
|
||||||
@@ -284,6 +298,23 @@ fn template_with_missing_title_substitutes_empty_string() {
|
|||||||
assert!(out.contains("<h1>Heading</h1>"));
|
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 =====
|
// ===== End-to-end smoke =====
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -3048,6 +3048,7 @@ pub mod export_ops {
|
|||||||
name,
|
name,
|
||||||
DiaryDate::today_utc(),
|
DiaryDate::today_utc(),
|
||||||
&cfg.html,
|
&cfg.html,
|
||||||
|
Some(cfg.file_extension.as_str()),
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
)?;
|
)?;
|
||||||
let out_path = output_path_for(&cfg.html, name);
|
let out_path = output_path_for(&cfg.html, name);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
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::date::DiaryDate;
|
||||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
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
|
/// Pure HTML render pass. The caller has already loaded the template
|
||||||
/// (or falls back). Wires up `{{date}}`, `{{root_path}}`, `{{toc}}`
|
/// (or falls back). Wires up `{{date}}`, `{{root_path}}`, `{{toc}}`
|
||||||
/// plus arbitrary extras passed via `extra_vars`.
|
/// 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(
|
pub fn render_page_html(
|
||||||
doc: &DocumentNode,
|
doc: &DocumentNode,
|
||||||
template: Option<String>,
|
template: Option<String>,
|
||||||
name: &str,
|
name: &str,
|
||||||
today: DiaryDate,
|
today: DiaryDate,
|
||||||
cfg: &HtmlConfig,
|
cfg: &HtmlConfig,
|
||||||
|
wiki_extension: Option<&str>,
|
||||||
extra_vars: HashMap<String, String>,
|
extra_vars: HashMap<String, String>,
|
||||||
) -> std::io::Result<String> {
|
) -> std::io::Result<String> {
|
||||||
let date_str = match doc.metadata.date.as_deref() {
|
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("root_path".into(), root_path);
|
||||||
vars.insert("toc".into(), toc);
|
vars.insert("toc".into(), toc);
|
||||||
vars.insert("css".into(), cfg.css_name.clone());
|
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 {
|
for (k, v) in extra_vars {
|
||||||
vars.insert(k, v);
|
vars.insert(k, v);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut r = HtmlRenderer::new();
|
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 {
|
if let Some(t) = template {
|
||||||
r = r.with_template(t);
|
r = r.with_template(t);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ fn render_page_html_substitutes_title_content_and_extras() {
|
|||||||
"Home",
|
"Home",
|
||||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||||
&c.html,
|
&c.html,
|
||||||
|
None,
|
||||||
extras,
|
extras,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -260,6 +261,7 @@ fn render_page_html_uses_metadata_date_when_set() {
|
|||||||
"Home",
|
"Home",
|
||||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||||
&c.html,
|
&c.html,
|
||||||
|
None,
|
||||||
HashMap::new(),
|
HashMap::new(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -276,6 +278,7 @@ fn render_page_html_root_path_for_nested_pages() {
|
|||||||
"diary/2026-05-11",
|
"diary/2026-05-11",
|
||||||
DiaryDate::today_utc(),
|
DiaryDate::today_utc(),
|
||||||
&c.html,
|
&c.html,
|
||||||
|
None,
|
||||||
HashMap::new(),
|
HashMap::new(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -294,12 +297,64 @@ fn render_page_html_extra_var_overrides_default() {
|
|||||||
"H",
|
"H",
|
||||||
DiaryDate::today_utc(),
|
DiaryDate::today_utc(),
|
||||||
&c.html,
|
&c.html,
|
||||||
|
None,
|
||||||
extras,
|
extras,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(html.contains("D=OVERRIDE"));
|
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 =====
|
// ===== fallback_template + DEFAULT_CSS =====
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user