feat(config): P3 config batch — group 7 (user_htmls pruning)

allToHtml now prunes orphan HTML files (no corresponding wiki source) under
html_path — export_ops::prune_orphan_html walks the output tree, maps each
<rel>.html back to <root>/<rel>.<ext>, and deletes those with no source,
skipping the CSS file and any basename in user_htmls. Gated on
!html_filename_parameterization (slugified names can't be reverse-mapped),
mirroring upstream; the pruned paths are reported in the command result.

hl_headers / hl_cb_checked are NOT implemented as toggles: nuwiki already
highlights headers per-level (@vimwikiHeading.level1..6) and checkboxes
always-on via LSP semantic tokens, which supersedes the upstream opt-in
toggles (default off) — same class as the maxhi divergence. Documented in the
gap doc rather than degrading the existing highlighting.

Test: prune_orphan_html_deletes_sourceless_keeps_protected. Full rust suite +
clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 12:37:38 +00:00
parent 5cc47b0a42
commit a63d3dd7cf
2 changed files with 83 additions and 0 deletions
+57
View File
@@ -1282,9 +1282,21 @@ fn export_all(backend: &Backend, args: Vec<Value>, force: bool) -> Result<Option
} }
} }
let css = crate::commands::export_ops::ensure_css(&cfg).map_err(|e| e.to_string())?; let css = crate::commands::export_ops::ensure_css(&cfg).map_err(|e| e.to_string())?;
// Prune orphan HTML (no wiki source), honouring user_htmls. Skipped when
// filename parameterization is on (slugified names can't reverse-map),
// mirroring upstream.
let pruned: Vec<serde_json::Value> = if cfg.html.html_filename_parameterization {
Vec::new()
} else {
crate::commands::export_ops::prune_orphan_html(&cfg)
.into_iter()
.map(|p| serde_json::json!(p))
.collect()
};
Ok(Some(serde_json::json!({ Ok(Some(serde_json::json!({
"exported": exported, "exported": exported,
"skipped": skipped, "skipped": skipped,
"pruned": pruned,
"files_seen": files.len(), "files_seen": files.len(),
"css_path": css.path, "css_path": css.path,
"css_created": css.created, "css_created": css.created,
@@ -3788,6 +3800,51 @@ pub mod export_ops {
) )
} }
/// Delete HTML files under `html_path` that have no corresponding wiki
/// source (vimwiki's `allToHtml` cleanup), skipping the CSS file and any
/// basename listed in `user_htmls`. Returns the pruned paths. Gated by the
/// caller on `!html_filename_parameterization` (slugified names can't be
/// reverse-mapped to a source).
pub fn prune_orphan_html(cfg: &WikiConfig) -> Vec<PathBuf> {
let html_root = &cfg.html.html_path;
let css = css_path(&cfg.html);
let ext = cfg.file_extension.trim_start_matches('.');
let mut pruned = Vec::new();
let mut stack = vec![html_root.clone()];
while let Some(dir) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("html") {
continue;
}
if path == css {
continue;
}
let base = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if cfg.html.user_htmls.iter().any(|u| u == base) {
continue;
}
// Map <html_path>/<rel>.html back to <root>/<rel>.<ext>.
let Ok(rel) = path.strip_prefix(html_root) else {
continue;
};
let mut src = cfg.root.join(rel);
src.set_extension(ext);
if !src.exists() && std::fs::remove_file(&path).is_ok() {
pruned.push(path);
}
}
}
pruned
}
fn rss_path(cfg: &WikiConfig) -> PathBuf { fn rss_path(cfg: &WikiConfig) -> PathBuf {
let name = if cfg.html.rss_name.trim().is_empty() { let name = if cfg.html.rss_name.trim().is_empty() {
"rss.xml" "rss.xml"
+26
View File
@@ -631,6 +631,32 @@ fn write_rss_uses_base_url_for_public_links() {
assert!(!xml.contains("file:///tmp/diary")); assert!(!xml.contains("file:///tmp/diary"));
} }
#[test]
fn prune_orphan_html_deletes_sourceless_keeps_protected() {
let root = temp_root("prune");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.user_htmls = vec!["keep.html".into()];
let html = &c.html.html_path;
std::fs::create_dir_all(html).unwrap();
// A sourced page (Home.wiki exists) → kept.
std::fs::write(root.join("Home.wiki"), "= Home =\n").unwrap();
std::fs::write(html.join("Home.html"), "<h1>Home</h1>").unwrap();
// An orphan (no source) → pruned.
std::fs::write(html.join("Ghost.html"), "<h1>Ghost</h1>").unwrap();
// A user_htmls-protected orphan → kept.
std::fs::write(html.join("keep.html"), "<p>manual</p>").unwrap();
// The CSS file → never pruned.
std::fs::write(html.join(&c.html.css_name), "body{}").unwrap();
let pruned = nuwiki_lsp::commands::export_ops::prune_orphan_html(&c);
assert_eq!(pruned.len(), 1, "only the orphan: {pruned:?}");
assert!(!html.join("Ghost.html").exists(), "orphan deleted");
assert!(html.join("Home.html").exists(), "sourced kept");
assert!(html.join("keep.html").exists(), "user_htmls kept");
assert!(html.join(&c.html.css_name).exists(), "css kept");
}
#[test] #[test]
fn write_rss_honours_rss_name_and_max_items() { fn write_rss_honours_rss_name_and_max_items() {
use nuwiki_lsp::diary::DiaryEntry; use nuwiki_lsp::diary::DiaryEntry;