feat(config): custom_wiki2html external converter + base_url for RSS (P2)
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:
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user