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