ebg/
generator.rs

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    /// Include posts marked with `published: false`
37    #[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
73/// Holds dynamic state and configuration needed to render a site.
74pub 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    /// Check if a template with the given name exists
96    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        // Clear the destination directory
103        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                    // If the rename fails, try to remove the destination directory
116                    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        // Create the destination directory
127        tokio::fs::create_dir_all(&self.options.destination)
128            .await
129            .map_err(|e| GeneratorError::CreateDestDir(self.options.destination.clone(), e))?;
130
131        // Generate pages
132        self.generate_pages(site)?;
133
134        // Copy raw files (those that don't need processing or generation)
135        self.copy_raw_files(site)?;
136
137        // Generate the atom feed
138        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        // FIXME(#199): We should add per-category atom feeds
146
147        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        // Generate per-category index pages if the template exists
170        if self.has_template("category") {
171            self.generate_category_pages(site)?;
172        }
173        
174        Ok(())
175    }
176
177    /// Generate index pages for each category
178    fn generate_category_pages(&self, site: &RenderedSite<'_>) -> Result<(), GeneratorError> {
179        // Iterate through all categories
180        for (category, pages) in site.categories_and_pages() {            // Create a directory for the category using a slug of its name
181            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            // Create the directory
191            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              // Prepare template context
196            let mut context = tera::Context::new();
197            context.insert("site", &site.value());
198            context.insert("category", &category.name);
199              // Create a page value with title for the category
200            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            // Add sorted pages for this category
207            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            // Render the template
214            let content = self.templates
215                .render("category.html", &context)
216                .map_err(|e| GeneratorError::RenderTemplate(Box::new(e)))?;
217            
218            // Write the output file
219            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
297/// Converts an object into a format that can be passed to a Tera template
298trait 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        // Add metadata from Site.toml
326        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    /// Regression test for #12
381    #[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}