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
14pub 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 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 pub fn templates(mut self, dir: impl Into<PathBuf>) -> Self {
43 self.templates_dir = Some(dir.into());
44 self
45 }
46
47 pub fn output(mut self, dir: impl Into<PathBuf>) -> Self {
49 self.output_dir = Some(dir.into());
50 self
51 }
52
53 pub fn default_template(mut self, name: impl Into<String>) -> Self {
55 self.default_template = Some(name.into());
56 self
57 }
58
59 pub fn link_base(mut self, base: impl Into<String>) -> Self {
61 self.link_base = Some(base.into());
62 self
63 }
64
65 pub fn data_json(mut self, name: impl Into<String>) -> Self {
79 self.data_json = Some(name.into());
80 self
81 }
82
83 pub fn watch(&self) -> Result<crate::watch::Watcher<'_>, DocError> {
87 crate::watch::Watcher::new(self)
88 }
89
90 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 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 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 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 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
259fn 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 for path in walk::walk_files_with_extension(templates_dir, "html")? {
277 let relative = path
278 .strip_prefix(templates_dir)
279 .expect("walked path must be under templates_dir")
280 .to_string_lossy()
281 .replace('\\', "/");
282 let content = fs::read_to_string(&path)?;
283 tera.add_raw_template(&relative, &content)
284 .map_err(DocError::Template)?;
285 }
286
287 Ok(tera)
288}