From e90bbab39e9dedbd011238f075d2d604bb350968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Thu, 28 May 2026 21:19:55 -0300 Subject: [PATCH] fix(export): render vimwiki-compatible HTML for templates, links, and tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``, 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 --- crates/nuwiki-core/src/render/html.rs | 42 ++++++++++------- crates/nuwiki-core/tests/html_renderer.rs | 37 +++++++++++++-- crates/nuwiki-lsp/src/commands.rs | 1 + crates/nuwiki-lsp/src/export.rs | 27 ++++++++++- crates/nuwiki-lsp/tests/html_export.rs | 55 +++++++++++++++++++++++ 5 files changed, 142 insertions(+), 20 deletions(-) diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index 7c404bf..8dafd35 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -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 ``, + // 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, - "
  • " - )?; + write!(w, "
  • ")?; } None => w.write_all(b"
  • ")?, } diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs index 24d23d1..9f9110e 100644 --- a/crates/nuwiki-core/tests/html_renderer.rs +++ b/crates/nuwiki-core/tests/html_renderer.rs @@ -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("
      ")); - assert!(out.contains("")); - assert!(out.contains("done")); - assert!(out.contains("not done")); + // vimwiki-compatible: class on the
    • , no (the stylesheet draws it). + assert!(out.contains("
    • done
    • "), "{out}"); + assert!(out.contains("
    • not done
    • "), "{out}"); + assert!(!out.contains("a"), "{out}"); + assert!(out.contains("
    • b
    • "), "{out}"); + assert!(out.contains("
    • c
    • "), "{out}"); + assert!(out.contains("
    • d
    • "), "{out}"); + assert!(out.contains("
    • e
    • "), "{out}"); + assert!(out.contains("
    • f
    • "), "{out}"); + assert!(!out.contains("task-"), "no legacy task-* classes: {out}"); + assert!(!out.contains("Heading")); } +#[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%%content%", + ) + .with_var("root_path", "../"); + let out = renderer.render_to_string(&doc).unwrap(); + assert!(out.contains("My Page"), "title: {out}"); + assert!(out.contains("

      Hello

      "), "content: {out}"); + assert!(out.contains("href=\"../style.css\""), "root_path: {out}"); + assert!(!out.contains('%'), "no placeholder left: {out}"); +} + // ===== End-to-end smoke ===== #[test] diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index ca6b92d..ac08c30 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -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); diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs index ca1a8ca..3ceeae4 100644 --- a/crates/nuwiki-lsp/src/export.rs +++ b/crates/nuwiki-lsp/src/export.rs @@ -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, name: &str, today: DiaryDate, cfg: &HtmlConfig, + wiki_extension: Option<&str>, extra_vars: HashMap, ) -> std::io::Result { 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); } diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs index ec2a10a..b41db6c 100644 --- a/crates/nuwiki-lsp/tests/html_export.rs +++ b/crates/nuwiki-lsp/tests/html_export.rs @@ -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%\ +\ +%content%
      %date%
      "; + 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("My Page"), "title: {html}"); + assert!(html.contains("body text"), "content: {html}"); + assert!( + html.contains("href=\"../style.css\""), + "root_path+css: {html}" + ); + assert!(html.contains(""), "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]