//! 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
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 =\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() {
// ``/`` are in the default valid_html_tags → verbatim; `\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("H2O"), "sub verbatim: {html}");
assert!(html.contains("Ctrl"), "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}}{{content}}";
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("My Page"));
assert!(html.contains(""));
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("
1. Intro
"), "got: {html}");
assert!(html.contains("
1.1. Background
"), "got: {html}");
assert!(html.contains("
1.2. Methods
"), "got: {html}");
assert!(html.contains("
2. Results
"), "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("