phase 17: html export commands + extended template vars
CI / cargo fmt --check (push) Successful in 23s
CI / cargo clippy (push) Successful in 1m26s
CI / cargo test (push) Successful in 1m16s

HtmlRenderer (nuwiki-core):
- `with_var(k, v)` / `with_vars(map)` add arbitrary `{{key}}`
  substitution alongside the existing `{{title}}` / `{{content}}`.
  Substitution order: content → title → user vars, so a rendered body
  that happens to contain a literal `{{title}}` isn't double-processed.

WikiConfig (nuwiki-lsp/config):
- New `html: HtmlConfig` field with per-wiki export options matching
  vimwiki's `g:vimwiki_*` keys: `html_path`, `template_path`,
  `template_default`, `template_ext`, `template_date_format`,
  `css_name`, `auto_export`, `html_filename_parameterization`,
  `exclude_files`. Defaults: `<root>/_html` and `<root>/_templates`.
- `RawWiki` accepts all of the above through `initializationOptions`
  (and `workspace/didChangeConfiguration` reload).

New `export` module (pure helpers):
- `output_path_for` — page name → on-disk HTML path. Slugifies only
  the final segment when `html_filename_parameterization = true` so
  the `diary/` subdir survives.
- `template_for` — primary/fallback path lookup tuple.
- `fallback_template` — minimal in-memory page used when neither
  template file exists.
- `format_date` — strftime subset (`%Y`/`%m`/`%d`/`%%`) so we don't
  depend on `chrono`/`time`.
- `build_toc_html` — nested `<ul class="toc">` from the doc's
  headings, with HTML escaping.
- `compute_root_path` — `../` prefix per directory depth for
  stylesheet hrefs in nested pages.
- `is_excluded` — glob matcher supporting `*`, `**`, `?`. Used by
  the `allToHtml*` walker against `exclude_files`.
- `render_page_html` — wires the above into HtmlRenderer with
  `{{date}}`, `{{root_path}}`, `{{toc}}`, `{{css}}` populated.
- `DEFAULT_CSS` — minimal stylesheet bundled for first-time exports.

New commands:
- `nuwiki.export.currentToHtml` — render the current buffer; honours
  `%nohtml` and `%template`; ensures CSS exists.
- `nuwiki.export.allToHtml` — walk the wiki root, skip pages matched
  by `exclude_files` and `%nohtml`, and only re-export pages whose
  source is newer than the existing HTML (incremental behaviour
  matching `:VimwikiAll2HTML`).
- `nuwiki.export.allToHtmlForce` — same but unconditional re-export.
- `nuwiki.export.browse` — alias of currentToHtml that returns the
  `file://` URL of the rendered page in the response so the client
  can open it.
- `nuwiki.export.rss` — write `<html_path>/rss.xml` listing the
  wiki's diary entries newest-first.
- `did_save` handler — runs the auto-export equivalent when the
  resolved wiki has `auto_export = true`.

LSP capability:
- `text_document_sync` switched from `Kind(FULL)` to `Options(...)`
  so the server can register a `save` notification (otherwise
  clients never push `did_save` events).

