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
+1
View File
@@ -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);
+26 -1
View File
@@ -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);
}
+55
View File
@@ -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]