//! 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