1#[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")]
58const MAX_CACHE_SIZE: usize = 1000;
60
61#[cfg(feature = "ssg")]
62#[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#[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#[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 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 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 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 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 {
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 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 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 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 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 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 async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
376 fs::create_dir_all(dst).await?;
378
379 let mut stack = vec![(src.to_path_buf(), dst.to_path_buf())];
381
382 while let Some((src_dir, dst_dir)) = stack.pop() {
383 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 fs::create_dir_all(&dest_path).await?;
393 stack.push((path, dest_path));
394 } else {
395 let _ = fs::copy(&path, &dest_path).await?;
397 }
398 }
399 }
400
401 Ok(())
402 }
403
404 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#[cfg(all(test, feature = "ssg"))]
422mod tests {
423 use super::*;
424 use tempfile::tempdir;
425
426 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 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 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 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 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}