Skip to main content

mini_docs/
builder.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde_json::{Map, Value};
5use tera::Tera;
6
7use crate::cache::{is_up_to_date, latest_mtime};
8use crate::data_json;
9use crate::error::DocError;
10use crate::escape::guard_output_path;
11use crate::sanitize::sanitize_html;
12use crate::{frontmatter, page, walk};
13
14/// Configures and runs a Markdown → HTML build.
15///
16/// All directories are explicit — there are no ambient globals. `templates_dir` and
17/// `output_dir` must be set via [`Builder::templates`] and [`Builder::output`] before
18/// [`Builder::build`] is called.
19pub struct Builder {
20    input_dir: PathBuf,
21    templates_dir: Option<PathBuf>,
22    output_dir: Option<PathBuf>,
23    default_template: Option<String>,
24    link_base: Option<String>,
25    data_json: Option<String>,
26}
27
28impl Builder {
29    /// Starts a builder rooted at `input_dir`, the directory of `.md` source files.
30    pub fn new(input_dir: impl Into<PathBuf>) -> Self {
31        Self {
32            input_dir: input_dir.into(),
33            templates_dir: None,
34            output_dir: None,
35            default_template: None,
36            link_base: None,
37            data_json: None,
38        }
39    }
40
41    /// Sets the directory of Tera templates.
42    pub fn templates(mut self, dir: impl Into<PathBuf>) -> Self {
43        self.templates_dir = Some(dir.into());
44        self
45    }
46
47    /// Sets the output directory that mirrors `input_dir`, one `.html` file per `.md` file.
48    pub fn output(mut self, dir: impl Into<PathBuf>) -> Self {
49        self.output_dir = Some(dir.into());
50        self
51    }
52
53    /// Sets the template used for pages that have no `template:` frontmatter key.
54    pub fn default_template(mut self, name: impl Into<String>) -> Self {
55        self.default_template = Some(name.into());
56        self
57    }
58
59    /// Sets the base path used to rewrite `[x](x.md)`-style links to clean URLs.
60    pub fn link_base(mut self, base: impl Into<String>) -> Self {
61        self.link_base = Some(base.into());
62        self
63    }
64
65    /// Opts into writing a `data.json` index of every non-draft page to `name`
66    /// (relative to `output_dir`) on every [`Builder::build`] — for a search index,
67    /// table of contents, or "recent items" list to consume.
68    ///
69    /// Each entry has `id`, `title`, `date`, `updated`, `version`, `url`, `summary`,
70    /// `tags`, and `pinned` — the frontmatter-sourced fields default to `""` (`[]`
71    /// for `tags`, `false` for `pinned`) when absent. A page with `draft: true` in
72    /// its frontmatter is excluded from both this index and the HTML build output.
73    ///
74    /// Off by default; explicit over implicit, like the rest of `Builder`'s optional
75    /// features. Regenerated by both `build()` and [`crate::Watcher::tick`] (whenever
76    /// a `.md` file was added, removed, or modified — a template-only change never
77    /// alters index content, so it's skipped then).
78    pub fn data_json(mut self, name: impl Into<String>) -> Self {
79        self.data_json = Some(name.into());
80        self
81    }
82
83    /// Starts a watch session: an initial full [`Builder::build`], then incremental
84    /// rebuilds via [`crate::Watcher::tick`] whenever a `.md` or template file's mtime
85    /// changes. See [`Builder::build`] for the same required-configuration panics.
86    pub fn watch(&self) -> Result<crate::watch::Watcher<'_>, DocError> {
87        crate::watch::Watcher::new(self)
88    }
89
90    /// Walks `input_dir` and (re-)renders each non-draft `.md` file whose output
91    /// isn't already up to date, writing the result under `output_dir`. If
92    /// [`Builder::data_json`] is set, also (re-)writes the page index.
93    ///
94    /// A page's HTML is skipped when `output_path` already exists and is at least as
95    /// new as both the `.md` file and every template file (the render cache —
96    /// `cache::is_up_to_date` internally). This makes repeat `build()` calls
97    /// incremental for free: no in-memory state, no cache to invalidate — the
98    /// filesystem's own mtimes decide.
99    ///
100    /// # Panics
101    ///
102    /// Panics if `.templates()` or `.output()` were not called first — this is a
103    /// programmer error (missing required configuration), not a runtime data failure.
104    pub fn build(&self) -> Result<(), DocError> {
105        let template_mtime = latest_mtime(self.templates_dir(), "html")?;
106        let mut tera: Option<Tera> = None;
107
108        for md_path in walk::walk_files_with_extension(&self.input_dir, "md")? {
109            let output_path = self.output_path_for(&md_path)?;
110            let md_mtime = fs::metadata(&md_path)?.modified()?;
111
112            if is_up_to_date(&output_path, md_mtime, template_mtime)? {
113                continue;
114            }
115
116            if tera.is_none() {
117                tera = Some(load_templates(self.templates_dir())?);
118            }
119            self.build_one(tera.as_ref().expect("just loaded above"), &md_path)?;
120        }
121
122        self.rebuild_data_json()
123    }
124
125    /// Rebuilds the `data.json` index (if [`Builder::data_json`] is set) from every
126    /// non-draft `.md` file's current frontmatter — a no-op, without even walking
127    /// `input_dir`, when the feature isn't enabled.
128    ///
129    /// This always does a full pass: a page's frontmatter (title, tags, `pinned`, …)
130    /// isn't tied to the render cache the way its HTML output is, so — unlike
131    /// `build()`'s HTML loop — there is no cheaper "only what changed" version of
132    /// this without tracking per-page frontmatter hashes, which isn't worth the
133    /// complexity for what is, in practice, reading a handful of small text files.
134    pub(crate) fn rebuild_data_json(&self) -> Result<(), DocError> {
135        let Some(name) = &self.data_json else {
136            return Ok(());
137        };
138
139        let mut entries = Vec::new();
140        for md_path in walk::walk_files_with_extension(&self.input_dir, "md")? {
141            let raw = fs::read_to_string(&md_path)?;
142            let (frontmatter, body) = frontmatter::split_frontmatter(&raw)?;
143
144            if data_json::is_draft(&frontmatter) {
145                continue;
146            }
147
148            let relative = md_path
149                .strip_prefix(&self.input_dir)
150                .expect("walked path must be under input_dir");
151            let fallback_title = relative
152                .file_stem()
153                .and_then(|s| s.to_str())
154                .unwrap_or("untitled");
155            let title = page::resolve_title(&frontmatter, body, fallback_title);
156            let id = relative
157                .with_extension("")
158                .to_string_lossy()
159                .replace('\\', "/");
160            let url = page::page_url(&id, self.link_base.as_deref());
161            entries.push(data_json::page_entry(&id, &title, &url, &frontmatter));
162        }
163
164        let json_path = guard_output_path(self.output_dir(), Path::new(name))?;
165        let json = serde_json::to_string_pretty(&Value::Array(entries)).expect(
166            "data.json entries are built only from strings/bools/arrays, always serializable",
167        );
168
169        if let Some(parent) = json_path.parent() {
170            fs::create_dir_all(parent)?;
171        }
172        fs::write(json_path, json)?;
173
174        Ok(())
175    }
176
177    /// Resolves the escape-guarded output path for a walked `.md` file.
178    pub(crate) fn output_path_for(&self, md_path: &Path) -> Result<PathBuf, DocError> {
179        let relative = md_path
180            .strip_prefix(&self.input_dir)
181            .expect("walked path must be under input_dir");
182        guard_output_path(self.output_dir(), &relative.with_extension("html"))
183    }
184
185    /// Renders and writes a single already-walked `.md` file, given an already-loaded
186    /// `tera`. Shared by [`Builder::build`] (loads `tera` lazily, only on a cache
187    /// miss) and [`crate::Watcher::tick`] (reloads `tera` only when a template
188    /// changed). A `draft: true` page is silently skipped — no output written.
189    pub(crate) fn build_one(&self, tera: &Tera, md_path: &Path) -> Result<(), DocError> {
190        let relative = md_path
191            .strip_prefix(&self.input_dir)
192            .expect("walked path must be under input_dir");
193
194        let raw = fs::read_to_string(md_path)?;
195        let (frontmatter, body) = frontmatter::split_frontmatter(&raw)?;
196
197        if data_json::is_draft(&frontmatter) {
198            return Ok(());
199        }
200
201        let output_path = guard_output_path(self.output_dir(), &relative.with_extension("html"))?;
202        let rendered = self.render_page(tera, relative, &frontmatter, body)?;
203
204        if let Some(parent) = output_path.parent() {
205            fs::create_dir_all(parent)?;
206        }
207        fs::write(&output_path, rendered)?;
208
209        Ok(())
210    }
211
212    pub(crate) fn input_dir(&self) -> &Path {
213        &self.input_dir
214    }
215
216    pub(crate) fn templates_dir(&self) -> &Path {
217        self.templates_dir
218            .as_deref()
219            .expect("templates_dir must be set via .templates() before build()/watch()")
220    }
221
222    pub(crate) fn output_dir(&self) -> &Path {
223        self.output_dir
224            .as_deref()
225            .expect("output_dir must be set via .output() before build()/watch()")
226    }
227
228    /// Resolves title/template, sanitizes, and renders through Tera, given
229    /// already-parsed `frontmatter`/`body`.
230    fn render_page(
231        &self,
232        tera: &Tera,
233        relative: &Path,
234        frontmatter: &Value,
235        body: &str,
236    ) -> Result<String, DocError> {
237        let fallback_title = relative
238            .file_stem()
239            .and_then(|s| s.to_str())
240            .unwrap_or("untitled");
241        let title = page::resolve_title(frontmatter, body, fallback_title);
242
243        let template_name = page::resolve_template(frontmatter, self.default_template.as_deref())
244            .ok_or_else(|| {
245                DocError::Template(tera::Error::message(format!(
246                    "no template resolved for {}: no frontmatter `template` key and no default_template set",
247                    relative.display()
248                )))
249            })?;
250
251        let content = sanitize_html(&page::render_markdown(body, self.link_base.as_deref()));
252        let context = build_context(title, content, frontmatter.clone());
253
254        tera.render(&template_name, &context)
255            .map_err(DocError::Template)
256    }
257}
258
259/// Assembles the Tera context: a `page` object carrying the sanitized content
260/// (`{{ page.content | safe }}` is only safe because [`sanitize_html`] already ran),
261/// resolved title, and the raw frontmatter map.
262fn build_context(title: String, sanitized_content: String, frontmatter: Value) -> tera::Context {
263    let mut page = Map::new();
264    page.insert("title".to_string(), Value::String(title));
265    page.insert("content".to_string(), Value::String(sanitized_content));
266    page.insert("frontmatter".to_string(), frontmatter);
267
268    let mut context = tera::Context::new();
269    context.insert("page", &Value::Object(page));
270    context
271}
272
273pub(crate) fn load_templates(templates_dir: &Path) -> Result<Tera, DocError> {
274    let mut tera = Tera::default();
275
276    // `add_raw_templates` (bulk) inserts every template into Tera's map *before*
277    // validating any `{% extends %}` chain; `add_raw_template` (singular, called
278    // once per file) validates after each individual insert instead, so a child
279    // template (e.g. `article.html`) that happens to walk before its parent
280    // (`base.html`) — alphabetically or otherwise — fails with a missing-parent
281    // error even though both files are present. Order must never matter here.
282    let mut templates = Vec::new();
283    for path in walk::walk_files_with_extension(templates_dir, "html")? {
284        let relative = path
285            .strip_prefix(templates_dir)
286            .expect("walked path must be under templates_dir")
287            .to_string_lossy()
288            .replace('\\', "/");
289        let content = fs::read_to_string(&path)?;
290        templates.push((relative, content));
291    }
292
293    tera.add_raw_templates(templates)
294        .map_err(DocError::Template)?;
295
296    Ok(tera)
297}