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
+33 -10
View File
@@ -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)?;
}