Files
nuwiki/crates/nuwiki-lsp/tests/html_export.rs
T
gffranco 21b485c91b Remove stale phase/spec references from code comments
Strip "Phase N", "SPEC §X.Y", and "v1.x" planning labels from comments
across the Rust crates and editor layers now that those tracking
artifacts are gone. Comments only — no logic, strings, or test names
touched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:25:51 -03:00

489 lines
15 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());
}
#[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 &amp; B &lt;ok&gt;"));
}
// ===== 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_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_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).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).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).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);
}
// ===== COMMANDS list completeness =====
#[test]
fn commands_list_includes_phase17_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}");
}
}