diff --git a/README.md b/README.md index 9df04e2..839a760 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification. | 14 | List & table edit commands | ✅ done (focused subset; table/list-renumber/colorize deferred) | | 15 | Link health + TOC/index generation | ✅ done | | 16 | Diary | ✅ done | -| 17 | HTML export commands | ⏳ | +| 17 | HTML export commands | ✅ done | | 18 | Multi-wiki | ⏳ | | 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ | diff --git a/SPEC.md b/SPEC.md index c38fd4a..9f73357 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1382,14 +1382,11 @@ Deferred because the value proposition over the existing `workspace/rename` flow is marginal and the editor-side support is inconsistent across clients. -### 13.3 v1.1 phases 17–19 +### 13.3 v1.1 phases 18–19 Tracked in §10 with status `⏳`. Not "missing" from earlier phases — they're future work in the v1.1 roadmap: -- **Phase 17** — HTML export commands (`nuwiki.export.*`), template - resolution, CSS file management, auto-export. Brings in - `html_path` / `template_path` / `color_dic` / etc. config keys. - **Phase 18** — Multi-wiki. Data structures (Wiki aggregate, per-wiki index) already landed in Phase 11; this phase is the config-shape change and the picker commands. @@ -1397,6 +1394,15 @@ they're future work in the v1.1 roadmap: keymaps, text objects, folding (P14 already resolved in favour of LSP `foldingRange` + `foldexpr` fallback). +Phase 17 (HTML export commands) shipped with the focused command +surface (`currentToHtml` / `allToHtml[Force]` / `browse` / `rss`), +template-file + fallback resolution, the `{{date}}` / `{{root_path}}` +/ `{{toc}}` / `{{css}}` template variables, and `did_save` auto-export +when `auto_export = true`. `color_dic` integration with the +`ColorNode` renderer is still open (it's only useful once `colorize` +from §13.1 lands), as is the §13.1 `link.pasteUrl` follow-up that +depends on the same export config. + ### 13.4 §9 syntax checklist status The §9 checklist tracks every vimwiki-syntax surface. Items currently diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index 48fa847..2ac72c6 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -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 String + Send + Sync>; pub struct HtmlRenderer { link_resolver: LinkResolver, template: Option, + vars: HashMap, } 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) -> 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, value: impl Into) -> Self { + self.vars.insert(key.into(), value.into()); + self + } + + /// Replace the entire template variable map. + pub fn with_vars(mut self, vars: HashMap) -> 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)?; } diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index f32f049..0da604f 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -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) -> Result, St }))) } +// ===== Phase 17: HTML export commands ===== + +fn export_current( + backend: &Backend, + args: Vec, + return_open_url: bool, +) -> Result, 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, force: bool) -> Result, 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 = Vec::new(); + let mut skipped: Vec = 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) -> Result, 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) -> Result, 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, + } + + 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`. + /// 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 { + 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 { + 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 `/` 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 { + 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 `/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 { + 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.push_str(&format!("{}\n", xml_escape(&cfg.name))); + xml.push_str("nuwiki diary\n"); + for e in sorted { + xml.push_str(""); + xml.push_str(&format!("{}", e.date.format())); + xml.push_str(&format!("{}", e.uri.as_str())); + xml.push_str(&format!("{}", e.uri.as_str())); + xml.push_str("\n"); + } + xml.push_str("\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) {} +} diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index 4853709..7429dec 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -34,6 +34,64 @@ pub struct WikiConfig { /// Matches vimwiki's `g:vimwiki_diary_index` (default `"diary"`). /// The full path is `//.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: `/_html`. + pub html_path: PathBuf, + /// Template search root. Default: `/_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, +} + +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 `/_html` + /// and `template_path` to `/_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, #[serde(default)] diary_index: Option, + // Phase 17 HTML keys — all optional, fall back to per-root defaults. + #[serde(default)] + html_path: Option, + #[serde(default)] + template_path: Option, + #[serde(default)] + template_default: Option, + #[serde(default)] + template_ext: Option, + #[serde(default)] + template_date_format: Option, + #[serde(default)] + css_name: Option, + #[serde(default)] + auto_export: Option, + #[serde(default)] + html_filename_parameterization: Option, + #[serde(default)] + exclude_files: Option>, } impl From for WikiConfig { @@ -208,6 +290,34 @@ impl From 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 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, } } } diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs new file mode 100644 index 0000000..c3da357 --- /dev/null +++ b/crates/nuwiki-lsp/src/export.rs @@ -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 = 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. `/.` if set. +/// 2. `/.`. +/// 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, + 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#" + + + +{{{{title}}}} + + + +{{{{content}}}} + + +"# + ) +} + +/// 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 `
    ` 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("
      "); + let mut depth = 0usize; + for h in &headings { + let target = h.level.saturating_sub(min_level) as usize; + while depth < target { + out.push_str("
        "); + depth += 1; + } + while depth > target { + out.push_str("
      "); + depth -= 1; + } + let anchor = crate::index::slugify(&h.title); + out.push_str(&format!( + "
    • {title}
    • ", + title = escape_html(&h.title) + )); + } + while depth > 0 { + out.push_str("
    "); + depth -= 1; + } + out.push_str("
"); + 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 ``/`