//! HTML export — pure helpers shared by the `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::PathBuf; use nuwiki_core::ast::{BlockNode, DocumentNode, LinkKind}; use nuwiki_core::date::DiaryDate; use nuwiki_core::render::{HtmlRenderer, Renderer}; use crate::config::HtmlConfig; /// 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; } // Anchor is the raw heading text (vimwiki scheme), HTML-escaped — must // match the `id` the renderer puts on the heading element. let anchor = escape_html(&h.title); out.push_str(&format!("
    • {anchor}
    • ")); } 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 ``/`