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