mini-docs 0.3.5

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use serde_json::{Map, Value};
use tera::Tera;

use crate::cache::{is_up_to_date, latest_mtime};
use crate::data_json;
use crate::error::DocError;
use crate::escape::guard_output_path;
use crate::sanitize::sanitize_html;
use crate::{frontmatter, page, walk};

/// Configures and runs a Markdown → HTML build.
///
/// All directories are explicit — there are no ambient globals. `templates_dir` and
/// `output_dir` must be set via [`Builder::templates`] and [`Builder::output`] before
/// [`Builder::build`] is called.
pub struct Builder {
    input_dir: PathBuf,
    templates_dir: Option<PathBuf>,
    output_dir: Option<PathBuf>,
    default_template: Option<String>,
    link_base: Option<String>,
    data_json: Option<String>,
}

impl Builder {
    /// Starts a builder rooted at `input_dir`, the directory of `.md` source files.
    pub fn new(input_dir: impl Into<PathBuf>) -> Self {
        Self {
            input_dir: input_dir.into(),
            templates_dir: None,
            output_dir: None,
            default_template: None,
            link_base: None,
            data_json: None,
        }
    }

    /// Sets the directory of Tera templates.
    pub fn templates(mut self, dir: impl Into<PathBuf>) -> Self {
        self.templates_dir = Some(dir.into());
        self
    }

    /// Sets the output directory that mirrors `input_dir`, one `.html` file per `.md` file.
    pub fn output(mut self, dir: impl Into<PathBuf>) -> Self {
        self.output_dir = Some(dir.into());
        self
    }

    /// Sets the template used for pages that have no `template:` frontmatter key.
    pub fn default_template(mut self, name: impl Into<String>) -> Self {
        self.default_template = Some(name.into());
        self
    }

    /// Sets the base path used to rewrite `[x](x.md)`-style links to clean URLs.
    pub fn link_base(mut self, base: impl Into<String>) -> Self {
        self.link_base = Some(base.into());
        self
    }

    /// Opts into writing a `data.json` index of every non-draft page to `name`
    /// (relative to `output_dir`) on every [`Builder::build`] — for a search index,
    /// table of contents, or "recent items" list to consume.
    ///
    /// Each entry has `id`, `title`, `date`, `updated`, `version`, `url`, `summary`,
    /// `tags`, and `pinned` — the frontmatter-sourced fields default to `""` (`[]`
    /// for `tags`, `false` for `pinned`) when absent. A page with `draft: true` in
    /// its frontmatter is excluded from both this index and the HTML build output.
    ///
    /// Off by default; explicit over implicit, like the rest of `Builder`'s optional
    /// features. Regenerated by both `build()` and [`crate::Watcher::tick`] (whenever
    /// a `.md` file was added, removed, or modified — a template-only change never
    /// alters index content, so it's skipped then).
    pub fn data_json(mut self, name: impl Into<String>) -> Self {
        self.data_json = Some(name.into());
        self
    }

