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
16pub 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 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 pub fn templates(mut self, dir: impl Into<PathBuf>) -> Self {
51 self.templates_dir = Some(dir.into());
52 self
53 }
54
55 pub fn output(mut self, dir: impl Into<PathBuf>) -> Self {
57 self.output_dir = Some(dir.into());
58 self
59 }
60
61 pub fn default_template(mut self, name: impl Into<String>) -> Self {
63 self.default_template = Some(name.into());
64 self
65 }
66
67 pub fn link_base(mut self, base: impl Into<String>) -> Self {
69 self.link_base = Some(base.into());
70 self
71 }
72
73 pub fn data_json(mut self, name: impl Into<String>) -> Self {
87 self.data_json = Some(name.into());
88 self
89 }
90
91 pub fn pretty_urls(mut self, enabled: bool) -> Self {
113 self.pretty_urls = enabled;
114 self
115 }
116
117 pub fn processor(mut self, processor: impl MarkdownProcessor + 'static) -> Self {
120 self.processors.push(Box::new(processor));
121 self
122 }
123
124 pub fn analyzer(mut self, analyzer: impl MarkdownAnalyzer + 'static) -> Self {
128 self.analyzers.push(Box::new(analyzer));
129 self
130 }
131
132 pub fn watch(&self) -> Result<crate::watch::Watcher<'_>, DocError> {
136 crate::watch::Watcher::new(self)
137 }
138
139 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 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 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 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 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
379fn 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 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}