1use std::{
2 io,
3 path::{Path, PathBuf},
4};
5
6use miette::Diagnostic;
7use pathdiff::diff_paths;
8use serde_json::{Map, Value, json};
9use std::fs;
10use tera::Tera;
11use thiserror::Error;
12use tracing::{debug, warn};
13
14use crate::{
15 index::{PageMetadata, SiteMetadata},
16 renderer::{RenderedPageRef, RenderedSite},
17};
18use clap::Args;
19use clap::ValueHint::DirPath;
20
21use self::{atom::generate_atom, theme::create_template_engine};
22
23use rayon::prelude::*;
24
25mod atom;
26mod theme;
27
28#[derive(Args, Clone)]
29pub struct Options {
30 #[arg(value_hint = DirPath)]
31 pub path: Option<PathBuf>,
32
33 #[arg(long, short = 'o', value_hint = DirPath, default_value = "publish")]
34 pub destination: PathBuf,
35
36 #[arg(long, default_value_t = false)]
38 pub unpublished: bool,
39}
40
41#[derive(Diagnostic, Debug, Error)]
42pub enum GeneratorError {
43 #[error("generating atom feed")]
44 AtomError(#[source] atom::AtomError),
45 #[error("could not compute relative path for {0}")]
46 ComputeRelativePath(PathBuf),
47 #[error("removing old destination directory: {}", .0.display())]
48 CleanDestDir(PathBuf, #[source] io::Error),
49 #[error("creating destination directory: {}", .0.display())]
50 CreateDestDir(PathBuf, #[source] io::Error),
51 #[error("copying {} to {}", .0.display(), .1.display())]
52 Copy(PathBuf, PathBuf, #[source] io::Error),
53 #[error("creating file `{}`", .0.display())]
54 CreateFile(PathBuf, #[source] io::Error),
55 #[error("writing file contents to `{}`", .0.display())]
56 WriteFile(PathBuf, #[source] io::Error),
57 #[error("loading templates")]
58 LoadTemplates(#[source] Box<dyn std::error::Error + Send + Sync>),
59 #[error("importing site macros")]
60 ImportSiteMacros(#[source] Box<dyn std::error::Error + Send + Sync>),
61 #[error("rendering template")]
62 RenderTemplate(#[source] Box<dyn std::error::Error + Send + Sync>),
63}
64
65pub trait Observer: Send + Sync {
66 fn begin_load_site(&self) {}
67 fn end_load_site(&self, _site: &dyn SiteMetadata) {}
68 fn begin_page(&self, _page: &dyn PageMetadata) {}
69 fn end_page(&self, _page: &dyn PageMetadata) {}
70 fn site_complete(&self, _site: &dyn SiteMetadata) {}
71}
72
73pub struct GeneratorContext<'a> {
75 templates: Tera,
76 options: &'a Options,
77 progress: Option<&'a dyn Observer>,
78}
79
80impl<'a> GeneratorContext<'a> {
81 pub fn new(site: &RenderedSite, options: &'a Options) -> Result<Self, GeneratorError> {
82 let templates = create_template_engine(site.root_dir(), site.config())?;
83 Ok(Self {
84 templates,
85 options,
86 progress: None,
87 })
88 }
89
90 pub fn with_progress(mut self, progress: &'a dyn Observer) -> Self {
91 self.progress = Some(progress);
92 self
93 }
94
95 fn has_template(&self, template_name: &str) -> bool {
97 let template_name = &format!("{template_name}.html");
98 self.templates.get_template_names().any(|name| name == template_name)
99 }
100
101 pub async fn generate_site(&self, site: &RenderedSite<'_>) -> super::Result<()> {
102 let cleanup = if self.options.destination.exists() {
104 let old = tempfile::tempdir().unwrap();
105 debug!(
106 "moving old destination directory out of the way: {} → {}",
107 self.options.destination.display(),
108 old.path().display()
109 );
110 fs::rename(&self.options.destination, &old.path().join("publish"))
111 .or_else(|e| {
112 warn!(
113 "failed to move old destination directory, falling back on regular removal: {}",
114 e);
115 fs::remove_dir_all(&self.options.destination)
117 })
118 .map_err(|e| GeneratorError::CleanDestDir(self.options.destination.clone(), e))?;
119 Some(tokio::spawn(async move {
120 drop(old);
121 }))
122 } else {
123 None
124 };
125
126 tokio::fs::create_dir_all(&self.options.destination)
128 .await
129 .map_err(|e| GeneratorError::CreateDestDir(self.options.destination.clone(), e))?;
130
131 self.generate_pages(site)?;
133
134 self.copy_raw_files(site)?;
136
137 generate_atom(
139 site,
140 std::fs::File::create(self.options.destination.join("atom.xml"))
141 .map_err(|e| GeneratorError::CreateFile("atom.xml".into(), e))?,
142 )
143 .map_err(GeneratorError::AtomError)?;
144
145 if let Some(cleanup) = cleanup {
148 cleanup.await.unwrap()
149 }
150
151 Ok(())
152 }
153
154 fn generate_pages(&self, site: &RenderedSite<'_>) -> Result<(), GeneratorError> {
155 site.all_pages()
156 .collect::<Vec<_>>()
157 .par_iter()
158 .try_for_each(|post: &RenderedPageRef<'_>| {
159 if let Some(progress) = self.progress {
160 progress.begin_page(post);
161 }
162 self.generate_page(*post, site)?;
163 if let Some(progress) = self.progress {
164 progress.end_page(post);
165 }
166 Ok::<_, GeneratorError>(())
167 })?;
168
169 if self.has_template("category") {
171 self.generate_category_pages(site)?;
172 }
173
174 Ok(())
175 }
176
177 fn generate_category_pages(&self, site: &RenderedSite<'_>) -> Result<(), GeneratorError> {
179 for (category, pages) in site.categories_and_pages() { let category_slug = slug::slugify(&category.name);
182 let dest_dir = self.options.destination
183 .join("blog")
184 .join("category")
185 .join(&category_slug);
186
187 debug!("Generating category page for '{}' at {}",
188 category.name, dest_dir.display());
189
190 std::fs::create_dir_all(&dest_dir)
192 .map_err(|e| GeneratorError::CreateDestDir(dest_dir.clone(), e))?;
193
194 let dest = dest_dir.join("index.html");
195 let mut context = tera::Context::new();
197 context.insert("site", &site.value());
198 context.insert("category", &category.name);
199 let mut page_value = serde_json::Map::new();
201 page_value.insert("title".to_string(), serde_json::json!(format!("Category: {}", category.name)));
202 page_value.insert("url".to_string(), serde_json::json!(format!("/blog/category/{}/", category_slug)));
203 page_value.insert("content".to_string(), serde_json::json!(""));
204 context.insert("page", &page_value);
205
206 let mut category_posts: Vec<_> = pages.collect();
208 category_posts.sort_by_key(|p| std::cmp::Reverse(p.publish_date()));
209
210 context.insert("posts", &category_posts.iter().map(|p| p.value()).collect::<Vec<_>>());
211 context.insert("theme", &site.config().theme_opts);
212
213 let content = self.templates
215 .render("category.html", &context)
216 .map_err(|e| GeneratorError::RenderTemplate(Box::new(e)))?;
217
218 std::fs::write(&dest, content)
220 .map_err(|e| GeneratorError::WriteFile(dest, e))?;
221 }
222
223 Ok(())
224 }
225
226 fn generate_page(
227 &self,
228 page: RenderedPageRef<'_>,
229 site: &RenderedSite<'_>,
230 ) -> Result<(), GeneratorError> {
231 let dest = self.options.destination.join(page.url()).join("index.html");
232
233 debug!("destination path: {}", dest.display());
234
235 let content = page.rendered_contents();
236
237 debug!("post template: {:?}", page.template());
238 let content = match page.template() {
239 Some(template) => {
240 let mut context = tera::Context::new();
241 context.insert("site", &site.value());
242 context.insert("page", &page.value());
243 context.insert("theme", &site.config().theme_opts);
244
245 let content_template = site
246 .config()
247 .macros
248 .iter()
249 .map(|(name, path)| format!("{{% import \"{}\" as {name} %}}", path.display()))
250 .collect::<Vec<_>>()
251 .join("")
252 + content;
253 let mut templates = self.templates.clone();
254 let content = templates
255 .render_str(&content_template, &context)
256 .map_err(|e| GeneratorError::ImportSiteMacros(Box::new(e)))?;
257
258 context.insert("content", &content);
259 self.templates
260 .render(&format!("{template}.html"), &context)
261 .map_err(|e| GeneratorError::RenderTemplate(Box::new(e)))?
262 }
263 None => content.to_string(),
264 };
265
266 std::fs::create_dir_all(dest.parent().unwrap())
267 .map_err(|e| GeneratorError::CreateDestDir(dest.parent().unwrap().to_path_buf(), e))?;
268
269 std::fs::write(&dest, content).map_err(|e| GeneratorError::WriteFile(dest, e))?;
270
271 Ok(())
272 }
273
274 fn copy_raw_files(&self, site: &RenderedSite<'_>) -> Result<(), GeneratorError> {
275 for file in site.raw_files() {
276 debug!(
277 "copying from {}, root {}",
278 file.display(),
279 site.root_dir().display()
280 );
281 let Some(relative_dest) = diff_paths(file, site.root_dir()) else {
282 return Err(GeneratorError::ComputeRelativePath(file.into()))?;
283 };
284 let dest = self.options.destination.join(relative_dest);
285
286 if let Some(parent) = dest.parent() {
287 fs::create_dir_all(parent)
288 .map_err(|e| GeneratorError::CreateDestDir(parent.into(), e))?;
289 }
290
291 fs::copy(file, &dest).map_err(|e| GeneratorError::Copy(file.into(), dest, e))?;
292 }
293 Ok(())
294 }
295}
296
297trait ToValue {
299 fn value(&self) -> Value;
300}
301
302impl ToValue for RenderedPageRef<'_> {
303 fn value(&self) -> Value {
304 let mut page = Map::new();
305 page.insert("title".to_string(), json!(self.title()));
306 page.insert("url".to_string(), json!(Path::new("/").join(self.url())));
307 if let Some(date) = self.publish_date() {
308 page.insert("date".to_string(), json!(date));
309 }
310 page.insert(
311 "excerpt".to_string(),
312 json!(self.rendered_excerpt().unwrap_or(self.rendered_contents())),
313 );
314 page.insert("content".to_string(), json!(self.rendered_contents()));
315 page.insert(
316 "show_in_home".to_string(),
317 json!(self.source.show_in_home()),
318 );
319 page.into()
320 }
321}
322
323impl ToValue for RenderedSite<'_> {
324 fn value(&self) -> Value {
325 let mut site = [
327 ("url".to_string(), json!(self.base_url())),
328 ("title".to_string(), json!(self.title())),
329 ("author".to_string(), json!(self.author())),
330 ("author_email".to_string(), json!(self.author_email())),
331 ]
332 .into_iter()
333 .collect::<Map<_, _>>();
334
335 let mut posts = self.posts().collect::<Vec<_>>();
336 posts.sort_by_key(|b| std::cmp::Reverse(b.publish_date()));
337
338 site.insert(
339 "posts".to_string(),
340 json!(
341 posts
342 .into_iter()
343 .map(|post| post.value())
344 .collect::<Vec<_>>()
345 ),
346 );
347
348 site.insert(
349 "categories".to_string(),
350 json!(
351 self.categories_and_pages()
352 .into_iter()
353 .map(|(category, pages)| {
354 let mut c = Map::new();
355 c.insert("name".to_string(), json!(category.name));
356 c.insert(
357 "posts".to_string(),
358 pages.map(|page| page.value()).collect::<Vec<_>>().into(),
359 );
360 c
361 })
362 .collect::<Vec<_>>()
363 ),
364 );
365
366 site.into()
367 }
368}
369
370#[cfg(test)]
371mod test {
372 use crate::{
373 diagnostics::DiagnosticContext,
374 index::{PageSource, SiteIndex, SourceFormat},
375 renderer::{CodeFormatter, RenderContext, RenderError, RenderSource, RenderedPageRef},
376 };
377
378 use super::ToValue;
379
380 #[test]
382 fn template_full_excerpt_when_missing_delimiter() -> miette::Result<()> {
383 let page = PageSource::from_string(
384 "2012-10-14-hello.md",
385 SourceFormat::Markdown,
386 "---
387title: Hello
388layout: page
389---
390this is *an excerpt*
391
392this is *also an excerpt*",
393 );
394
395 let site = SiteIndex::default();
396 let fmt = CodeFormatter::new();
397 let page = DiagnosticContext::with(|dcx| {
398 let rcx = RenderContext::new(&site, &fmt, dcx);
399 let rendered_page = page.render(&rcx)?;
400 let page = RenderedPageRef::new(&page, &rendered_page);
401 Ok::<_, RenderError>(page.value())
402 })?;
403
404 assert_eq!(
405 page["excerpt"],
406 "<p>this is <em>an excerpt</em></p>\n<p>this is <em>also an excerpt</em></p>\n<hr />\n"
407 );
408
409 Ok(())
410 }
411}