    /// Starts a watch session: an initial full [`Builder::build`], then incremental
    /// rebuilds via [`crate::Watcher::tick`] whenever a `.md` or template file's mtime
    /// changes. See [`Builder::build`] for the same required-configuration panics.
    pub fn watch(&self) -> Result<crate::watch::Watcher<'_>, DocError> {
        crate::watch::Watcher::new(self)
    }

    /// Walks `input_dir` and (re-)renders each non-draft `.md` file whose output
    /// isn't already up to date, writing the result under `output_dir`. If
    /// [`Builder::data_json`] is set, also (re-)writes the page index.
    ///
    /// A page's HTML is skipped when `output_path` already exists and is at least as
    /// new as both the `.md` file and every template file (the render cache —
    /// `cache::is_up_to_date` internally). This makes repeat `build()` calls
    /// incremental for free: no in-memory state, no cache to invalidate — the
    /// filesystem's own mtimes decide.
    ///
    /// # Panics
    ///
    /// Panics if `.templates()` or `.output()` were not called first — this is a
    /// programmer error (missing required configuration), not a runtime data failure.
    pub fn build(&self) -> Result<(), DocError> {
        let template_mtime = latest_mtime(self.templates_dir(), "html")?;
        let mut tera: Option<Tera> = None;

        for md_path in walk::walk_files_with_extension(&self.input_dir, "md")? {
            let output_path = self.output_path_for(&md_path)?;
            let md_mtime = fs::metadata(&md_path)?.modified()?;

            if is_up_to_date(&output_path, md_mtime, template_mtime)? {
                continue;
            }

            if tera.is_none() {
                tera = Some(load_templates(self.templates_dir())?);
            }
            self.build_one(tera.as_ref().expect("just loaded above"), &md_path)?;
        }

        self.rebuild_data_json()
    }

    /// Rebuilds the `data.json` index (if [`Builder::data_json`] is set) from every
    /// non-draft `.md` file's current frontmatter — a no-op, without even walking
    /// `input_dir`, when the feature isn't enabled.
    ///
    /// This always does a full pass: a page's frontmatter (title, tags, `pinned`, …)
    /// isn't tied to the render cache the way its HTML output is, so — unlike
    /// `build()`'s HTML loop — there is no cheaper "only what changed" version of
    /// this without tracking per-page frontmatter hashes, which isn't worth the
    /// complexity for what is, in practice, reading a handful of small text files.
    pub(crate) fn rebuild_data_json(&self) -> Result<(), DocError> {
        let Some(name) = &self.data_json else {
            return Ok(());
        };

        let mut entries = Vec::new();
        for md_path in walk::walk_files_with_extension(&self.input_dir, "md")? {
            let raw = fs::read_to_string(&md_path)?;
            let (frontmatter, body) = frontmatter::split_frontmatter(&raw)?;

            if data_json::is_draft(&frontmatter) {
                continue;
            }

            let relative = md_path
                .strip_prefix(&self.input_dir)
                .expect("walked path must be under input_dir");
            let fallback_title = relative
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("untitled");
            let title = page::resolve_title(&frontmatter, body, fallback_title);
            let id = relative
                .with_extension("")
                .to_string_lossy()
                .replace('\\', "/");
            let url = page::page_url(&id, self.link_base.as_deref());
            entries.push(data_json::page_entry(&id, &title, &url, &frontmatter));
        }

        let json_path = guard_output_path(self.output_dir(), Path::new(name))?;
        let json = serde_json::to_string_pretty(&Value::Array(entries)).expect(
            "data.json entries are built only from strings/bools/arrays, always serializable",
        );

        if let Some(parent) = json_path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(json_path, json)?;

        Ok(())
    }

    /// Resolves the escape-guarded output path for a walked `.md` file.
    pub(crate) fn output_path_for(&self, md_path: &Path) -> Result<PathBuf, DocError> {
        let relative = md_path
            .strip_prefix(&self.input_dir)
            .expect("walked path must be under input_dir");
        guard_output_path(self.output_dir(), &relative.with_extension("html"))
    }

    /// Renders and writes a single already-walked `.md` file, given an already-loaded
    /// `tera`. Shared by [`Builder::build`] (loads `tera` lazily, only on a cache
    /// miss) and [`crate::Watcher::tick`] (reloads `tera` only when a template
    /// changed). A `draft: true` page is silently skipped — no output written.
    pub(crate) fn build_one(&self, tera: &Tera, md_path: &Path) -> Result<(), DocError> {
        let relative = md_path
            .strip_prefix(&self.input_dir)
            .expect("walked path must be under input_dir");

        let raw = fs::read_to_string(md_path)?;
        let (frontmatter, body) = frontmatter::split_frontmatter(&raw)?;

        if data_json::is_draft(&frontmatter) {
            return Ok(());
        }

        let output_path = guard_output_path(self.output_dir(), &relative.with_extension("html"))?;
        let rendered = self.render_page(tera, relative, &frontmatter, body)?;

        if let Some(parent) = output_path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(&output_path, rendered)?;

        Ok(())
    }

    pub(crate) fn input_dir(&self) -> &Path {
        &self.input_dir
    }

    pub(crate) fn templates_dir(&self) -> &Path {
        self.templates_dir
            .as_deref()
            .expect("templates_dir must be set via .templates() before build()/watch()")
    }

    pub(crate) fn output_dir(&self) -> &Path {
        self.output_dir
            .as_deref()
            .expect("output_dir must be set via .output() before build()/watch()")
    }

    /// Resolves title/template, sanitizes, and renders through Tera, given
    /// already-parsed `frontmatter`/`body`.
    fn render_page(
        &self,
        tera: &Tera,
        relative: &Path,
        frontmatter: &Value,
        body: &str,
    ) -> Result<String, DocError> {
        let fallback_title = relative
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("untitled");
        let title = page::resolve_title(frontmatter, body, fallback_title);

        let template_name = page::resolve_template(frontmatter, self.default_template.as_deref())
            .ok_or_else(|| {
                DocError::Template(tera::Error::message(format!(
                    "no template resolved for {}: no frontmatter `template` key and no default_template set",
                    relative.display()
                )))
            })?;

        let content = sanitize_html(&page::render_markdown(body, self.link_base.as_deref()));
        let context = build_context(title, content, frontmatter.clone());

        tera.render(&template_name, &context)
            .map_err(DocError::Template)
    }
}

/// Assembles the Tera context: a `page` object carrying the sanitized content
/// (`{{ page.content | safe }}` is only safe because [`sanitize_html`] already ran),
/// resolved title, and the raw frontmatter map.
fn build_context(title: String, sanitized_content: String, frontmatter: Value) -> tera::Context {
    let mut page = Map::new();
    page.insert("title".to_string(), Value::String(title));
    page.insert("content".to_string(), Value::String(sanitized_content));
    page.insert("frontmatter".to_string(), frontmatter);

    let mut context = tera::Context::new();
    context.insert("page", &Value::Object(page));
    context
}

pub(crate) fn load_templates(templates_dir: &Path) -> Result<Tera, DocError> {
    let mut tera = Tera::default();

    // `add_raw_templates` (bulk) inserts every template into Tera's map *before*
    // validating any `{% extends %}` chain; `add_raw_template` (singular, called
    // once per file) validates after each individual insert instead, so a child
    // template (e.g. `article.html`) that happens to walk before its parent
    // (`base.html`) — alphabetically or otherwise — fails with a missing-parent
    // error even though both files are present. Order must never matter here.
    let mut templates = Vec::new();
    for path in walk::walk_files_with_extension(templates_dir, "html")? {
        let relative = path
            .strip_prefix(templates_dir)
            .expect("walked path must be under templates_dir")
            .to_string_lossy()
            .replace('\\', "/");
        let content = fs::read_to_string(&path)?;
        templates.push((relative, content));
    }

    tera.add_raw_templates(templates)
        .map_err(DocError::Template)?;

    Ok(tera)
}