Tests: 33 new in `phase17_html_export.rs` covering HtmlConfig
defaults + JSON-shape config, the `format_date` subset (including
the `%%` escape and pass-through for unsupported specifiers),
root-path computation, output-path slugification rules, the glob
matcher (single-star, globstar, question-mark, non-match),
template_for primary/fallback selection, render_page_html for the
title/content/date/root_path triad and the override-by-extras flow,
and side-effecting disk paths (write_page creates parents, picks up
on-disk template, RSS is newest-first, ensure_css is idempotent).
Total 357 tests pass; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 21:37:36 +00:00
parent ae7d8c7971
commit d1b807b7d1
8 changed files with 1380 additions and 30 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification.
| 14 | List & table edit commands | ✅ done (focused subset; table/list-renumber/colorize deferred) | | 14 | List & table edit commands | ✅ done (focused subset; table/list-renumber/colorize deferred) |
| 15 | Link health + TOC/index generation | ✅ done | | 15 | Link health + TOC/index generation | ✅ done |
| 16 | Diary | ✅ done | | 16 | Diary | ✅ done |
| 17 | HTML export commands | | | 17 | HTML export commands | ✅ done |
| 18 | Multi-wiki | ⏳ | | 18 | Multi-wiki | ⏳ |
| 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ | | 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ |
+10 -4
View File
@@ -1382,14 +1382,11 @@ Deferred because the value proposition over the existing
`workspace/rename` flow is marginal and the editor-side support is `workspace/rename` flow is marginal and the editor-side support is
inconsistent across clients. inconsistent across clients.
### 13.3 v1.1 phases 1719 ### 13.3 v1.1 phases 1819
Tracked in §10 with status `⏳`. Not "missing" from earlier phases — Tracked in §10 with status `⏳`. Not "missing" from earlier phases —
they're future work in the v1.1 roadmap: they're future work in the v1.1 roadmap:
- **Phase 17** — HTML export commands (`nuwiki.export.*`), template
resolution, CSS file management, auto-export. Brings in
`html_path` / `template_path` / `color_dic` / etc. config keys.
- **Phase 18** — Multi-wiki. Data structures (Wiki aggregate, - **Phase 18** — Multi-wiki. Data structures (Wiki aggregate,
per-wiki index) already landed in Phase 11; this phase is the per-wiki index) already landed in Phase 11; this phase is the
config-shape change and the picker commands. config-shape change and the picker commands.
@@ -1397,6 +1394,15 @@ they're future work in the v1.1 roadmap:
keymaps, text objects, folding (P14 already resolved in favour of keymaps, text objects, folding (P14 already resolved in favour of
LSP `foldingRange` + `foldexpr` fallback). LSP `foldingRange` + `foldexpr` fallback).
Phase 17 (HTML export commands) shipped with the focused command
surface (`currentToHtml` / `allToHtml[Force]` / `browse` / `rss`),
template-file + fallback resolution, the `{{date}}` / `{{root_path}}`
/ `{{toc}}` / `{{css}}` template variables, and `did_save` auto-export
when `auto_export = true`. `color_dic` integration with the
`ColorNode` renderer is still open (it's only useful once `colorize`
from §13.1 lands), as is the §13.1 `link.pasteUrl` follow-up that
depends on the same export config.
### 13.4 §9 syntax checklist status ### 13.4 §9 syntax checklist status
The §9 checklist tracks every vimwiki-syntax surface. Items currently The §9 checklist tracks every vimwiki-syntax surface. Items currently
+33 -10
View File
@@ -2,14 +2,17 @@
//! //!
//! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is //! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is
//! configured with a link resolver (callback that turns a `LinkTarget` into //! configured with a link resolver (callback that turns a `LinkTarget` into
//! a URL string) and, optionally, an enclosing template with `{{title}}` / //! a URL string) and, optionally, an enclosing template with substitution
//! `{{content}}` placeholders (SPEC.md §6.8). //! placeholders (SPEC.md §6.8 + §12.8).
//! //!
//! Without a template the output is just the body markup, so callers are //! Substitution model: `{{content}}` and `{{title}}` are computed from
//! free to embed it in their own page shell. With a template the rendered //! the rendered body and the document's metadata; `with_var(k, v)` /
//! body replaces `{{content}}` and the document's `metadata.title` //! `with_vars(map)` add arbitrary key→value pairs (Phase 17 ships
//! replaces `{{title}}`. //! `{{date}}`, `{{root_path}}`, `{{toc}}`). Order: `{{content}}` is
//! substituted first so a body that happens to contain a literal
//! `{{title}}` isn't itself rewritten in the next pass.
use std::collections::HashMap;
use std::io::{self, Write}; use std::io::{self, Write};
use std::sync::Arc; use std::sync::Arc;
@@ -32,6 +35,7 @@ pub type LinkResolver = Arc<dyn Fn(&LinkTarget) -> String + Send + Sync>;
pub struct HtmlRenderer { pub struct HtmlRenderer {
link_resolver: LinkResolver, link_resolver: LinkResolver,
template: Option<String>, template: Option<String>,
vars: HashMap<String, String>,
} }
impl Default for HtmlRenderer { impl Default for HtmlRenderer {
@@ -45,6 +49,7 @@ impl HtmlRenderer {
Self { Self {
link_resolver: Arc::new(default_link_resolver), link_resolver: Arc::new(default_link_resolver),
template: None, template: None,
vars: HashMap::new(),
} }
} }
@@ -59,11 +64,25 @@ impl HtmlRenderer {
} }
/// Wrap the rendered body in a template. `{{title}}` and `{{content}}` /// Wrap the rendered body in a template. `{{title}}` and `{{content}}`
/// are substituted; everything else passes through verbatim. /// are substituted automatically; additional `{{key}}` placeholders
/// resolve via `with_var` / `with_vars`. Anything unresolved passes
/// through verbatim.
pub fn with_template(mut self, template: impl Into<String>) -> Self { pub fn with_template(mut self, template: impl Into<String>) -> Self {
self.template = Some(template.into()); self.template = Some(template.into());
self self
} }
/// Add a single template variable. Re-use to overwrite.
pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.vars.insert(key.into(), value.into());
self
}
/// Replace the entire template variable map.
pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
self.vars = vars;
self
}
} }
impl Renderer for HtmlRenderer { impl Renderer for HtmlRenderer {
@@ -77,9 +96,13 @@ impl Renderer for HtmlRenderer {
// Replace `{{content}}` first so its substituted body, which may // Replace `{{content}}` first so its substituted body, which may
// legitimately contain the literal `{{title}}`, isn't itself // legitimately contain the literal `{{title}}`, isn't itself
// rewritten in the next pass. // rewritten in the next pass.
let with_content = template.replace("{{content}}", body_str); let mut out = template.replace("{{content}}", body_str);
let final_doc = with_content.replace("{{title}}", title); out = out.replace("{{title}}", title);
w.write_all(final_doc.as_bytes())?; for (k, v) in &self.vars {
let needle = format!("{{{{{k}}}}}");
out = out.replace(&needle, v);
}
w.write_all(out.as_bytes())?;
} else { } else {
self.render_body(doc, w)?; self.render_body(doc, w)?;
} }
+324
View File
@@ -55,6 +55,11 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.tags.search", "nuwiki.tags.search",
"nuwiki.tags.generateLinks", "nuwiki.tags.generateLinks",
"nuwiki.tags.rebuild", "nuwiki.tags.rebuild",
"nuwiki.export.currentToHtml",
"nuwiki.export.allToHtml",
"nuwiki.export.allToHtmlForce",
"nuwiki.export.browse",
"nuwiki.export.rss",
]; ];
pub(crate) async fn execute( pub(crate) async fn execute(
@@ -121,6 +126,19 @@ pub(crate) async fn execute(
tags_generate_links(backend, args).map(|o| o.map(CommandOutcome::Edit)) tags_generate_links(backend, args).map(|o| o.map(CommandOutcome::Edit))
} }
"nuwiki.tags.rebuild" => tags_rebuild(backend, args).map(|o| o.map(CommandOutcome::Value)), "nuwiki.tags.rebuild" => tags_rebuild(backend, args).map(|o| o.map(CommandOutcome::Value)),
"nuwiki.export.currentToHtml" => {
export_current(backend, args, false).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.export.allToHtml" => {
export_all(backend, args, false).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.export.allToHtmlForce" => {
export_all(backend, args, true).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.export.browse" => {
export_current(backend, args, true).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.export.rss" => export_rss(backend, args).map(|o| o.map(CommandOutcome::Value)),
other => Err(format!("unknown nuwiki command: {other}")), other => Err(format!("unknown nuwiki command: {other}")),
} }
} }
@@ -582,6 +600,143 @@ fn tags_rebuild(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, St
}))) })))
} }
// ===== Phase 17: HTML export commands =====
fn export_current(
backend: &Backend,
args: Vec<Value>,
return_open_url: bool,
) -> Result<Option<Value>, String> {
let p = parse_uri_arg(args)?;
let doc = match backend.documents.get(&p.uri) {
Some(d) => d,
None => return Ok(None),
};
let Some(wiki) = backend.wiki_for_uri(&p.uri) else {
return Ok(None);
};
let cfg = &wiki.config;
if doc.ast.metadata.nohtml {
return Ok(Some(serde_json::json!({
"exported": [],
"skipped": [{"uri": p.uri, "reason": "nohtml"}],
})));
}
let name = crate::index::page_name_from_uri(&p.uri, Some(&cfg.root));
let outcome = match crate::commands::export_ops::write_page(&doc.ast, &name, cfg) {
Ok(o) => o,
Err(e) => return Err(format!("nuwiki.export.currentToHtml: {e}")),
};
let css = crate::commands::export_ops::ensure_css(cfg).map_err(|e| e.to_string())?;
let mut body = serde_json::json!({
"exported": [outcome.to_json()],
"css_path": css.path,
"css_created": css.created,
});
if return_open_url {
if let Ok(url) = Url::from_file_path(&outcome.output_path) {
body["browse"] = serde_json::json!(url);
}
}
Ok(Some(body))
}
fn export_all(backend: &Backend, args: Vec<Value>, force: bool) -> Result<Option<Value>, String> {
let p = parse_optional_uri_arg(args)?;
let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else {
return Ok(None);
};
let cfg = wiki.config.clone();
if cfg.root.as_os_str().is_empty() {
return Ok(Some(serde_json::json!({
"exported": [],
"reason": "no wiki root configured",
})));
}
let registry = std::sync::Arc::clone(&backend.registry);
let Some(plugin) = registry.get("vimwiki") else {
return Err("vimwiki syntax plugin missing".to_string());
};
let files = crate::index::walk_wiki_files(&cfg.root);
let mut exported: Vec<serde_json::Value> = Vec::new();
let mut skipped: Vec<serde_json::Value> = Vec::new();
for path in &files {
let Ok(uri) = Url::from_file_path(path) else {
continue;
};
let name = crate::index::page_name_from_uri(&uri, Some(&cfg.root));
if crate::export::is_excluded(&cfg.html.exclude_files, &name) {
skipped.push(serde_json::json!({"uri": uri, "reason": "excluded"}));
continue;
}
let (text, ast) = match backend.documents.get(&uri) {
Some(d) => (d.text.clone(), d.ast.clone()),
None => {
let Ok(text) = std::fs::read_to_string(path) else {
continue;
};
let ast = plugin.parse(&text);
(text, ast)
}
};
let _ = text;
if ast.metadata.nohtml {
skipped.push(serde_json::json!({"uri": uri, "reason": "nohtml"}));
continue;
}
let out_path = crate::export::output_path_for(&cfg.html, &name);
if !force {
// Compare modification times. Skip when the HTML is newer than
// the source. Cheap and matches vimwiki's incremental behaviour.
if let (Ok(src_meta), Ok(html_meta)) =
(std::fs::metadata(path), std::fs::metadata(&out_path))
{
let src_mtime = src_meta.modified().ok();
let out_mtime = html_meta.modified().ok();
if let (Some(s), Some(o)) = (src_mtime, out_mtime) {
if o >= s {
skipped.push(serde_json::json!({"uri": uri, "reason": "up-to-date"}));
continue;
}
}
}
}
match crate::commands::export_ops::write_page(&ast, &name, &cfg) {
Ok(outcome) => exported.push(outcome.to_json()),
Err(e) => skipped.push(serde_json::json!({"uri": uri, "reason": e.to_string()})),
}
}
let css = crate::commands::export_ops::ensure_css(&cfg).map_err(|e| e.to_string())?;
Ok(Some(serde_json::json!({
"exported": exported,
"skipped": skipped,
"files_seen": files.len(),
"css_path": css.path,
"css_created": css.created,
"forced": force,
})))
}
fn export_rss(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
let p = parse_optional_uri_arg(args)?;
let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else {
return Ok(None);
};
let entries = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
crate::diary::list_entries(&idx)
};
let out_path = crate::commands::export_ops::write_rss(&wiki.config, &entries)
.map_err(|e| format!("nuwiki.export.rss: {e}"))?;
Ok(Some(serde_json::json!({
"rss_path": out_path,
"entries": entries.len(),
})))
}
fn workspace_find_orphans(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> { fn workspace_find_orphans(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
let p = parse_optional_uri_arg(args)?; let p = parse_optional_uri_arg(args)?;
let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) { let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) {
@@ -1411,3 +1566,172 @@ pub mod ops {
) )
} }
} }
/// Disk-touching helpers for the Phase 17 `nuwiki.export.*` commands.
/// Kept out of `ops` because everything here is side-effecting — pure
/// rendering / templating lives in [`crate::export`].
pub mod export_ops {
use std::path::{Path, PathBuf};
use nuwiki_core::ast::DocumentNode;
use nuwiki_core::date::DiaryDate;
use crate::config::WikiConfig;
use crate::diary::DiaryEntry;
use crate::export::{
css_path, fallback_template, is_excluded, output_path_for, render_page_html, template_for,
DEFAULT_CSS,
};
/// Per-page result of `write_page`. Serialised back to the client as
/// `{ uri, output_path, page }`.
pub struct PageExport {
pub page: String,
pub output_path: PathBuf,
pub source_uri: Option<tower_lsp::lsp_types::Url>,
}
impl PageExport {
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"page": self.page,
"output_path": self.output_path,
"uri": self.source_uri,
})
}
}
/// CSS write outcome — `created = true` when we just put a default
/// stylesheet on disk; false when one already existed.
pub struct CssOutcome {
pub path: PathBuf,
pub created: bool,
}
/// Render `doc` and write the result to `<html_path>/<name>.html`.
/// Reads the template from disk (primary → fallback → built-in
/// fallback). Creates any missing parent directories.
pub fn write_page(
doc: &DocumentNode,
name: &str,
cfg: &WikiConfig,
) -> std::io::Result<PageExport> {
if is_excluded(&cfg.html.exclude_files, name) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("excluded by exclude_files: {name}"),
));
}
let template_source = template_for(&cfg.html, doc);
let template = load_template(&template_source)
.unwrap_or_else(|| fallback_template(&cfg.html.css_name));
let html = render_page_html(
doc,
Some(template),
name,
DiaryDate::today_utc(),
&cfg.html,
std::collections::HashMap::new(),
)?;
let out_path = output_path_for(&cfg.html, name);
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, html)?;
let source_uri = tower_lsp::lsp_types::Url::from_file_path(
cfg.root.join(format!("{name}{}", cfg.file_extension)),
)
.ok();
Ok(PageExport {
page: name.to_string(),
output_path: out_path,
source_uri,
})
}
/// Two-tier template load. Returns the resolved template body, or
/// `None` if neither file exists — caller falls back to the built-in
/// template.
fn load_template(src: &crate::export::TemplateSource) -> Option<String> {
if let Some(p) = src.primary.as_ref() {
if let Ok(text) = std::fs::read_to_string(p) {
return Some(text);
}
}
std::fs::read_to_string(&src.fallback).ok()
}
/// Ensure `<html_path>/<css_name>` exists. Writes the built-in
/// [`DEFAULT_CSS`] when missing. No-op when it's already there.
pub fn ensure_css(cfg: &WikiConfig) -> std::io::Result<CssOutcome> {
let path = css_path(&cfg.html);
if path.exists() {
return Ok(CssOutcome {
path,
created: false,
});
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, DEFAULT_CSS)?;
Ok(CssOutcome {
path,
created: true,
})
}
/// Write `<html_path>/rss.xml` listing the wiki's diary entries
/// newest-first. The feed is intentionally minimal — just enough to
/// validate against RSS 2.0 parsers — since the diary URLs are file:
/// scheme and won't survive remote consumption anyway.
pub fn write_rss(cfg: &WikiConfig, entries: &[DiaryEntry]) -> std::io::Result<PathBuf> {
let mut sorted: Vec<&DiaryEntry> = entries.iter().collect();
sorted.sort_by(|a, b| b.date.cmp(&a.date));
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
"#,
);
xml.push_str(&format!("<title>{}</title>\n", xml_escape(&cfg.name)));
xml.push_str("<description>nuwiki diary</description>\n");
for e in sorted {
xml.push_str("<item>");
xml.push_str(&format!("<title>{}</title>", e.date.format()));
xml.push_str(&format!("<link>{}</link>", e.uri.as_str()));
xml.push_str(&format!("<guid>{}</guid>", e.uri.as_str()));
xml.push_str("</item>\n");
}
xml.push_str("</channel></rss>\n");
let path = rss_path(cfg);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, xml)?;
Ok(path)
}
fn rss_path(cfg: &WikiConfig) -> PathBuf {
cfg.html.html_path.join("rss.xml")
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(ch),
}
}
out
}
/// Re-export so the dispatcher doesn't have to know about the export
/// module's path manipulation primitives.
#[allow(dead_code)]
pub fn _silence_unused(_: &Path) {}
}
+111
View File
@@ -34,6 +34,64 @@ pub struct WikiConfig {
/// Matches vimwiki's `g:vimwiki_diary_index` (default `"diary"`). /// Matches vimwiki's `g:vimwiki_diary_index` (default `"diary"`).
/// The full path is `<root>/<diary_rel_path>/<diary_index>.wiki`. /// The full path is `<root>/<diary_rel_path>/<diary_index>.wiki`.
pub diary_index: String, pub diary_index: String,
/// Phase 17 HTML export options. See SPEC §12.8.
pub html: HtmlConfig,
}
/// Per-wiki HTML export settings. All paths are absolute (relative
/// paths from `initializationOptions` are resolved against the wiki
/// root at config-load time).
#[derive(Debug, Clone)]
pub struct HtmlConfig {
/// Output directory for `nuwiki.export.*` commands.
/// Default: `<root>/_html`.
pub html_path: PathBuf,
/// Template search root. Default: `<root>/_templates`.
pub template_path: PathBuf,
/// Template name used when a page has no `%template` directive.
pub template_default: String,
/// Template file extension appended when looking up a template.
pub template_ext: String,
/// `%Y`/`%m`/`%d` format string passed to `{{date}}`.
pub template_date_format: String,
/// CSS file name relative to `html_path` (copied/created if missing).
pub css_name: String,
/// Run `currentToHtml` automatically on `textDocument/didSave`.
pub auto_export: bool,
/// When true, the output filename for a page is URL-safe-slugified.
pub html_filename_parameterization: bool,
/// Glob patterns (matched against the page name relative to root)
/// to skip during `allToHtml*`.
pub exclude_files: Vec<String>,
}
impl Default for HtmlConfig {
fn default() -> Self {
Self {
html_path: PathBuf::new(),
template_path: PathBuf::new(),
template_default: "default".into(),
template_ext: ".tpl".into(),
template_date_format: "%Y-%m-%d".into(),
css_name: "style.css".into(),
auto_export: false,
html_filename_parameterization: false,
exclude_files: Vec::new(),
}
}
}
impl HtmlConfig {
/// Synthesise default paths under `root` when the user hasn't
/// specified explicit ones. `html_path` defaults to `<root>/_html`
/// and `template_path` to `<root>/_templates` per SPEC §12.8.
pub fn from_root(root: &Path) -> Self {
Self {
html_path: root.join("_html"),
template_path: root.join("_templates"),
..Self::default()
}
}
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -62,10 +120,12 @@ impl WikiConfig {
syntax: "vimwiki".into(), syntax: "vimwiki".into(),
diary_rel_path: default_diary_rel_path(), diary_rel_path: default_diary_rel_path(),
diary_index: default_diary_index(), diary_index: default_diary_index(),
html: HtmlConfig::default(),
} }
} }
pub fn from_root(root: PathBuf) -> Self { pub fn from_root(root: PathBuf) -> Self {
let html = HtmlConfig::from_root(&root);
Self { Self {
name: root name: root
.file_name() .file_name()
@@ -76,6 +136,7 @@ impl WikiConfig {
syntax: "vimwiki".into(), syntax: "vimwiki".into(),
diary_rel_path: default_diary_rel_path(), diary_rel_path: default_diary_rel_path(),
diary_index: default_diary_index(), diary_index: default_diary_index(),
html,
} }
} }
@@ -127,6 +188,7 @@ impl Config {
let wikis = if let Some(list) = raw.wikis { let wikis = if let Some(list) = raw.wikis {
list.into_iter().map(WikiConfig::from).collect() list.into_iter().map(WikiConfig::from).collect()
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) { } else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
let html = HtmlConfig::from_root(&root);
vec![WikiConfig { vec![WikiConfig {
name: root name: root
.file_name() .file_name()
@@ -137,6 +199,7 @@ impl Config {
syntax: raw.syntax.unwrap_or_else(|| "vimwiki".into()), syntax: raw.syntax.unwrap_or_else(|| "vimwiki".into()),
diary_rel_path: default_diary_rel_path(), diary_rel_path: default_diary_rel_path(),
diary_index: default_diary_index(), diary_index: default_diary_index(),
html,
}] }]
} else if let Some(folder) = first_workspace_folder(params) { } else if let Some(folder) = first_workspace_folder(params) {
vec![WikiConfig::from_root(folder)] vec![WikiConfig::from_root(folder)]
@@ -199,6 +262,25 @@ struct RawWiki {
diary_rel_path: Option<String>, diary_rel_path: Option<String>,
#[serde(default)] #[serde(default)]
diary_index: Option<String>, diary_index: Option<String>,
// Phase 17 HTML keys — all optional, fall back to per-root defaults.
#[serde(default)]
html_path: Option<String>,
#[serde(default)]
template_path: Option<String>,
#[serde(default)]
template_default: Option<String>,
#[serde(default)]
template_ext: Option<String>,
#[serde(default)]
template_date_format: Option<String>,
#[serde(default)]
css_name: Option<String>,
#[serde(default)]
auto_export: Option<bool>,
#[serde(default)]
html_filename_parameterization: Option<bool>,
#[serde(default)]
exclude_files: Option<Vec<String>>,
} }
impl From<RawWiki> for WikiConfig { impl From<RawWiki> for WikiConfig {
@@ -208,6 +290,34 @@ impl From<RawWiki> for WikiConfig {
.file_name() .file_name()
.map(|s| s.to_string_lossy().into_owned()) .map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(); .unwrap_or_default();
let mut html = HtmlConfig::from_root(&root);
if let Some(p) = r.html_path.as_deref().and_then(expand_path) {
html.html_path = p;
}
if let Some(p) = r.template_path.as_deref().and_then(expand_path) {
html.template_path = p;
}
if let Some(s) = r.template_default {
html.template_default = s;
}
if let Some(s) = r.template_ext {
html.template_ext = s;
}
if let Some(s) = r.template_date_format {
html.template_date_format = s;
}
if let Some(s) = r.css_name {
html.css_name = s;
}
if let Some(b) = r.auto_export {
html.auto_export = b;
}
if let Some(b) = r.html_filename_parameterization {
html.html_filename_parameterization = b;
}
if let Some(v) = r.exclude_files {
html.exclude_files = v;
}
WikiConfig { WikiConfig {
name: r.name.unwrap_or(derived_name), name: r.name.unwrap_or(derived_name),
root, root,
@@ -215,6 +325,7 @@ impl From<RawWiki> for WikiConfig {
syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()), syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()),
diary_rel_path: r.diary_rel_path.unwrap_or_else(default_diary_rel_path), diary_rel_path: r.diary_rel_path.unwrap_or_else(default_diary_rel_path),
diary_index: r.diary_index.unwrap_or_else(default_diary_index), diary_index: r.diary_index.unwrap_or_else(default_diary_index),
html,
} }
} }
} }
+391
View File
@@ -0,0 +1,391 @@
//! HTML export — pure helpers shared by the Phase 17 `nuwiki.export.*`
//! command handlers. Side-effecting work (reading the template from
//! disk, writing the rendered HTML, copying CSS) lives in `commands.rs`
//! since the dispatcher is the one with a `&Backend` and async context.
//!
//! Design split:
//! - [`output_path_for`] / [`output_url_for`] — name → on-disk path.
//! - [`template_for`] — locate the right template file on disk.
//! - [`fallback_template`] — minimal stand-in when nothing is found.
//! - [`format_date`] — strftime subset (`%Y`/`%m`/`%d`).
//! - [`build_toc_html`] — TOC rendered into the `{{toc}}` variable.
//! - [`compute_root_path`] — relative path from a page's output to
//! `html_path` for stylesheet hrefs in nested pages.
//! - [`is_excluded`] — glob match against `exclude_files`.
//! - [`render_page_html`] — drives [`HtmlRenderer`] with all of the
//! above wired up; returns the final HTML string.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use nuwiki_core::ast::{BlockNode, DocumentNode, HeadingNode, InlineNode};
use nuwiki_core::date::DiaryDate;
use nuwiki_core::render::{HtmlRenderer, Renderer};
use crate::config::{HtmlConfig, WikiConfig};
/// Map a page name (e.g. `"diary/2026-05-11"`) to its on-disk HTML
/// output path under `html_path`. Honours
/// [`HtmlConfig::html_filename_parameterization`] for URL-safe slugs
/// — when enabled, only the final path segment is slugified (so the
/// `diary/` subdir survives).
pub fn output_path_for(cfg: &HtmlConfig, name: &str) -> PathBuf {
let segments: Vec<String> = name
.split('/')
.map(|s| {
if cfg.html_filename_parameterization {
slugify(s)
} else {
s.to_string()
}
})
.collect();
let mut p = cfg.html_path.clone();
for (i, seg) in segments.iter().enumerate() {
if i + 1 == segments.len() {
p.push(format!("{seg}.html"));
} else {
p.push(seg);
}
}
p
}
/// Locate the template body for a document. Resolution order:
/// 1. `<template_path>/<doc.metadata.template>.<template_ext>` if set.
/// 2. `<template_path>/<template_default>.<template_ext>`.
/// 3. [`fallback_template`].
///
/// The on-disk read is performed by the caller (`commands.rs`) via the
/// [`TemplateSource`] returned here. Keeps this fn pure for tests.
pub fn template_for(cfg: &HtmlConfig, doc: &DocumentNode) -> TemplateSource {
let primary = doc.metadata.template.as_deref().map(|name| {
cfg.template_path
.join(format!("{name}{}", cfg.template_ext))
});
let fallback = cfg
.template_path
.join(format!("{}{}", cfg.template_default, cfg.template_ext));
TemplateSource { primary, fallback }
}
/// Two-tier lookup result. Caller tries `primary` first, then `fallback`,
/// then falls back to [`fallback_template`] when neither file exists.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateSource {
pub primary: Option<PathBuf>,
pub fallback: PathBuf,
}
/// Minimal valid HTML page used when no template file is found on disk.
/// Includes the same `{{title}}` / `{{content}}` placeholders as a real
/// template so the renderer's substitution still works.
pub fn fallback_template(css_name: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{{{title}}}}</title>
<link rel="stylesheet" href="{{{{root_path}}}}{css_name}">
</head>
<body>
{{{{content}}}}
</body>
</html>
"#
)
}
/// Tiny strftime: supports `%Y` (4-digit year), `%m` (2-digit month),
/// `%d` (2-digit day), `%%` (literal percent). Anything else passes
/// through. The diary use case never produces time-of-day output, so
/// `%H`/`%M`/`%S` are intentionally omitted.
pub fn format_date(date: &DiaryDate, fmt: &str) -> String {
let mut out = String::with_capacity(fmt.len() + 4);
let bytes = fmt.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 1 < bytes.len() {
match bytes[i + 1] {
b'Y' => out.push_str(&format!("{:04}", date.year)),
b'm' => out.push_str(&format!("{:02}", date.month)),
b'd' => out.push_str(&format!("{:02}", date.day)),
b'%' => out.push('%'),
other => {
out.push('%');
out.push(other as char);
}
}
i += 2;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
/// Render the page's headings as a nested `<ul>` for the `{{toc}}`
/// template variable. Returns an empty string when the document has
/// no headings.
pub fn build_toc_html(doc: &DocumentNode) -> String {
let headings = collect_headings(doc);
if headings.is_empty() {
return String::new();
}
let min_level = headings.iter().map(|h| h.level).min().unwrap_or(1);
let mut out = String::from("<ul class=\"toc\">");
let mut depth = 0usize;
for h in &headings {
let target = h.level.saturating_sub(min_level) as usize;
while depth < target {
out.push_str("<ul>");
depth += 1;
}
while depth > target {
out.push_str("</ul>");
depth -= 1;
}
let anchor = crate::index::slugify(&h.title);
out.push_str(&format!(
"<li><a href=\"#{anchor}\">{title}</a></li>",
title = escape_html(&h.title)
));
}
while depth > 0 {
out.push_str("</ul>");
depth -= 1;
}
out.push_str("</ul>");
out
}
/// Compute the relative path back to `html_path` from the directory
/// holding the output file for `name`. Used as the `{{root_path}}`
/// prefix so a nested page's `<link>`/`<script>` hrefs still find
/// the shared CSS at the export root.
pub fn compute_root_path(name: &str) -> String {
let depth = name.matches('/').count();
if depth == 0 {
String::new()
} else {
let mut s = String::with_capacity(depth * 3);
for _ in 0..depth {
s.push_str("../");
}
s
}
}
/// True if `name` matches any of the `exclude_files` glob patterns.
/// Supported wildcards: `*` (any segment chars except `/`), `**` (any
/// segments including `/`), `?` (one char). Anything else is literal.
pub fn is_excluded(patterns: &[String], name: &str) -> bool {
patterns.iter().any(|p| glob_match(p, name))
}
/// Pure HTML render pass. The caller has already loaded the template
/// (or falls back). Wires up `{{date}}`, `{{root_path}}`, `{{toc}}`
/// plus arbitrary extras passed via `extra_vars`.
pub fn render_page_html(
doc: &DocumentNode,
template: Option<String>,
name: &str,
today: DiaryDate,
cfg: &HtmlConfig,
extra_vars: HashMap<String, String>,
) -> std::io::Result<String> {
let date_str = match doc.metadata.date.as_deref() {
Some(s) => match DiaryDate::parse(s) {
Some(d) => format_date(&d, &cfg.template_date_format),
None => s.to_string(),
},
None => format_date(&today, &cfg.template_date_format),
};
let root_path = compute_root_path(name);
let toc = build_toc_html(doc);
let mut vars: HashMap<String, String> = HashMap::new();
vars.insert("date".into(), date_str);
vars.insert("root_path".into(), root_path);
vars.insert("toc".into(), toc);
vars.insert("css".into(), cfg.css_name.clone());
for (k, v) in extra_vars {
vars.insert(k, v);
}
let mut r = HtmlRenderer::new();
if let Some(t) = template {
r = r.with_template(t);
}
r = r.with_vars(vars);
r.render_to_string(doc)
}
// ===== Internals =====
fn slugify(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_dash = false;
for ch in s.chars() {
if ch.is_alphanumeric() {
for c in ch.to_lowercase() {
out.push(c);
}
prev_dash = false;
} else if !prev_dash && !out.is_empty() {
out.push('-');
prev_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
"untitled".into()
} else {
out
}
}
fn collect_headings(doc: &DocumentNode) -> Vec<HeadingProj> {
let mut out = Vec::new();
for b in &doc.children {
if let BlockNode::Heading(h) = b {
out.push(HeadingProj {
level: h.level,
title: inline_to_text(h),
});
}
}
out
}
struct HeadingProj {
level: u8,
title: String,
}
fn inline_to_text(h: &HeadingNode) -> String {
let mut s = String::new();
for n in &h.children {
push_inline(n, &mut s);
}
s.trim().to_string()
}
fn push_inline(n: &InlineNode, out: &mut String) {
match n {
InlineNode::Text(t) => out.push_str(&t.content),
InlineNode::Bold(b) => b.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::Italic(i) => i.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::BoldItalic(bi) => bi.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::Strikethrough(s) => s.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::Code(c) => out.push_str(&c.content),
InlineNode::Superscript(s) => s.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::Subscript(s) => s.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::Color(c) => c.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::WikiLink(w) => match &w.description {
Some(d) => d.iter().for_each(|c| push_inline(c, out)),
None => {
if let Some(p) = &w.target.path {
out.push_str(p);
}
}
},
InlineNode::ExternalLink(e) => match &e.description {
Some(d) => d.iter().for_each(|c| push_inline(c, out)),
None => out.push_str(&e.url),
},
_ => {}
}
}
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(ch),
}
}
out
}
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
/// literal characters. No character classes — those aren't needed for
/// "exclude foo/*.tmp" style patterns.
fn glob_match(pattern: &str, text: &str) -> bool {
glob_impl(pattern.as_bytes(), text.as_bytes())
}
fn glob_impl(p: &[u8], t: &[u8]) -> bool {
let mut pi = 0;
let mut ti = 0;
let mut star: Option<(usize, usize)> = None;
let mut globstar = false;
while ti < t.len() {
if pi < p.len() && pi + 1 < p.len() && p[pi] == b'*' && p[pi + 1] == b'*' {
globstar = true;
star = Some((pi, ti));
pi += 2;
// Allow optional `/` after globstar.
if pi < p.len() && p[pi] == b'/' {
pi += 1;
}
continue;
} else if pi < p.len() && p[pi] == b'*' {
star = Some((pi, ti));
pi += 1;
globstar = false;
continue;
} else if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
continue;
} else if let Some((s_pi, s_ti)) = star {
if !globstar && t[s_ti] == b'/' && t[ti] != b'/' {
// single-star can't cross a `/`
return false;
}
pi = if globstar { s_pi + 2 } else { s_pi + 1 };
ti = s_ti + 1;
star = Some((s_pi, ti));
continue;
} else {
return false;
}
}
while pi < p.len() && (p[pi] == b'*' || (p[pi] == b'/' && globstar)) {
pi += 1;
}
pi == p.len()
}
/// `<html_path>/<css_name>` — the absolute path of the CSS file the
/// renderer references via `{{root_path}}{{css}}`.
pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
cfg.html_path.join(&cfg.css_name)
}
/// Minimal default CSS. Used by `nuwiki.export.*` commands when no CSS
/// file exists yet at [`css_path`]. Deliberately tiny — users are
/// expected to replace it with their own.
pub const DEFAULT_CSS: &str =
"body{font-family:sans-serif;max-width:46em;margin:2em auto;padding:0 1em;line-height:1.55}\
h1,h2,h3,h4,h5,h6{font-family:serif;line-height:1.2}\
pre{background:#f4f4f4;padding:.5em;overflow:auto}\
code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\
table{border-collapse:collapse}\
table td,table th{border:1px solid #ccc;padding:.25em .5em}\
ul.toc{padding-left:1.5em}\n";
/// Convenience wrapper exposed for the dispatcher: convert a wiki config
/// to the absolute output dir.
pub fn html_output_dir(cfg: &WikiConfig) -> &Path {
&cfg.html.html_path
}
+77 -15
View File
@@ -26,6 +26,7 @@ pub mod config;
pub mod diagnostics; pub mod diagnostics;
pub mod diary; pub mod diary;
pub mod edits; pub mod edits;
pub mod export;
pub mod index; pub mod index;
pub mod nav; pub mod nav;
pub mod rename; pub mod rename;
@@ -44,19 +45,21 @@ use tower_lsp::lsp_types::{
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse, CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
DeleteFilesParams, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DeleteFilesParams, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams,
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, ExecuteCommandOptions, DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
ExecuteCommandParams, FileOperationFilter, FileOperationPattern, FileOperationPatternKind, ExecuteCommandOptions, ExecuteCommandParams, FileOperationFilter, FileOperationPattern,
FileOperationRegistrationOptions, GotoDefinitionParams, GotoDefinitionResponse, Hover, FileOperationPatternKind, FileOperationRegistrationOptions, GotoDefinitionParams,
HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability,
InitializedParams, Location, MarkupContent, MarkupKind, MessageType, OneOf, InitializeParams, InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind,
PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken, Range, MessageType, OneOf, PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken,
ReferenceParams, RenameFilesParams, RenameParams, SemanticTokens, SemanticTokensFullOptions, Range, ReferenceParams, RenameFilesParams, RenameParams, SaveOptions, SemanticTokens,
SemanticTokensOptions, SemanticTokensParams, SemanticTokensRangeParams, SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams,
SemanticTokensRangeResult, SemanticTokensResult, SemanticTokensServerCapabilities, SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult,
ServerCapabilities, ServerInfo, SymbolInformation as LspSymbolInformation, SymbolKind, SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo,
TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgress, WorkDoneProgressBegin, SymbolInformation as LspSymbolInformation, SymbolKind, TextDocumentSyncCapability,
WorkDoneProgressEnd, WorkDoneProgressOptions, WorkspaceEdit, TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentSyncSaveOptions, Url,
WorkspaceFileOperationsServerCapabilities, WorkspaceServerCapabilities, WorkspaceSymbolParams, WorkDoneProgress, WorkDoneProgressBegin, WorkDoneProgressEnd, WorkDoneProgressOptions,
WorkspaceEdit, WorkspaceFileOperationsServerCapabilities, WorkspaceServerCapabilities,
WorkspaceSymbolParams,
}; };
use tower_lsp::{Client, LanguageServer, LspService, Server}; use tower_lsp::{Client, LanguageServer, LspService, Server};
@@ -293,8 +296,19 @@ impl LanguageServer for Backend {
Ok(InitializeResult { Ok(InitializeResult {
capabilities: ServerCapabilities { capabilities: ServerCapabilities {
position_encoding, position_encoding,
text_document_sync: Some(TextDocumentSyncCapability::Kind( text_document_sync: Some(TextDocumentSyncCapability::Options(
TextDocumentSyncKind::FULL, TextDocumentSyncOptions {
open_close: Some(true),
change: Some(TextDocumentSyncKind::FULL),
will_save: None,
will_save_wait_until: None,
// Phase 17 `auto_export` listens on `did_save`. Asking
// for `include_text: false` since the document store
// already mirrors the live buffer.
save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
include_text: Some(false),
})),
},
)), )),
document_symbol_provider: Some(OneOf::Left(true)), document_symbol_provider: Some(OneOf::Left(true)),
workspace_symbol_provider: Some(OneOf::Left(true)), workspace_symbol_provider: Some(OneOf::Left(true)),
@@ -440,6 +454,54 @@ impl LanguageServer for Backend {
} }
} }
async fn did_save(&self, params: DidSaveTextDocumentParams) {
// Phase 17: when `auto_export` is set on the resolved wiki, run
// the equivalent of `nuwiki.export.currentToHtml` after the file
// hits disk. Best-effort — failures are logged but do not bubble
// to the client. Skips pages with the `%nohtml` directive.
let uri = params.text_document.uri.clone();
let wiki = match self.wiki_for_uri(&uri) {
Some(w) => w,
None => return,
};
if !wiki.config.html.auto_export {
return;
}
let doc = match self.documents.get(&uri) {
Some(d) => d,
None => return,
};
if doc.ast.metadata.nohtml {
return;
}
let name = index::page_name_from_uri(&uri, Some(&wiki.config.root));
let outcome = commands::export_ops::write_page(&doc.ast, &name, &wiki.config);
drop(doc);
match outcome {
Ok(out) => {
let _ = commands::export_ops::ensure_css(&wiki.config);
self.client
.log_message(
MessageType::INFO,
format!(
"nuwiki: auto-exported {} → {}",
name,
out.output_path.display()
),
)
.await;
}
Err(e) => {
self.client
.log_message(
MessageType::ERROR,
format!("nuwiki: auto-export of {name} failed — {e}"),
)
.await;
}
}
}
async fn did_close(&self, params: DidCloseTextDocumentParams) { async fn did_close(&self, params: DidCloseTextDocumentParams) {
let uri = &params.text_document.uri; let uri = &params.text_document.uri;
self.documents.remove(uri); self.documents.remove(uri);
@@ -0,0 +1,433 @@
//! Phase 17: 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,
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,
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,
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,
extras,
)
.unwrap();
assert!(html.contains("D=OVERRIDE"));
}
// ===== 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}");
}
}