feat(config): custom_wiki2html external converter + base_url for RSS (P2)
CI / cargo fmt --check (push) Failing after 16s
CI / cargo clippy (push) Successful in 32s
CI / cargo test (push) Successful in 48s
CI / editor keymaps (push) Successful in 1m39s

Add three per-wiki HtmlConfig keys mirroring vimwiki globals:

- custom_wiki2html / custom_wiki2html_args: when set, export shells out
  to the external converter via `sh -c` with vimwiki's exact arg order
  (<cmd> <force> <syntax> <ext> <out_dir> <in_file> <css> <tpl_path>
  <tpl_default> <tpl_ext> <root_path> <args>, `-` for empty optionals)
  instead of the built-in renderer.
- base_url: when set, diary RSS item + channel links become
  <base_url><diary_rel_path>/<date>.html instead of file:// URIs.

write_page() gains a `force` flag threaded through export_current (false),
export_all (its own flag) and the did_save auto-export path (false).

Tests: write_page_invokes_custom_wiki2html_converter,
write_rss_uses_base_url_for_public_links (html_export.rs), config
round-trip + defaults (index_and_config.rs). Docs: README, doc/nuwiki.txt,
lua config comment, vimwiki-gap.md item ticked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 19:06:42 +00:00
parent 09b5a2bd46
commit 23aec3e6c7
9 changed files with 236 additions and 11 deletions
+6
View File
@@ -229,6 +229,9 @@ require('nuwiki').setup({
template_default = 'default',
template_ext = '.tpl',
css_name = 'style.css',
custom_wiki2html = '', -- external converter; empty = built-in renderer
custom_wiki2html_args = '', -- extra args appended to the converter call
base_url = '', -- public URL prefix for RSS item links
auto_export = false, -- export on save
auto_toc = false, -- auto-refresh TOC on save
auto_generate_links = false, -- regen Generated Links on save
@@ -358,6 +361,9 @@ Each is a boolean. All default to `true` except `mouse`.
| `css_name` | string | `'style.css'` | stylesheet filename |
| `auto_export` | bool | `false` | `true` \| `false` (export on save) |
| `html_filename_parameterization` | bool | `false` | `true` \| `false` |
| `custom_wiki2html` | string | `''` | external converter command; empty = built-in renderer. Called as `<cmd> <force> <syntax> <ext> <out_dir> <in_file> <css> <tpl_path> <tpl_default> <tpl_ext> <root_path> <args>` (`-` for empty optionals), matching vimwiki |
| `custom_wiki2html_args` | string | `''` | extra args appended to the `custom_wiki2html` call |
| `base_url` | string | `''` | public URL prefix; when set, diary RSS item links become `<base_url><diary_rel_path>/<date>.html` instead of `file://` |
| `exclude_files` | list | `{}` | glob patterns |
| `color_dic` | table | `{}` | `{ name = 'color', … }` |
+107 -4
View File
@@ -1143,7 +1143,7 @@ fn export_current(
})));
}
let name = crate::index::page_name_from_uri(&p.uri, Some(&cfg.root));
let outcome = match crate::commands::export_ops::write_page(&doc.ast, &name, cfg) {
let outcome = match crate::commands::export_ops::write_page(&doc.ast, &name, cfg, false) {
Ok(o) => o,
Err(e) => return Err(format!("nuwiki.export.currentToHtml: {e}")),
};
@@ -1222,7 +1222,7 @@ fn export_all(backend: &Backend, args: Vec<Value>, force: bool) -> Result<Option
}
}
}
match crate::commands::export_ops::write_page(&ast, &name, &cfg) {
match crate::commands::export_ops::write_page(&ast, &name, &cfg, force) {
Ok(outcome) => exported.push(outcome.to_json()),
Err(e) => skipped.push(serde_json::json!({"uri": uri, "reason": e.to_string()})),
}
@@ -3342,6 +3342,7 @@ pub mod export_ops {
doc: &DocumentNode,
name: &str,
cfg: &WikiConfig,
force: bool,
) -> std::io::Result<PageExport> {
if is_excluded(&cfg.html.exclude_files, name) {
return Err(std::io::Error::new(
@@ -3349,6 +3350,11 @@ pub mod export_ops {
format!("excluded by exclude_files: {name}"),
));
}
// When a `custom_wiki2html` converter is configured, hand the page off
// to it instead of the built-in renderer (vimwiki parity).
if !cfg.html.custom_wiki2html.trim().is_empty() {
return run_custom_wiki2html(name, cfg, force);
}
let template_source = template_for(&cfg.html, doc);
let template = load_template(&template_source)
.unwrap_or_else(|| fallback_template(&cfg.html.css_name));
@@ -3378,6 +3384,86 @@ pub mod export_ops {
})
}
/// `sh`-quote a path/string for the `custom_wiki2html` command line.
fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
/// Run an external `custom_wiki2html` converter for `name`, mirroring
/// vimwiki's argument order:
/// script force syntax ext output_dir input_file css_file
/// template_path template_default template_ext root_path args
/// Empty optional args are passed as `-` (vimwiki convention). The script
/// is responsible for writing the output file; we return the path it is
/// expected to produce (`output_path_for`).
fn run_custom_wiki2html(
name: &str,
cfg: &WikiConfig,
force: bool,
) -> std::io::Result<PageExport> {
let h = &cfg.html;
let input = cfg.root.join(format!("{name}{}", cfg.file_extension));
std::fs::create_dir_all(&h.html_path)?;
let css = crate::export::css_path(h);
let ext = cfg.file_extension.trim_start_matches('.');
let dash = |s: &str| {
if s.is_empty() {
"-".to_string()
} else {
s.to_string()
}
};
let tpl_path = if h.template_path.as_os_str().is_empty() {
"-".to_string()
} else {
sh_quote(&h.template_path.display().to_string())
};
let root_path = {
let rp = crate::export::compute_root_path(name);
if rp.is_empty() {
"-".to_string()
} else {
sh_quote(&rp)
}
};
let cmd = format!(
"{} {} {} {} {} {} {} {} {} {} {} {}",
h.custom_wiki2html,
if force { 1 } else { 0 },
cfg.syntax,
ext,
sh_quote(&h.html_path.display().to_string()),
sh_quote(&input.display().to_string()),
sh_quote(&css.display().to_string()),
tpl_path,
dash(&h.template_default),
dash(&h.template_ext),
root_path,
dash(h.custom_wiki2html_args.trim()),
);
let status = std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
.status()
.map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("custom_wiki2html spawn failed: {e}"),
)
})?;
if !status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("custom_wiki2html exited with {status}"),
));
}
Ok(PageExport {
page: name.to_string(),
output_path: output_path_for(&cfg.html, name),
source_uri: tower_lsp::lsp_types::Url::from_file_path(&input).ok(),
})
}
/// Two-tier template load. Returns the resolved template body, or
/// `None` if neither file exists — caller falls back to the built-in
/// template.
@@ -3423,12 +3509,29 @@ pub mod export_ops {
"#,
);
xml.push_str(&format!("<title>{}</title>\n", xml_escape(&cfg.name)));
// `base_url` (per-wiki) prefixes the feed + item links so a hosted
// feed points at public URLs rather than local `file://` paths.
let base_url = cfg.html.base_url.trim();
if !base_url.is_empty() {
xml.push_str(&format!("<link>{}</link>\n", xml_escape(base_url)));
}
xml.push_str("<description>nuwiki diary</description>\n");
for e in sorted {
// With base_url set, build a public URL from the diary path + date;
// otherwise fall back to the entry's `file://` URI.
let link = if base_url.is_empty() {
e.uri.as_str().to_string()
} else {
format!(
"{base_url}{}/{}.html",
cfg.diary_rel_path.trim_matches('/'),
e.date.format()
)
};
xml.push_str("<item>");
xml.push_str(&format!("<title>{}</title>", e.date.format()));
xml.push_str(&format!("<link>{}</link>", e.uri.as_str()));
xml.push_str(&format!("<guid>{}</guid>", e.uri.as_str()));
xml.push_str(&format!("<link>{}</link>", xml_escape(&link)));
xml.push_str(&format!("<guid>{}</guid>", xml_escape(&link)));
xml.push_str("</item>\n");
}
xml.push_str("</channel></rss>\n");
+25
View File
@@ -130,6 +130,13 @@ pub struct HtmlConfig {
/// renderer falls back to `class="color-<name>"` when a name
/// isn't in the dict.
pub color_dic: std::collections::HashMap<String, String>,
/// External wiki→HTML converter (vimwiki's `custom_wiki2html`). When
/// non-empty, export shells out to it instead of the built-in renderer.
pub custom_wiki2html: String,
/// Extra args appended to the `custom_wiki2html` invocation.
pub custom_wiki2html_args: String,
/// URL prefix prepended to RSS feed / diary item links (`base_url`).
pub base_url: String,
}
impl Default for HtmlConfig {
@@ -145,6 +152,9 @@ impl Default for HtmlConfig {
html_filename_parameterization: false,
exclude_files: Vec::new(),
color_dic: std::collections::HashMap::new(),
custom_wiki2html: String::new(),
custom_wiki2html_args: String::new(),
base_url: String::new(),
}
}
}
@@ -634,6 +644,12 @@ struct RawWiki {
exclude_files: Option<Vec<String>>,
#[serde(default)]
color_dic: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
custom_wiki2html: Option<String>,
#[serde(default)]
custom_wiki2html_args: Option<String>,
#[serde(default)]
base_url: Option<String>,
// per-wiki keys mirroring vimwiki globals. All optional so
// existing single-wiki configs migrate without touching anything.
#[serde(default)]
@@ -720,6 +736,15 @@ impl From<RawWiki> for WikiConfig {
if let Some(v) = r.color_dic {
html.color_dic = v;
}
if let Some(s) = r.custom_wiki2html {
html.custom_wiki2html = s;
}
if let Some(s) = r.custom_wiki2html_args {
html.custom_wiki2html_args = s;
}
if let Some(s) = r.base_url {
html.base_url = s;
}
let d = wiki_defaults();
WikiConfig {
name: r.name.unwrap_or(derived_name),
+1 -1
View File
@@ -728,7 +728,7 @@ impl LanguageServer for Backend {
return;
}
let name = index::page_name_from_uri(&uri, Some(&wiki.config.root));
let outcome = commands::export_ops::write_page(&doc.ast, &name, &wiki.config);
let outcome = commands::export_ops::write_page(&doc.ast, &name, &wiki.config, false);
drop(doc);
match outcome {
Ok(out) => {
+57 -3
View File
@@ -428,7 +428,7 @@ fn write_page_writes_html_to_disk() {
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
let ast = parse("= Hello =\nworld\n");
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "Hello", &c).unwrap();
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "Hello", &c, false).unwrap();
assert_eq!(outcome.page, "Hello");
assert!(outcome.output_path.exists());
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
@@ -442,7 +442,7 @@ fn write_page_creates_subdirs() {
c.html = HtmlConfig::from_root(&root);
let ast = parse("= Daily =\n");
let outcome =
nuwiki_lsp::commands::export_ops::write_page(&ast, "diary/2026-05-11", &c).unwrap();
nuwiki_lsp::commands::export_ops::write_page(&ast, "diary/2026-05-11", &c, false).unwrap();
assert!(outcome.output_path.exists());
assert!(outcome
.output_path
@@ -463,7 +463,7 @@ fn write_page_uses_template_file_when_present() {
)
.unwrap();
let ast = parse("%title T\n= H =\nbody\n");
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "P", &c).unwrap();
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "P", &c, false).unwrap();
let body = std::fs::read_to_string(outcome.output_path).unwrap();
assert!(body.starts_with("<x>T|"));
assert!(body.ends_with("</x>"));
@@ -518,6 +518,60 @@ fn write_rss_emits_valid_xml_with_entries() {
let i11 = xml.find("2026-05-11").unwrap();
let i10 = xml.find("2026-05-10").unwrap();
assert!(i11 < i10);
// No base_url → item links keep the file:// URI.
assert!(xml.contains("<link>file:///tmp/diary/2026-05-11.wiki</link>"));
}
#[test]
fn write_rss_uses_base_url_for_public_links() {
use nuwiki_lsp::diary::DiaryEntry;
use tower_lsp::lsp_types::Url;
let root = temp_root("rss_base");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.base_url = "https://example.com/wiki/".into();
c.diary_rel_path = "diary".into();
let entries = vec![DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(),
}];
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
let xml = std::fs::read_to_string(&path).unwrap();
// Channel + item links use the public base URL, not file://.
assert!(xml.contains("<link>https://example.com/wiki/</link>"));
assert!(xml.contains("<link>https://example.com/wiki/diary/2026-05-11.html</link>"));
assert!(xml.contains("<guid>https://example.com/wiki/diary/2026-05-11.html</guid>"));
assert!(!xml.contains("file:///tmp/diary"));
}
#[test]
fn write_page_invokes_custom_wiki2html_converter() {
let root = temp_root("custom_w2h");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
// Source file must exist on disk — the converter receives its path.
std::fs::write(root.join("Hello.wiki"), "= Hello =\nworld\n").unwrap();
// A tiny shell "converter": writes a marker into the output path it is told
// to produce. nuwiki appends args positionally after `_` ($0), so
// $1=force, $2=syntax, $3=ext, $4=output_dir, $5=input_file, …
let script = "mkdir -p \"$4\" && printf 'CUSTOM:%s' \"$5\" > \"$4\"/Hello.html";
c.html.custom_wiki2html = format!("sh -c {} _", shell_words_quote(script));
let outcome =
nuwiki_lsp::commands::export_ops::write_page(&parse("= Hello =\n"), "Hello", &c, true)
.unwrap();
assert_eq!(outcome.page, "Hello");
assert!(outcome.output_path.exists(), "converter must produce output");
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
assert!(
body.starts_with("CUSTOM:"),
"marker from converter, got {body}"
);
}
/// Minimal single-quote shell escaping for the test command above.
fn shell_words_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
// ===== COMMANDS list completeness =====
@@ -284,6 +284,10 @@ fn defaults_carry_vimwiki_per_wiki_keys() {
assert_eq!(cfg.links_header, "Generated Links");
assert_eq!(cfg.tags_header, "Generated Tags");
assert_eq!(cfg.listsym_rejected, "-");
// No external converter / public base URL by default.
assert_eq!(cfg.html.custom_wiki2html, "");
assert_eq!(cfg.html.custom_wiki2html_args, "");
assert_eq!(cfg.html.base_url, "");
}
#[test]
@@ -312,6 +316,9 @@ fn raw_wiki_parses_every_new_key() {
"links_header": "All Pages",
"tags_header": "Tag Index",
"tags_header_level": 3,
"custom_wiki2html": "~/bin/my_wiki2html.sh",
"custom_wiki2html_args": "--flag",
"base_url": "https://example.com/wiki/",
}],
}));
let w = &cfg.wikis[0];
@@ -338,6 +345,11 @@ fn raw_wiki_parses_every_new_key() {
assert_eq!(w.links_header_level, 1); // unspecified → default
assert_eq!(w.tags_header, "Tag Index");
assert_eq!(w.tags_header_level, 3);
// custom_wiki2html keeps the literal command string (tilde-expansion is
// the converter's job, not ours); args + base_url round-trip verbatim.
assert_eq!(w.html.custom_wiki2html, "~/bin/my_wiki2html.sh");
assert_eq!(w.html.custom_wiki2html_args, "--flag");
assert_eq!(w.html.base_url, "https://example.com/wiki/");
// The parsed keys drive a date-mode, Sunday-start diary calendar:
// a weekly note is the week-start date (YYYY-MM-DD), stepping ±7 days.
+16 -2
View File
@@ -118,8 +118,22 @@ fix site.
custom rejected glyph both lexes and round-trips. Tests:
`listsyms::custom_rejected_glyph_is_honoured` + config round-trip in
`index_and_config.rs`.
- [ ] **`custom_wiki2html` (+`_args`)** and **`base_url`** — external HTML
converter hook + export URL prefix. _Fix:_ `config.rs` `HtmlConfig`.
- [x] **`custom_wiki2html` (+`_args`)** and **`base_url`** — external HTML
converter hook + export URL prefix. _Fix:_ `HtmlConfig` (`config.rs`) gained
`custom_wiki2html`, `custom_wiki2html_args`, `base_url` (all default `""`,
round-tripped through `RawWiki`/`From`/defaults). `write_page`
(`commands.rs`) now takes a `force` flag and, when `custom_wiki2html` is set,
hands the page to `run_custom_wiki2html()` — which shells out via `sh -c`
with vimwiki's exact arg order (`<cmd> <force> <syntax> <ext> <out_dir>
<in_file> <css> <tpl_path> <tpl_default> <tpl_ext> <root_path> <args>`, `-`
for empty optionals) instead of the built-in renderer. `force` is threaded
through `export_current` (false), `export_all` (its own flag), and the
`did_save` auto-export path (false). `write_rss` now prefixes the channel +
item links with `base_url` (`<base_url><diary_rel_path>/<date>.html`) when
set, falling back to the entry's `file://` URI otherwise. Tests:
`write_page_invokes_custom_wiki2html_converter`,
`write_rss_uses_base_url_for_public_links` (`html_export.rs`), config
round-trip + defaults (`index_and_config.rs`).
- [ ] **`map_prefix`** — `<Leader>w` hardcoded across the map layer.
_Fix:_ `plugin/nuwiki.vim`, `lua/nuwiki/keymaps.lua`, `ftplugin/vimwiki.vim`.
- [x] **`<M-CR>` badd-link** — was mouse-only (`<MiddleMouse>`) / command-only
+10
View File
@@ -194,6 +194,16 @@ match upstream vimwiki.
`template_default` `'default'`
`template_ext` `'.tpl'`
`css_name` `'style.css'`
`custom_wiki2html` `''` — external converter command. When set, export
shells out to it instead of the built-in renderer,
called as: <cmd> <force> <syntax> <ext> <out_dir>
<in_file> <css> <tpl_path> <tpl_default> <tpl_ext>
<root_path> <args> (`-` for empty optionals), matching
vimwiki's `g:vimwiki_custom_wiki2html` contract.
`custom_wiki2html_args` `''` — extra args appended to the converter call.
`base_url` `''` — public URL prefix. When set, diary RSS item
links become <base_url><diary_rel_path>/<date>.html
instead of local `file://` paths.
`auto_export` `false` — export to HTML on save.
`auto_toc` `false` — refresh TOC on save.
`auto_generate_links` `false` — regen an existing Generated Links section
+2 -1
View File
@@ -26,7 +26,8 @@ M.defaults = {
-- html_path, template_path, template_default, template_ext,
-- template_date_format, css_name, auto_export, auto_toc,
-- auto_generate_links, auto_generate_tags, auto_diary_index,
-- html_filename_parameterization, exclude_files, color_dic
-- html_filename_parameterization, exclude_files, color_dic,
-- custom_wiki2html, custom_wiki2html_args, base_url,
-- toc_header, toc_header_level, links_header, links_header_level,
-- tags_header, tags_header_level,
-- listsyms, listsym_rejected, listsyms_propagate, list_margin,