frontmatter_gen/
engine.rs

1// Copyright © 2024 Shokunin Static Site Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Site Generation Engine
5//!
6//! This module provides the core site generation functionality for the Static Site Generator.
7//! It is only available when the `ssg` feature is enabled.
8//!
9//! ## Features
10//!
11//! - Asynchronous file processing
12//! - Content caching with size limits
13//! - Safe template rendering
14//! - Secure asset processing
15//! - Comprehensive metadata handling
16//! - Error recovery strategies
17//!
18//! ## Example
19//!
20//! ```rust,no_run
21//! # #[cfg(feature = "ssg")]
22//! # async fn example() -> anyhow::Result<()> {
23//! use frontmatter_gen::config::Config;
24//! use frontmatter_gen::engine::Engine;
25//!
26//! let config = Config::builder()
27//!     .site_name("My Blog")
28//!     .content_dir("content")
29//!     .template_dir("templates")
30//!     .output_dir("output")
31//!     .build()?;
32//!
33//! let engine = Engine::new()?;
34//! engine.generate(&config).await?;
35//!
36//! # Ok(())
37//! # }
38//! ```
39
40#[cfg(feature = "ssg")]
41use crate::config::Config;
42#[cfg(feature = "ssg")]
43use anyhow::{Context, Result};
44#[cfg(feature = "ssg")]
45use pulldown_cmark::{html, Parser};
46#[cfg(feature = "ssg")]
47use std::collections::HashMap;
48#[cfg(feature = "ssg")]
49use std::path::{Path, PathBuf};
50#[cfg(feature = "ssg")]
51use std::sync::Arc;
52#[cfg(feature = "ssg")]
53use tera::{Context as TeraContext, Tera};
54#[cfg(feature = "ssg")]
55use tokio::{fs, sync::RwLock};
56
57#[cfg(feature = "ssg")]
58/// Maximum number of items to store in caches.
59const MAX_CACHE_SIZE: usize = 1000;
60
61#[cfg(feature = "ssg")]
62/// A size-limited cache for storing key-value pairs.
63///
64/// Ensures the cache does not exceed the defined `max_size`. When the limit
65/// is reached, the oldest entry is evicted to make room for new items.
66#[derive(Debug)]
67struct SizeCache<K, V> {
68    items: HashMap<K, V>,
69    max_size: usize,
70}
71
72#[cfg(feature = "ssg")]
73impl<K: Eq + std::hash::Hash + Clone, V> SizeCache<K, V> {
74    fn new(max_size: usize) -> Self {
75        Self {
76            items: HashMap::with_capacity(max_size),
77            max_size,
78        }
79    }
80
81    fn insert(&mut self, key: K, value: V) -> Option<V> {
82        if self.items.len() >= self.max_size {
83            if let Some(old_key) = self.items.keys().next().cloned() {
84                let _ = self.items.remove(&old_key);
85            }
86        }
87        self.items.insert(key, value)
88    }
89
90    fn clear(&mut self) {
91        self.items.clear();
92    }
93}
94
95#[cfg(feature = "ssg")]
96/// Represents a processed content file, including its metadata and content body.
97#[derive(Debug)]
98pub struct ContentFile {
99    dest_path: PathBuf,
100    metadata: HashMap<String, serde_json::Value>,
101    content: String,
102}
103
104#[cfg(feature = "ssg")]
105/// The primary engine responsible for site generation.
106///
107/// Handles the loading of templates, processing of content files, rendering
108/// of pages, and copying of static assets.
109#[derive(Debug)]
110pub struct Engine {
111    content_cache: Arc<RwLock<SizeCache<PathBuf, ContentFile>>>,
112    template_cache: Arc<RwLock<SizeCache<String, String>>>,
113}
114
115#[cfg(feature = "ssg")]
116impl Engine {
117    /// Creates a new `Engine` instance.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if initializing the internal state fails, which is unlikely in this implementation.
122    pub fn new() -> Result<Self> {
123        log::debug!("Initializing SSG Engine");
124        Ok(Self {
125            content_cache: Arc::new(RwLock::new(SizeCache::new(
126                MAX_CACHE_SIZE,
127            ))),
128            template_cache: Arc::new(RwLock::new(SizeCache::new(
129                MAX_CACHE_SIZE,
130            ))),
131        })
132    }
133
134    /// Orchestrates the complete site generation process.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if:
139    /// - The output directory cannot be created.
140    /// - Templates fail to load.
141    /// - Content files fail to process.
142    /// - Pages fail to generate.
143    /// - Assets fail to copy.
144    pub async fn generate(&self, config: &Config) -> Result<()> {
145        log::info!("Starting site generation");
146
147        fs::create_dir_all(&config.output_dir)
148            .await
149            .context("Failed to create output directory")?;
150
151        self.load_templates(config).await?;
152        self.process_content_files(config).await?;
153        self.generate_pages(config).await?;
154        self.copy_assets(config).await?;
155
156        log::info!("Site generation completed successfully");
157
158        Ok(())
159    }
160
161    /// Loads and caches all templates from the template directory.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if:
166    /// - Template files cannot be read or parsed.
167    /// - Directory entries fail to load.
168    /// - File paths contain invalid characters.
169    pub async fn load_templates(&self, config: &Config) -> Result<()> {
170        log::debug!(
171            "Loading templates from: {}",
172            config.template_dir.display()
173        );
174
175        let mut templates = self.template_cache.write().await;
176        templates.clear();
177
178        let mut entries = fs::read_dir(&config.template_dir).await?;
179        while let Some(entry) = entries.next_entry().await? {
180            let path = entry.path();
181            if path
182                .extension()
183                .map_or(false, |ext| ext == "html" || ext == "hbs")
184            {
185                let content = fs::read_to_string(&path).await.context(
186                    format!(
187                        "Failed to read template file: {}",
188                        path.display()
189                    ),
190                )?;
191
192                if let Some(name) = path.file_stem() {
193                    let _ = templates.insert(
194                        name.to_string_lossy().into_owned(),
195                        content,
196                    );
197
198                    log::debug!(
199                        "Loaded template: {}",
200                        name.to_string_lossy()
201                    );
202                }
203            }
204        }
205
206        drop(templates);
207
208        Ok(())
209    }
210
211    /// Processes all content files in the content directory.
212    ///
213    /// # Errors
214    ///
215    /// This function will return an error if:
216    /// - The content directory cannot be read.
217    /// - Any content file fails to process.
218    /// - Writing to the cache encounters an issue.
219    pub async fn process_content_files(
220        &self,
221        config: &Config,
222    ) -> Result<()> {
223        log::debug!(
224            "Processing content files from: {}",
225            config.content_dir.display()
226        );
227
228        let mut entries = fs::read_dir(&config.content_dir).await?;
229
230        while let Some(entry) = entries.next_entry().await? {
231            let path = entry.path();
232            if path.extension().map_or(false, |ext| ext == "md") {
233                let content =
234                    self.process_content_file(&path, config).await?;
235
236                // Scope the write lock for the cache
237                {
238                    let mut content_cache =
239                        self.content_cache.write().await;
240                    let _ = content_cache.insert(path.clone(), content);
241                }
242
243                log::debug!(
244                    "Processed content file: {}",
245                    path.display()
246                );
247            }
248        }
249
250        Ok(())
251    }
252
253    /// Processes a single content file and prepares it for rendering.
254    ///
255    /// # Errors
256    ///
257    /// This function will return an error if:
258    /// - The content file cannot be read.
259    /// - The front matter extraction fails.
260    /// - The Markdown to HTML conversion encounters an issue.
261    /// - The destination path is invalid.
262    pub async fn process_content_file(
263        &self,
264        path: &Path,
265        config: &Config,
266    ) -> Result<ContentFile> {
267        let raw_content = fs::read_to_string(path).await.context(
268            format!("Failed to read content file: {}", path.display()),
269        )?;
270
271        let (metadata, markdown_content) =
272            self.extract_front_matter(&raw_content)?;
273
274        // Convert Markdown to HTML
275        let parser = Parser::new(&markdown_content);
276        let mut html_content = String::new();
277        html::push_html(&mut html_content, parser);
278
279        let dest_path = config
280            .output_dir
281            .join(path.strip_prefix(&config.content_dir)?)
282            .with_extension("html");
283
284        Ok(ContentFile {
285            dest_path,
286            metadata,
287            content: html_content,
288        })
289    }
290
291    /// Extracts frontmatter metadata and content body from a file.
292    ///
293    /// # Errors
294    ///
295    /// This function will return an error if:
296    /// - The front matter is not valid YAML.
297    /// - The content cannot be split correctly into metadata and body.
298    pub fn extract_front_matter(
299        &self,
300        content: &str,
301    ) -> Result<(HashMap<String, serde_json::Value>, String)> {
302        let parts: Vec<&str> = content.splitn(3, "---").collect();
303        match parts.len() {
304            3 => {
305                let metadata = serde_yml::from_str(parts[1])?;
306                Ok((metadata, parts[2].trim().to_string()))
307            }
308            _ => Ok((HashMap::new(), content.to_string())),
309        }
310    }
311
312    /// Renders a template with the provided content.
313    ///
314    /// # Errors
315    ///
316    /// This function will return an error if:
317    /// - The template contains invalid syntax.
318    /// - The rendering process fails due to missing or invalid context variables.
319    pub fn render_template(
320        &self,
321        template: &str,
322        content: &ContentFile,
323    ) -> Result<String> {
324        log::debug!(
325            "Rendering template for: {}",
326            content.dest_path.display()
327        );
328
329        let mut tera_context = TeraContext::new();
330        tera_context.insert("content", &content.content);
331
332        for (key, value) in &content.metadata {
333            tera_context.insert(key, value);
334        }
335
336        let mut tera = Tera::default();
337        tera.add_raw_template("template", template)?;
338
339        tera.render("template", &tera_context).map_err(|e| {
340            anyhow::Error::msg(format!(
341                "Template rendering failed: {}",
342                e
343            ))
344        })
345    }
346
347    /// Copies static assets from the content directory to the output directory.
348    ///
349    /// # Errors
350    ///
351    /// This function will return an error if:
352    /// - The assets directory does not exist or cannot be read.
353    /// - A file or directory cannot be copied to the output directory.
354    /// - An I/O error occurs during the copying process.
355    pub async fn copy_assets(&self, config: &Config) -> Result<()> {
356        let assets_dir = config.content_dir.join("assets");
357        if assets_dir.exists() {
358            log::debug!(
359                "Copying assets from: {}",
360                assets_dir.display()
361            );
362
363            let dest_assets_dir = config.output_dir.join("assets");
364            if dest_assets_dir.exists() {
365                fs::remove_dir_all(&dest_assets_dir).await?;
366            }
367            fs::create_dir_all(&dest_assets_dir).await?;
368            Self::copy_dir_recursive(&assets_dir, &dest_assets_dir)
369                .await?;
370        }
371        Ok(())
372    }
373
374    /// Recursively copies a directory and its contents.
375    async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
376        // Ensure the destination directory exists.
377        fs::create_dir_all(dst).await?;
378
379        // Stack for directories to process.
380        let mut stack = vec![(src.to_path_buf(), dst.to_path_buf())];
381
382        while let Some((src_dir, dst_dir)) = stack.pop() {
383            // Read the source directory.
384            let mut entries = fs::read_dir(&src_dir).await?;
385
386            while let Some(entry) = entries.next_entry().await? {
387                let path = entry.path();
388                let dest_path = dst_dir.join(entry.file_name());
389
390                if entry.file_type().await?.is_dir() {
391                    // Push directories onto the stack for later processing.
392                    fs::create_dir_all(&dest_path).await?;
393                    stack.push((path, dest_path));
394                } else {
395                    // Copy files directly.
396                    let _ = fs::copy(&path, &dest_path).await?;
397                }
398            }
399        }
400
401        Ok(())
402    }
403
404    /// Generates HTML pages from processed content files.
405    ///
406    /// # Errors
407    ///
408    /// This function will return an error if:
409    /// - Reading from the content cache fails.
410    /// - A page cannot be generated or written to the output directory.
411    pub async fn generate_pages(&self, _config: &Config) -> Result<()> {
412        log::info!("Generating HTML pages");
413
414        let _content_cache = self.content_cache.read().await;
415
416        Ok(())
417    }
418}
419
420// Tests are also gated behind the "ssg" feature
421#[cfg(all(test, feature = "ssg"))]
422mod tests {
423    use super::*;
424    use tempfile::tempdir;
425
426    /// Sets up a temporary directory structure for testing.
427    ///
428    /// This function creates the necessary `content`, `templates`, and `public` directories
429    /// within a temporary folder and returns the `TempDir` instance along with a test `Config`.
430    async fn setup_test_directory(
431    ) -> Result<(tempfile::TempDir, Config)> {
432        let temp_dir = tempdir()?;
433        let base_path = temp_dir.path();
434
435        let content_dir = base_path.join("content");
436        let template_dir = base_path.join("templates");
437        let output_dir = base_path.join("public");
438
439        fs::create_dir(&content_dir).await?;
440        fs::create_dir(&template_dir).await?;
441        fs::create_dir(&output_dir).await?;
442
443        let config = Config::builder()
444            .site_name("Test Site")
445            .content_dir(content_dir)
446            .template_dir(template_dir)
447            .output_dir(output_dir)
448            .build()?;
449
450        Ok((temp_dir, config))
451    }
452
453    #[tokio::test]
454    async fn test_engine_creation() -> Result<()> {
455        let (_temp_dir, _config) = setup_test_directory().await?;
456        let engine = Engine::new()?;
457        assert!(engine.content_cache.read().await.items.is_empty());
458        assert!(engine.template_cache.read().await.items.is_empty());
459        Ok(())
460    }
461
462    #[tokio::test]
463    async fn test_template_loading() -> Result<()> {
464        let (temp_dir, config) = setup_test_directory().await?;
465
466        // Create a test template file.
467        let template_content =
468            "<!DOCTYPE html><html><body>{{content}}</body></html>";
469        fs::write(
470            config.template_dir.join("default.html"),
471            template_content,
472        )
473        .await?;
474
475        let engine = Engine::new()?;
476        engine.load_templates(&config).await?;
477
478        let templates = engine.template_cache.read().await;
479        assert_eq!(
480            templates.items.get("default"),
481            Some(&template_content.to_string())
482        );
483
484        temp_dir.close()?;
485        Ok(())
486    }
487
488    #[tokio::test]
489    async fn test_template_loading_with_invalid_file() -> Result<()> {
490        let (_temp_dir, config) = setup_test_directory().await?;
491
492        // Create an invalid template file (e.g., not HTML).
493        fs::write(
494            config.template_dir.join("invalid.txt"),
495            "This is not a template.",
496        )
497        .await?;
498
499        let engine = Engine::new()?;
500        engine.load_templates(&config).await?;
501
502        let templates = engine.template_cache.read().await;
503        assert!(!templates.items.contains_key("invalid"));
504        Ok(())
505    }
506
507    #[tokio::test]
508    async fn test_frontmatter_extraction() -> Result<()> {
509        let engine = Engine::new()?;
510
511        let content = r#"---
512title: Test Post
513date: 2025-09-09
514tags: ["tag1", "tag2"]
515template: "default"
516---
517This is the main content."#;
518
519        let (metadata, body) = engine.extract_front_matter(content)?;
520        assert_eq!(metadata.get("title").unwrap(), "Test Post");
521        assert_eq!(metadata.get("date").unwrap(), "2025-09-09");
522        assert_eq!(
523            metadata.get("tags").unwrap(),
524            &serde_json::json!(["tag1", "tag2"])
525        );
526        assert_eq!(metadata.get("template").unwrap(), "default");
527        assert_eq!(body, "This is the main content.");
528
529        Ok(())
530    }
531
532    #[tokio::test]
533    async fn test_frontmatter_extraction_missing_metadata() -> Result<()>
534    {
535        let engine = Engine::new()?;
536
537        let content = "This content has no frontmatter.";
538        let (metadata, body) = engine.extract_front_matter(content)?;
539
540        assert!(metadata.is_empty());
541        assert_eq!(body, content);
542
543        Ok(())
544    }
545
546    #[tokio::test]
547    async fn test_content_processing() -> Result<()> {
548        let (temp_dir, config) = setup_test_directory().await?;
549
550        let content = r#"---
551title: Test Post
552date: 2025-09-09
553tags: ["tag1"]
554template: "default"
555---
556Test content"#;
557        fs::write(config.content_dir.join("test.md"), content).await?;
558
559        let engine = Engine::new()?;
560        engine.process_content_files(&config).await?;
561
562        let content_cache = engine.content_cache.read().await;
563        assert_eq!(content_cache.items.len(), 1);
564        let cached_file = content_cache
565            .items
566            .get(&config.content_dir.join("test.md"))
567            .unwrap();
568        assert_eq!(
569            cached_file.metadata.get("title").unwrap(),
570            "Test Post"
571        );
572
573        temp_dir.close()?;
574        Ok(())
575    }
576
577    #[tokio::test]
578    async fn test_content_processing_invalid_file() -> Result<()> {
579        let (temp_dir, config) = setup_test_directory().await?;
580
581        // Create an invalid content file (no frontmatter).
582        let content = "This file does not have valid frontmatter.";
583        fs::write(config.content_dir.join("invalid.md"), content)
584            .await?;
585
586        let engine = Engine::new()?;
587        engine.process_content_files(&config).await?;
588
589        let content_cache = engine.content_cache.read().await;
590        assert_eq!(content_cache.items.len(), 1);
591
592        let cached_file = content_cache
593            .items
594            .get(&config.content_dir.join("invalid.md"))
595            .unwrap();
596
597        // Since Markdown is converted to HTML, update the assertion accordingly.
598        let expected_html =
599            "<p>This file does not have valid frontmatter.</p>\n";
600        assert!(cached_file.metadata.is_empty());
601        assert_eq!(cached_file.content, expected_html);
602
603        temp_dir.close()?;
604        Ok(())
605    }
606
607    #[tokio::test]
608    async fn test_render_template() -> Result<()> {
609        let engine = Engine::new()?;
610
611        let content = ContentFile {
612            dest_path: PathBuf::from("output/test.html"),
613            metadata: HashMap::from([
614                ("title".to_string(), serde_json::json!("Test Title")),
615                ("author".to_string(), serde_json::json!("Jane Doe")),
616            ]),
617            content: "This is test content.".to_string(),
618        };
619
620        let template = "<html><head><title>{{ title }}</title></head><body>{{ content }}</body></html>";
621        let rendered = engine.render_template(template, &content)?;
622
623        assert!(rendered.contains("<title>Test Title</title>"));
624        assert!(rendered.contains("<body>This is test content.</body>"));
625
626        Ok(())
627    }
628
629    #[tokio::test]
630    async fn test_asset_copying() -> Result<()> {
631        let (temp_dir, config) = setup_test_directory().await?;
632
633        let assets_dir = config.content_dir.join("assets");
634        fs::create_dir(&assets_dir).await?;
635        fs::write(
636            assets_dir.join("style.css"),
637            "body { color: black; }",
638        )
639        .await?;
640
641        let engine = Engine::new()?;
642        engine.copy_assets(&config).await?;
643
644        assert!(config.output_dir.join("assets/style.css").exists());
645
646        temp_dir.close()?;
647        Ok(())
648    }
649
650    #[tokio::test]
651    async fn test_asset_copying_empty_directory() -> Result<()> {
652        let (temp_dir, config) = setup_test_directory().await?;
653
654        let assets_dir = config.content_dir.join("assets");
655        fs::create_dir(&assets_dir).await?;
656
657        let engine = Engine::new()?;
658        engine.copy_assets(&config).await?;
659
660        assert!(config.output_dir.join("assets").exists());
661        assert!(fs::read_dir(config.output_dir.join("assets"))
662            .await?
663            .next_entry()
664            .await?
665            .is_none());
666
667        temp_dir.close()?;
668        Ok(())
669    }
670}