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
15pub 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 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 pub fn templates(mut self, dir: impl Into<PathBuf>) -> Self {
48 self.templates_dir = Some(dir.into());
49 self
50 }
51
52 pub fn output(mut self, dir: impl Into<PathBuf>) -> Self {
54 self.output_dir = Some(dir.into());
55 self
56 }
57
58 pub fn default_template(mut self, name: impl Into<String>) -> Self {
60 self.default_template = Some(name.into());
61 self
62 }
63
64 pub fn link_base(mut self, base: impl Into<String>) -> Self {
66 self.link_base = Some(base.into());
67 self
68 }
69
70 pub fn data_json(mut self, name: impl Into<String>) -> Self {
84 self.data_json = Some(name.into());
85 self
86 }
87
88 pub fn processor(mut self, processor: impl MarkdownProcessor + 'static) -> Self {
91 self.processors.push(Box::new(processor));
92 self
93 }
94
95 pub fn analyzer(mut self, analyzer: impl MarkdownAnalyzer + 'static) -> Self {
99 self.analyzers.push(Box::new(analyzer));
100 self
101 }
102
103 pub fn watch(&self) -> Result<crate::watch::Watcher<'_>, DocError> {
107 crate::watch::Watcher::new(self)
108 }
109
110 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 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 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 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 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
337fn 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 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}