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