phase 17: html export commands + extended template vars
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:
@@ -2,14 +2,17 @@
|
||||
//!
|
||||
//! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is
|
||||
//! configured with a link resolver (callback that turns a `LinkTarget` into
|
||||
//! a URL string) and, optionally, an enclosing template with `{{title}}` /
|
||||
//! `{{content}}` placeholders (SPEC.md §6.8).
|
||||
//! a URL string) and, optionally, an enclosing template with substitution
|
||||
//! placeholders (SPEC.md §6.8 + §12.8).
|
||||
//!
|
||||
//! Without a template the output is just the body markup, so callers are
|
||||
//! free to embed it in their own page shell. With a template the rendered
|
||||
//! body replaces `{{content}}` and the document's `metadata.title`
|
||||
//! replaces `{{title}}`.
|
||||
//! Substitution model: `{{content}}` and `{{title}}` are computed from
|
||||
//! the rendered body and the document's metadata; `with_var(k, v)` /
|
||||
//! `with_vars(map)` add arbitrary key→value pairs (Phase 17 ships
|
||||
//! `{{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::sync::Arc;
|
||||
|
||||
@@ -32,6 +35,7 @@ pub type LinkResolver = Arc<dyn Fn(&LinkTarget) -> String + Send + Sync>;
|
||||
pub struct HtmlRenderer {
|
||||
link_resolver: LinkResolver,
|
||||
template: Option<String>,
|
||||
vars: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for HtmlRenderer {
|
||||
@@ -45,6 +49,7 @@ impl HtmlRenderer {
|
||||
Self {
|
||||
link_resolver: Arc::new(default_link_resolver),
|
||||
template: None,
|
||||
vars: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,11 +64,25 @@ impl HtmlRenderer {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.template = Some(template.into());
|
||||
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 {
|
||||
@@ -77,9 +96,13 @@ impl Renderer for HtmlRenderer {
|
||||
// Replace `{{content}}` first so its substituted body, which may
|
||||
// legitimately contain the literal `{{title}}`, isn't itself
|
||||
// rewritten in the next pass.
|
||||
let with_content = template.replace("{{content}}", body_str);
|
||||
let final_doc = with_content.replace("{{title}}", title);
|
||||
w.write_all(final_doc.as_bytes())?;
|
||||
let mut out = template.replace("{{content}}", body_str);
|
||||
out = out.replace("{{title}}", title);
|
||||
for (k, v) in &self.vars {
|
||||
let needle = format!("{{{{{k}}}}}");
|
||||
out = out.replace(&needle, v);
|
||||
}
|
||||
w.write_all(out.as_bytes())?;
|
||||
} else {
|
||||
self.render_body(doc, w)?;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,11 @@ pub const COMMANDS: &[&str] = &[
|
||||
"nuwiki.tags.search",
|
||||
"nuwiki.tags.generateLinks",
|
||||
"nuwiki.tags.rebuild",
|
||||
"nuwiki.export.currentToHtml",
|
||||
"nuwiki.export.allToHtml",
|
||||
"nuwiki.export.allToHtmlForce",
|
||||
"nuwiki.export.browse",
|
||||
"nuwiki.export.rss",
|
||||
];
|
||||
|
||||
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))
|
||||
}
|
||||
"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}")),
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
let p = parse_optional_uri_arg(args)?;
|
||||
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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => 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) {}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,64 @@ pub struct WikiConfig {
|
||||
/// Matches vimwiki's `g:vimwiki_diary_index` (default `"diary"`).
|
||||
/// The full path is `<root>/<diary_rel_path>/<diary_index>.wiki`.
|
||||
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)]
|
||||
@@ -62,10 +120,12 @@ impl WikiConfig {
|
||||
syntax: "vimwiki".into(),
|
||||
diary_rel_path: default_diary_rel_path(),
|
||||
diary_index: default_diary_index(),
|
||||
html: HtmlConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_root(root: PathBuf) -> Self {
|
||||
let html = HtmlConfig::from_root(&root);
|
||||
Self {
|
||||
name: root
|
||||
.file_name()
|
||||
@@ -76,6 +136,7 @@ impl WikiConfig {
|
||||
syntax: "vimwiki".into(),
|
||||
diary_rel_path: default_diary_rel_path(),
|
||||
diary_index: default_diary_index(),
|
||||
html,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +188,7 @@ impl Config {
|
||||
let wikis = if let Some(list) = raw.wikis {
|
||||
list.into_iter().map(WikiConfig::from).collect()
|
||||
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
|
||||
let html = HtmlConfig::from_root(&root);
|
||||
vec![WikiConfig {
|
||||
name: root
|
||||
.file_name()
|
||||
@@ -137,6 +199,7 @@ impl Config {
|
||||
syntax: raw.syntax.unwrap_or_else(|| "vimwiki".into()),
|
||||
diary_rel_path: default_diary_rel_path(),
|
||||
diary_index: default_diary_index(),
|
||||
html,
|
||||
}]
|
||||
} else if let Some(folder) = first_workspace_folder(params) {
|
||||
vec![WikiConfig::from_root(folder)]
|
||||
@@ -199,6 +262,25 @@ struct RawWiki {
|
||||
diary_rel_path: Option<String>,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -208,6 +290,34 @@ impl From<RawWiki> for WikiConfig {
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.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 {
|
||||
name: r.name.unwrap_or(derived_name),
|
||||
root,
|
||||
@@ -215,6 +325,7 @@ impl From<RawWiki> for WikiConfig {
|
||||
syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()),
|
||||
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),
|
||||
html,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => 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
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub mod config;
|
||||
pub mod diagnostics;
|
||||
pub mod diary;
|
||||
pub mod edits;
|
||||
pub mod export;
|
||||
pub mod index;
|
||||
pub mod nav;
|
||||
pub mod rename;
|
||||
@@ -44,19 +45,21 @@ use tower_lsp::lsp_types::{
|
||||
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
|
||||
DeleteFilesParams, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams,
|
||||
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
|
||||
DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, ExecuteCommandOptions,
|
||||
ExecuteCommandParams, FileOperationFilter, FileOperationPattern, FileOperationPatternKind,
|
||||
FileOperationRegistrationOptions, GotoDefinitionParams, GotoDefinitionResponse, Hover,
|
||||
HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult,
|
||||
InitializedParams, Location, MarkupContent, MarkupKind, MessageType, OneOf,
|
||||
PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken, Range,
|
||||
ReferenceParams, RenameFilesParams, RenameParams, SemanticTokens, SemanticTokensFullOptions,
|
||||
SemanticTokensOptions, SemanticTokensParams, SemanticTokensRangeParams,
|
||||
SemanticTokensRangeResult, SemanticTokensResult, SemanticTokensServerCapabilities,
|
||||
ServerCapabilities, ServerInfo, SymbolInformation as LspSymbolInformation, SymbolKind,
|
||||
TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgress, WorkDoneProgressBegin,
|
||||
WorkDoneProgressEnd, WorkDoneProgressOptions, WorkspaceEdit,
|
||||
WorkspaceFileOperationsServerCapabilities, WorkspaceServerCapabilities, WorkspaceSymbolParams,
|
||||
DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
|
||||
ExecuteCommandOptions, ExecuteCommandParams, FileOperationFilter, FileOperationPattern,
|
||||
FileOperationPatternKind, FileOperationRegistrationOptions, GotoDefinitionParams,
|
||||
GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability,
|
||||
InitializeParams, InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind,
|
||||
MessageType, OneOf, PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken,
|
||||
Range, ReferenceParams, RenameFilesParams, RenameParams, SaveOptions, SemanticTokens,
|
||||
SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams,
|
||||
SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult,
|
||||
SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo,
|
||||
SymbolInformation as LspSymbolInformation, SymbolKind, TextDocumentSyncCapability,
|
||||
TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentSyncSaveOptions, Url,
|
||||
WorkDoneProgress, WorkDoneProgressBegin, WorkDoneProgressEnd, WorkDoneProgressOptions,
|
||||
WorkspaceEdit, WorkspaceFileOperationsServerCapabilities, WorkspaceServerCapabilities,
|
||||
WorkspaceSymbolParams,
|
||||
};
|
||||
use tower_lsp::{Client, LanguageServer, LspService, Server};
|
||||
|
||||
@@ -293,8 +296,19 @@ impl LanguageServer for Backend {
|
||||
Ok(InitializeResult {
|
||||
capabilities: ServerCapabilities {
|
||||
position_encoding,
|
||||
text_document_sync: Some(TextDocumentSyncCapability::Kind(
|
||||
TextDocumentSyncKind::FULL,
|
||||
text_document_sync: Some(TextDocumentSyncCapability::Options(
|
||||
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)),
|
||||
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) {
|
||||
let uri = ¶ms.text_document.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 & 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_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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user