a63d3dd7cf
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>
731 lines
24 KiB
Rust
731 lines
24 KiB
Rust
//! HTML export commands.
|
|
//!
|
|
//! Drives the pure `export::*` helpers directly. The side-effecting
|
|
//! `export_ops::write_page` / `write_rss` paths get exercised via a
|
|
//! per-test temp dir so we know writes land where they should and
|
|
//! the rendered HTML contains the substituted vars.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use nuwiki_core::date::DiaryDate;
|
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
use nuwiki_core::syntax::SyntaxPlugin;
|
|
use nuwiki_lsp::config::{HtmlConfig, WikiConfig};
|
|
use nuwiki_lsp::export;
|
|
|
|
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
VimwikiSyntax::new().parse(src)
|
|
}
|
|
|
|
fn cfg(root: &str) -> WikiConfig {
|
|
WikiConfig::from_root(PathBuf::from(root))
|
|
}
|
|
|
|
fn temp_root(suffix: &str) -> PathBuf {
|
|
let p = std::env::temp_dir().join(format!("nuwiki-p17-{suffix}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&p);
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
p
|
|
}
|
|
|
|
// ===== HtmlConfig defaults =====
|
|
|
|
#[test]
|
|
fn html_config_defaults_match_vimwiki() {
|
|
let c = HtmlConfig::default();
|
|
assert_eq!(c.template_default, "default");
|
|
assert_eq!(c.template_ext, ".tpl");
|
|
assert_eq!(c.template_date_format, "%Y-%m-%d");
|
|
assert_eq!(c.css_name, "style.css");
|
|
assert!(!c.auto_export);
|
|
assert!(!c.html_filename_parameterization);
|
|
assert!(c.exclude_files.is_empty());
|
|
assert_eq!(c.html_header_numbering, 0); // upstream default: numbering off
|
|
assert!(c.html_header_numbering_sym.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn html_config_from_root_uses_underscore_dirs() {
|
|
let h = HtmlConfig::from_root(Path::new("/tmp/wiki"));
|
|
assert_eq!(h.html_path, PathBuf::from("/tmp/wiki/_html"));
|
|
assert_eq!(h.template_path, PathBuf::from("/tmp/wiki/_templates"));
|
|
}
|
|
|
|
#[test]
|
|
fn wiki_config_picks_up_html_paths() {
|
|
let c = cfg("/tmp/x");
|
|
assert_eq!(c.html.html_path, PathBuf::from("/tmp/x/_html"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_from_json_honours_explicit_html_keys() {
|
|
use nuwiki_lsp::config::config_from_json;
|
|
let c = config_from_json(serde_json::json!({
|
|
"wikis": [{
|
|
"root": "/tmp/y",
|
|
"html_path": "/tmp/y/site",
|
|
"template_default": "post",
|
|
"template_ext": ".tmpl",
|
|
"auto_export": true,
|
|
"html_filename_parameterization": true,
|
|
"exclude_files": ["_drafts/**"],
|
|
}],
|
|
}));
|
|
let h = &c.wikis[0].html;
|
|
assert_eq!(h.html_path, PathBuf::from("/tmp/y/site"));
|
|
assert_eq!(h.template_default, "post");
|
|
assert_eq!(h.template_ext, ".tmpl");
|
|
assert!(h.auto_export);
|
|
assert!(h.html_filename_parameterization);
|
|
assert_eq!(h.exclude_files, vec!["_drafts/**"]);
|
|
}
|
|
|
|
// ===== format_date =====
|
|
|
|
#[test]
|
|
fn format_date_strftime_subset() {
|
|
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
assert_eq!(export::format_date(&d, "%Y-%m-%d"), "2026-05-11");
|
|
assert_eq!(export::format_date(&d, "%Y"), "2026");
|
|
assert_eq!(export::format_date(&d, "%m/%d/%Y"), "05/11/2026");
|
|
assert_eq!(export::format_date(&d, "literal text"), "literal text");
|
|
assert_eq!(
|
|
export::format_date(&d, "100%% done"),
|
|
"100% done",
|
|
"%% escapes to a literal %"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_date_unknown_specifier_passes_through() {
|
|
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
// We support Y/m/d/%; %H is not supported and should pass through verbatim.
|
|
assert_eq!(export::format_date(&d, "%H:00"), "%H:00");
|
|
}
|
|
|
|
// ===== compute_root_path =====
|
|
|
|
#[test]
|
|
fn compute_root_path_is_empty_at_top_level() {
|
|
assert_eq!(export::compute_root_path("Home"), "");
|
|
}
|
|
|
|
#[test]
|
|
fn compute_root_path_adds_one_dotdot_per_subdir() {
|
|
assert_eq!(export::compute_root_path("diary/2026-05-11"), "../");
|
|
assert_eq!(export::compute_root_path("a/b/c"), "../../");
|
|
}
|
|
|
|
// ===== output_path_for =====
|
|
|
|
#[test]
|
|
fn output_path_for_basic() {
|
|
let c = cfg("/tmp/x");
|
|
assert_eq!(
|
|
export::output_path_for(&c.html, "Home"),
|
|
PathBuf::from("/tmp/x/_html/Home.html")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn output_path_for_nested_preserves_subdirs() {
|
|
let c = cfg("/tmp/x");
|
|
assert_eq!(
|
|
export::output_path_for(&c.html, "diary/2026-05-11"),
|
|
PathBuf::from("/tmp/x/_html/diary/2026-05-11.html")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn output_path_for_slugifies_only_the_filename_when_enabled() {
|
|
let mut c = cfg("/tmp/x");
|
|
c.html.html_filename_parameterization = true;
|
|
let out = export::output_path_for(&c.html, "Notes/My Page!");
|
|
// subdir keeps casing, filename becomes lowercase + url-safe.
|
|
assert_eq!(out, PathBuf::from("/tmp/x/_html/notes/my-page.html"));
|
|
}
|
|
|
|
// ===== is_excluded =====
|
|
|
|
#[test]
|
|
fn is_excluded_matches_globstar() {
|
|
let patterns = vec!["_drafts/**".into()];
|
|
assert!(export::is_excluded(&patterns, "_drafts/foo"));
|
|
assert!(export::is_excluded(&patterns, "_drafts/x/y"));
|
|
assert!(!export::is_excluded(&patterns, "notes/x"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_excluded_matches_simple_glob() {
|
|
let patterns = vec!["*.tmp".into()];
|
|
assert!(export::is_excluded(&patterns, "foo.tmp"));
|
|
assert!(!export::is_excluded(&patterns, "foo.wiki"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_excluded_matches_question_mark() {
|
|
let patterns = vec!["??".into()];
|
|
assert!(export::is_excluded(&patterns, "ab"));
|
|
assert!(!export::is_excluded(&patterns, "abc"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_excluded_no_match_returns_false() {
|
|
let patterns = vec!["foo".into()];
|
|
assert!(!export::is_excluded(&patterns, "bar"));
|
|
}
|
|
|
|
// ===== TOC HTML =====
|
|
|
|
#[test]
|
|
fn build_toc_html_produces_nested_lists() {
|
|
let ast = parse("= One =\n== Two ==\n=== Three ===\n= Four =\n");
|
|
let html = export::build_toc_html(&ast);
|
|
assert!(html.contains("ul class=\"toc\""));
|
|
assert!(html.contains(">One<"));
|
|
assert!(html.contains(">Two<"));
|
|
assert!(html.contains(">Three<"));
|
|
assert!(html.contains(">Four<"));
|
|
// Verify the nesting: Two should be inside an inner <ul> after One.
|
|
let one_idx = html.find(">One<").unwrap();
|
|
let two_idx = html.find(">Two<").unwrap();
|
|
let three_idx = html.find(">Three<").unwrap();
|
|
assert!(one_idx < two_idx && two_idx < three_idx);
|
|
}
|
|
|
|
#[test]
|
|
fn build_toc_html_empty_for_no_headings() {
|
|
let ast = parse("Just a paragraph.\n");
|
|
assert!(export::build_toc_html(&ast).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn build_toc_html_escapes_special_chars_in_title() {
|
|
let ast = parse("= A & B <ok> =\n");
|
|
let html = export::build_toc_html(&ast);
|
|
assert!(html.contains("A & B <ok>"));
|
|
}
|
|
|
|
// ===== template_for =====
|
|
|
|
#[test]
|
|
fn template_for_prefers_metadata_template() {
|
|
let c = cfg("/tmp/x");
|
|
let ast = parse("%template post\n= Hi =\n");
|
|
let src = export::template_for(&c.html, &ast);
|
|
assert_eq!(
|
|
src.primary,
|
|
Some(PathBuf::from("/tmp/x/_templates/post.tpl"))
|
|
);
|
|
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
|
|
}
|
|
|
|
#[test]
|
|
fn template_for_falls_back_when_no_directive() {
|
|
let c = cfg("/tmp/x");
|
|
let ast = parse("= Hi =\n");
|
|
let src = export::template_for(&c.html, &ast);
|
|
assert!(src.primary.is_none());
|
|
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
|
|
}
|
|
|
|
// ===== render_page_html =====
|
|
|
|
#[test]
|
|
fn render_page_html_passes_through_valid_html_tags() {
|
|
// `<sub>`/`<kbd>` are in the default valid_html_tags → verbatim; `<script>`
|
|
// is not → escaped.
|
|
let ast = parse("H<sub>2</sub>O press <kbd>Ctrl</kbd> <script>x</script>\n");
|
|
let c = cfg("/tmp/x");
|
|
let html = export::render_page_html(
|
|
&ast,
|
|
Some("{{content}}".into()),
|
|
"Home",
|
|
DiaryDate::today_utc(),
|
|
&c.html,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(html.contains("H<sub>2</sub>O"), "sub verbatim: {html}");
|
|
assert!(html.contains("<kbd>Ctrl</kbd>"), "kbd verbatim: {html}");
|
|
assert!(html.contains("<script>"), "script escaped: {html}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_html_substitutes_emoji_when_enabled() {
|
|
let ast = parse("ship it :rocket: and :tada:\n");
|
|
let c = cfg("/tmp/x"); // emoji_enable defaults true
|
|
let html = export::render_page_html(
|
|
&ast,
|
|
Some("{{content}}".into()),
|
|
"Home",
|
|
DiaryDate::today_utc(),
|
|
&c.html,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(html.contains("🚀"), "rocket: {html}");
|
|
assert!(html.contains("🎉"), "tada: {html}");
|
|
assert!(!html.contains(":rocket:"), "shortcode replaced: {html}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_html_emoji_off_leaves_shortcodes() {
|
|
let ast = parse("plain :rocket: here\n");
|
|
let mut c = cfg("/tmp/x");
|
|
c.html.emoji_enable = false;
|
|
let html = export::render_page_html(
|
|
&ast,
|
|
Some("{{content}}".into()),
|
|
"Home",
|
|
DiaryDate::today_utc(),
|
|
&c.html,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(html.contains(":rocket:"), "left as-is: {html}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_html_substitutes_title_content_and_extras() {
|
|
let ast = parse("%title My Page\n= One =\nbody text\n");
|
|
let c = cfg("/tmp/x");
|
|
let template = "<title>{{title}}</title><body>{{content}}</body><footer>{{date}}</footer>";
|
|
let extras = HashMap::new();
|
|
let html = export::render_page_html(
|
|
&ast,
|
|
Some(template.into()),
|
|
"Home",
|
|
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
|
&c.html,
|
|
None,
|
|
extras,
|
|
)
|
|
.unwrap();
|
|
assert!(html.contains("<title>My Page</title>"));
|
|
assert!(html.contains("<footer>2026-05-11</footer>"));
|
|
assert!(html.contains("body text"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_html_uses_metadata_date_when_set() {
|
|
let ast = parse("%date 2025-01-02\n= Hi =\n");
|
|
let c = cfg("/tmp/x");
|
|
let html = export::render_page_html(
|
|
&ast,
|
|
Some("DATE={{date}}".into()),
|
|
"Home",
|
|
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
|
&c.html,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(html.contains("DATE=2025-01-02"), "got: {html}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_html_numbers_headers_when_enabled() {
|
|
let ast = parse("= Intro =\n== Background ==\n== Methods ==\n= Results =\n");
|
|
let mut c = cfg("/tmp/x");
|
|
c.html.html_header_numbering = 1; // number from h1
|
|
c.html.html_header_numbering_sym = ".".into();
|
|
let html = export::render_page_html(
|
|
&ast,
|
|
None,
|
|
"Home",
|
|
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
|
&c.html,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(html.contains("<h1>1. Intro</h1>"), "got: {html}");
|
|
assert!(html.contains("<h2>1.1. Background</h2>"), "got: {html}");
|
|
assert!(html.contains("<h2>1.2. Methods</h2>"), "got: {html}");
|
|
assert!(html.contains("<h1>2. Results</h1>"), "got: {html}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_html_numbering_skips_headers_above_start_level() {
|
|
let ast = parse("= Title =\n== Section ==\n=== Sub ===\n");
|
|
let mut c = cfg("/tmp/x");
|
|
c.html.html_header_numbering = 2; // start at h2; h1 stays unnumbered
|
|
// empty sym → number followed by a bare space
|
|
let html = export::render_page_html(
|
|
&ast,
|
|
None,
|
|
"Home",
|
|
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
|
&c.html,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
html.contains("<h1>Title</h1>"),
|
|
"h1 unnumbered, got: {html}"
|
|
);
|
|
assert!(html.contains("<h2>1 Section</h2>"), "got: {html}");
|
|
assert!(html.contains("<h3>1.1 Sub</h3>"), "got: {html}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_html_no_header_numbering_by_default() {
|
|
let ast = parse("= Intro =\n== Sub ==\n");
|
|
let c = cfg("/tmp/x");
|
|
let html = export::render_page_html(
|
|
&ast,
|
|
None,
|
|
"Home",
|
|
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
|
&c.html,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(html.contains("<h1>Intro</h1>"), "got: {html}");
|
|
assert!(html.contains("<h2>Sub</h2>"), "got: {html}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_html_root_path_for_nested_pages() {
|
|
let ast = parse("= D =\n");
|
|
let c = cfg("/tmp/x");
|
|
let html = export::render_page_html(
|
|
&ast,
|
|
Some("CSS={{root_path}}{{css}}".into()),
|
|
"diary/2026-05-11",
|
|
DiaryDate::today_utc(),
|
|
&c.html,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(html.contains("CSS=../style.css"), "got: {html}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_html_extra_var_overrides_default() {
|
|
let ast = parse("= H =\n");
|
|
let c = cfg("/tmp/x");
|
|
let mut extras = HashMap::new();
|
|
extras.insert("date".into(), "OVERRIDE".into());
|
|
let html = export::render_page_html(
|
|
&ast,
|
|
Some("D={{date}}".into()),
|
|
"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]
|
|
fn fallback_template_references_root_path_and_css() {
|
|
let t = export::fallback_template("custom.css");
|
|
assert!(t.contains("{{title}}"));
|
|
assert!(t.contains("{{content}}"));
|
|
assert!(t.contains("{{root_path}}custom.css"));
|
|
}
|
|
|
|
#[test]
|
|
fn default_css_is_non_empty() {
|
|
assert!(!export::DEFAULT_CSS.is_empty());
|
|
assert!(export::DEFAULT_CSS.contains("font-family"));
|
|
}
|
|
|
|
// ===== write_page (disk-touching) =====
|
|
|
|
#[test]
|
|
fn write_page_writes_html_to_disk() {
|
|
let root = temp_root("write");
|
|
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, false).unwrap();
|
|
assert_eq!(outcome.page, "Hello");
|
|
assert!(outcome.output_path.exists());
|
|
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
|
|
assert!(body.contains("Hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn write_page_creates_subdirs() {
|
|
let root = temp_root("subdir");
|
|
let mut c = WikiConfig::from_root(root.clone());
|
|
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, false).unwrap();
|
|
assert!(outcome.output_path.exists());
|
|
assert!(outcome
|
|
.output_path
|
|
.to_string_lossy()
|
|
.contains("diary/2026-05-11.html"));
|
|
}
|
|
|
|
#[test]
|
|
fn write_page_uses_template_file_when_present() {
|
|
let root = temp_root("template");
|
|
let mut c = WikiConfig::from_root(root.clone());
|
|
c.html = HtmlConfig::from_root(&root);
|
|
// Write a template file the renderer should pick up.
|
|
std::fs::create_dir_all(&c.html.template_path).unwrap();
|
|
std::fs::write(
|
|
c.html.template_path.join("default.tpl"),
|
|
"<x>{{title}}|{{content}}</x>",
|
|
)
|
|
.unwrap();
|
|
let ast = parse("%title T\n= H =\nbody\n");
|
|
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>"));
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_css_writes_default_when_missing() {
|
|
let root = temp_root("css");
|
|
let mut c = WikiConfig::from_root(root.clone());
|
|
c.html = HtmlConfig::from_root(&root);
|
|
let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
|
|
assert!(res.created);
|
|
assert!(res.path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_css_is_idempotent_when_present() {
|
|
let root = temp_root("css2");
|
|
let mut c = WikiConfig::from_root(root.clone());
|
|
c.html = HtmlConfig::from_root(&root);
|
|
nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
|
|
let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
|
|
assert!(!res.created);
|
|
}
|
|
|
|
// ===== RSS =====
|
|
|
|
#[test]
|
|
fn write_rss_emits_valid_xml_with_entries() {
|
|
use nuwiki_lsp::diary::DiaryEntry;
|
|
use tower_lsp::lsp_types::Url;
|
|
|
|
let root = temp_root("rss");
|
|
let mut c = WikiConfig::from_root(root.clone());
|
|
c.html = HtmlConfig::from_root(&root);
|
|
c.name = "Test Wiki".into();
|
|
let entries = vec![
|
|
DiaryEntry {
|
|
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
|
uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(),
|
|
},
|
|
DiaryEntry {
|
|
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
|
|
uri: Url::parse("file:///tmp/diary/2026-05-10.wiki").unwrap(),
|
|
},
|
|
];
|
|
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
|
|
let xml = std::fs::read_to_string(&path).unwrap();
|
|
assert!(xml.starts_with("<?xml"));
|
|
assert!(xml.contains("<title>Test Wiki</title>"));
|
|
// Newest first.
|
|
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 link points at the diary index page (upstream fidelity); item
|
|
// link is the public per-entry URL.
|
|
assert!(
|
|
xml.contains("<link>https://example.com/wiki/diary/diary.html</link>"),
|
|
"channel link: {xml}"
|
|
);
|
|
assert!(xml.contains("<link>https://example.com/wiki/diary/2026-05-11.html</link>"));
|
|
// guid is the bare date, isPermaLink=false.
|
|
assert!(
|
|
xml.contains("<guid isPermaLink=\"false\">2026-05-11</guid>"),
|
|
"guid: {xml}"
|
|
);
|
|
// atom:link self + a pubDate are present.
|
|
assert!(xml.contains("rel=\"self\""), "atom:link: {xml}");
|
|
assert!(xml.contains("<pubDate>"), "pubDate: {xml}");
|
|
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]
|
|
fn write_rss_honours_rss_name_and_max_items() {
|
|
use nuwiki_lsp::diary::DiaryEntry;
|
|
use tower_lsp::lsp_types::Url;
|
|
let root = temp_root("rss_cap");
|
|
let mut c = WikiConfig::from_root(root.clone());
|
|
c.html = HtmlConfig::from_root(&root);
|
|
c.html.rss_name = "feed.xml".into();
|
|
c.html.rss_max_items = 2;
|
|
let entries: Vec<DiaryEntry> = (1..=5)
|
|
.map(|d| DiaryEntry {
|
|
date: DiaryDate::from_ymd(2026, 5, d).unwrap(),
|
|
uri: Url::parse(&format!("file:///tmp/diary/2026-05-0{d}.wiki")).unwrap(),
|
|
})
|
|
.collect();
|
|
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
|
|
assert!(path.ends_with("feed.xml"), "rss_name: {path:?}");
|
|
let xml = std::fs::read_to_string(&path).unwrap();
|
|
// Capped at 2 items (newest first: 05-05, 05-04).
|
|
assert_eq!(xml.matches("<item>").count(), 2, "cap: {xml}");
|
|
assert!(xml.contains("2026-05-05") && xml.contains("2026-05-04"));
|
|
assert!(!xml.contains("2026-05-03"), "older items dropped: {xml}");
|
|
}
|
|
|
|
#[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 =====
|
|
|
|
#[test]
|
|
fn commands_list_includes_export_entries() {
|
|
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
|
for name in [
|
|
"nuwiki.export.currentToHtml",
|
|
"nuwiki.export.allToHtml",
|
|
"nuwiki.export.allToHtmlForce",
|
|
"nuwiki.export.browse",
|
|
"nuwiki.export.rss",
|
|
] {
|
|
assert!(names.contains(&name), "missing: {name}");
|
|
}
|
|
}
|