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:
@@ -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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user