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.contains_key(&key)
89 && self.items.len() >= self.max_size
90 {
91 if let Some(old_key) = self.items.keys().next().cloned() {
92 let _ = self.items.remove(&old_key);
93 }
94 }
95 self.items.insert(key, value)
96 }
97
98 fn clear(&mut self) {
99 self.items.clear();
100 }
101}
102
103#[cfg(feature = "ssg")]
104#[derive(Debug)]
106pub struct ContentFile {
107 dest_path: PathBuf,
108 metadata: HashMap<String, serde_json::Value>,
109 content: String,
110}
111
112#[cfg(feature = "ssg")]
113#[derive(Debug)]
118pub struct Engine {
119 content_cache: Arc<RwLock<SizeCache<PathBuf, ContentFile>>>,
120 template_cache: Arc<RwLock<SizeCache<String, String>>>,
121}
122
123#[cfg(feature = "ssg")]
124impl Engine {
125 pub fn new() -> Result<Self> {
131 log::debug!("Initializing SSG Engine");
132 Ok(Self {
133 content_cache: Arc::new(RwLock::new(SizeCache::new(
134 MAX_CACHE_SIZE,
135 ))),
136 template_cache: Arc::new(RwLock::new(SizeCache::new(
137 MAX_CACHE_SIZE,
138 ))),
139 })
140 }
141
142 pub async fn generate(&self, config: &Config) -> Result<()> {
153 log::info!("Starting site generation");
154
155 fs::create_dir_all(&config.output_dir)
156 .await
157 .context("Failed to create output directory")?;
158
159 self.load_templates(config).await?;
160 self.process_content_files(config).await?;
161 self.generate_pages(config).await?;
162 self.copy_assets(config).await?;
163
164 log::info!("Site generation completed successfully");
165
166 Ok(())
167 }
168
169 pub async fn load_templates(&self, config: &Config) -> Result<()> {
178 log::debug!(
179 "Loading templates from: {}",
180 config.template_dir.display()
181 );
182
183 let mut templates = self.template_cache.write().await;
184 templates.clear();
185
186 let mut entries = fs::read_dir(&config.template_dir).await?;
187 while let Some(entry) = entries.next_entry().await? {
188 let path = entry.path();
189 if path
190 .extension()
191 .is_some_and(|ext| ext == "html" || ext == "hbs")
192 {
193 let content = fs::read_to_string(&path).await.context(
194 format!(
195 "Failed to read template file: {}",
196 path.display()
197 ),
198 )?;
199
200 let name = path
201 .file_stem()
202 .unwrap_or_default()
203 .to_string_lossy()
204 .into_owned();
205 log::debug!("Loaded template: {}", name);
206 let _ = templates.insert(name, content);
207 }
208 }
209
210 drop(templates);
211
212 Ok(())
213 }
214
215 pub async fn process_content_files(
224 &self,
225 config: &Config,
226 ) -> Result<()> {
227 log::debug!(
228 "Processing content files from: {}",
229 config.content_dir.display()
230 );
231
232 let mut entries = fs::read_dir(&config.content_dir).await?;
233
234 while let Some(entry) = entries.next_entry().await? {
235 let path = entry.path();
236 if path.extension().is_some_and(|ext| ext == "md") {
237 let content =
238 self.process_content_file(&path, config).await?;
239
240 {
242 let mut content_cache =
243 self.content_cache.write().await;
244 let _ = content_cache.insert(path.clone(), content);
245 }
246
247 log::debug!(
248 "Processed content file: {}",
249 path.display()
250 );
251 }
252 }
253
254 Ok(())
255 }
256
257 pub async fn process_content_file(
267 &self,
268 path: &Path,
269 config: &Config,
270 ) -> Result<ContentFile> {
271 let raw_content = fs::read_to_string(path).await.context(
272 format!("Failed to read content file: {}", path.display()),
273 )?;
274
275 let (metadata, markdown_content) =
276 self.extract_front_matter(&raw_content)?;
277
278 let parser = Parser::new(&markdown_content);
280 let mut html_content = String::new();
281 html::push_html(&mut html_content, parser);
282
283 let dest_path = config
284 .output_dir
285 .join(path.strip_prefix(&config.content_dir)?)
286 .with_extension("html");
287
288 Ok(ContentFile {
289 dest_path,
290 metadata,
291 content: html_content,
292 })
293 }
294
295 pub fn extract_front_matter(
303 &self,
304 content: &str,
305 ) -> Result<(HashMap<String, serde_json::Value>, String)> {
306 let parts: Vec<&str> = content.splitn(3, "---").collect();
307 match parts.len() {
308 3 => {
309 let metadata = noyalib::from_str(parts[1])?;
310 Ok((metadata, parts[2].trim().to_string()))
311 }
312 _ => Ok((HashMap::new(), content.to_string())),
313 }
314 }
315
316 pub fn render_template(
324 &self,
325 template: &str,
326 content: &ContentFile,
327 ) -> Result<String> {
328 log::debug!(
329 "Rendering template for: {}",
330 content.dest_path.display()
331 );
332
333 let mut tera_context = TeraContext::new();
334 tera_context.insert("content", &content.content);
335
336 for (key, value) in &content.metadata {
337 tera_context.insert(key.clone(), value);
341 }
342
343 let mut tera = Tera::default();
344 tera.add_raw_template("template", template)?;
345
346 tera.render("template", &tera_context).map_err(|e| {
347 anyhow::Error::msg(format!(
348 "Template rendering failed: {}",
349 e
350 ))
351 })
352 }
353
354 pub async fn copy_assets(&self, config: &Config) -> Result<()> {
363 let assets_dir = config.content_dir.join("assets");
364 if assets_dir.exists() {
365 log::debug!(
366 "Copying assets from: {}",
367 assets_dir.display()
368 );
369
370 let dest_assets_dir = config.output_dir.join("assets");
371 if dest_assets_dir.exists() {
372 fs::remove_dir_all(&dest_assets_dir).await?;
373 }
374 fs::create_dir_all(&dest_assets_dir).await?;
375 Self::copy_dir_recursive(&assets_dir, &dest_assets_dir)
376 .await?;
377 }
378 Ok(())
379 }
380
381 async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
383 fs::create_dir_all(dst).await?;
385
386 let mut stack = vec![(src.to_path_buf(), dst.to_path_buf())];
388
389 while let Some((src_dir, dst_dir)) = stack.pop() {
390 let mut entries = fs::read_dir(&src_dir).await?;
392
393 while let Some(entry) = entries.next_entry().await? {
394 let path = entry.path();
395 let dest_path = dst_dir.join(entry.file_name());
396
397 if entry.file_type().await?.is_dir() {
398 fs::create_dir_all(&dest_path).await?;
400 stack.push((path, dest_path));
401 } else {
402 let _ = fs::copy(&path, &dest_path).await?;
404 }
405 }
406 }
407
408 Ok(())
409 }
410
411 pub async fn generate_pages(&self, _config: &Config) -> Result<()> {
419 log::info!("Generating HTML pages");
420
421 let _content_cache = self.content_cache.read().await;
422
423 Ok(())
424 }
425}
426
427#[cfg(all(test, feature = "ssg"))]
429mod tests {
430 use super::*;
431 use tempfile::tempdir;
432
433 async fn setup_test_directory(
438 ) -> Result<(tempfile::TempDir, Config)> {
439 let temp_dir = tempdir()?;
440 let base_path = temp_dir.path();
441
442 let content_dir = base_path.join("content");
443 let template_dir = base_path.join("templates");
444 let output_dir = base_path.join("public");
445
446 fs::create_dir(&content_dir).await?;
447 fs::create_dir(&template_dir).await?;
448 fs::create_dir(&output_dir).await?;
449
450 let config = Config::builder()
451 .site_name("Test Site")
452 .content_dir(content_dir)
453 .template_dir(template_dir)
454 .output_dir(output_dir)
455 .build()
456 .unwrap();
457
458 Ok((temp_dir, config))
459 }
460
461 #[tokio::test]
462 async fn test_engine_creation() -> Result<()> {
463 let (_temp_dir, _config) = setup_test_directory().await?;
464 let engine = Engine::new()?;
465 assert!(engine.content_cache.read().await.items.is_empty());
466 assert!(engine.template_cache.read().await.items.is_empty());
467 Ok(())
468 }
469
470 #[tokio::test]
471 async fn test_template_loading() -> Result<()> {
472 let (temp_dir, config) = setup_test_directory().await?;
473
474 let template_content =
476 "<!DOCTYPE html><html><body>{{content}}</body></html>";
477 fs::write(
478 config.template_dir.join("default.html"),
479 template_content,
480 )
481 .await?;
482
483 let engine = Engine::new()?;
484 engine.load_templates(&config).await?;
485
486 let templates = engine.template_cache.read().await;
487 assert_eq!(
488 templates.items.get("default"),
489 Some(&template_content.to_string())
490 );
491
492 temp_dir.close()?;
493 Ok(())
494 }
495
496 #[tokio::test]
497 async fn test_template_loading_with_invalid_file() -> Result<()> {
498 let (_temp_dir, config) = setup_test_directory().await?;
499
500 fs::write(
502 config.template_dir.join("invalid.txt"),
503 "This is not a template.",
504 )
505 .await?;
506
507 let engine = Engine::new()?;
508 engine.load_templates(&config).await?;
509
510 let templates = engine.template_cache.read().await;
511 assert!(!templates.items.contains_key("invalid"));
512 Ok(())
513 }
514
515 #[tokio::test]
516 async fn test_frontmatter_extraction() -> Result<()> {
517 let engine = Engine::new()?;
518
519 let content = r#"---
520title: Test Post
521date: 2025-09-09
522tags: ["tag1", "tag2"]
523template: "default"
524---
525This is the main content."#;
526
527 let (metadata, body) = engine.extract_front_matter(content)?;
528 assert_eq!(metadata.get("title").unwrap(), "Test Post");
529 assert_eq!(metadata.get("date").unwrap(), "2025-09-09");
530 assert_eq!(
531 metadata.get("tags").unwrap(),
532 &serde_json::json!(["tag1", "tag2"])
533 );
534 assert_eq!(metadata.get("template").unwrap(), "default");
535 assert_eq!(body, "This is the main content.");
536
537 Ok(())
538 }
539
540 #[tokio::test]
541 async fn test_frontmatter_extraction_missing_metadata() -> Result<()>
542 {
543 let engine = Engine::new()?;
544
545 let content = "This content has no frontmatter.";
546 let (metadata, body) = engine.extract_front_matter(content)?;
547
548 assert!(metadata.is_empty());
549 assert_eq!(body, content);
550
551 Ok(())
552 }
553
554 #[tokio::test]
555 async fn test_content_processing() -> Result<()> {
556 let (temp_dir, config) = setup_test_directory().await?;
557
558 let content = r#"---
559title: Test Post
560date: 2025-09-09
561tags: ["tag1"]
562template: "default"
563---
564Test content"#;
565 fs::write(config.content_dir.join("test.md"), content).await?;
566
567 let engine = Engine::new()?;
568 engine.process_content_files(&config).await?;
569
570 let content_cache = engine.content_cache.read().await;
571 assert_eq!(content_cache.items.len(), 1);
572 let cached_file = content_cache
573 .items
574 .get(&config.content_dir.join("test.md"))
575 .unwrap();
576 assert_eq!(
577 cached_file.metadata.get("title").unwrap(),
578 "Test Post"
579 );
580
581 temp_dir.close()?;
582 Ok(())
583 }
584
585 #[tokio::test]
586 async fn test_content_processing_invalid_file() -> Result<()> {
587 let (temp_dir, config) = setup_test_directory().await?;
588
589 let content = "This file does not have valid frontmatter.";
591 fs::write(config.content_dir.join("invalid.md"), content)
592 .await?;
593
594 let engine = Engine::new()?;
595 engine.process_content_files(&config).await?;
596
597 let content_cache = engine.content_cache.read().await;
598 assert_eq!(content_cache.items.len(), 1);
599
600 let cached_file = content_cache
601 .items
602 .get(&config.content_dir.join("invalid.md"))
603 .unwrap();
604
605 let expected_html =
607 "<p>This file does not have valid frontmatter.</p>\n";
608 assert!(cached_file.metadata.is_empty());
609 assert_eq!(cached_file.content, expected_html);
610
611 temp_dir.close()?;
612 Ok(())
613 }
614
615 #[tokio::test]
616 async fn test_render_template() -> Result<()> {
617 let engine = Engine::new()?;
618
619 let content = ContentFile {
620 dest_path: PathBuf::from("output/test.html"),
621 metadata: HashMap::from([
622 ("title".to_string(), serde_json::json!("Test Title")),
623 ("author".to_string(), serde_json::json!("Jane Doe")),
624 ]),
625 content: "This is test content.".to_string(),
626 };
627
628 let template = "<html><head><title>{{ title }}</title></head><body>{{ content }}</body></html>";
629 let rendered = engine.render_template(template, &content)?;
630
631 assert!(rendered.contains("<title>Test Title</title>"));
632 assert!(rendered.contains("<body>This is test content.</body>"));
633
634 Ok(())
635 }
636
637 #[tokio::test]
638 async fn test_asset_copying() -> Result<()> {
639 let (temp_dir, config) = setup_test_directory().await?;
640
641 let assets_dir = config.content_dir.join("assets");
642 fs::create_dir(&assets_dir).await?;
643 fs::write(
644 assets_dir.join("style.css"),
645 "body { color: black; }",
646 )
647 .await?;
648
649 let engine = Engine::new()?;
650 engine.copy_assets(&config).await?;
651
652 assert!(config.output_dir.join("assets/style.css").exists());
653
654 temp_dir.close()?;
655 Ok(())
656 }
657
658 #[tokio::test]
659 async fn test_asset_copying_empty_directory() -> Result<()> {
660 let (temp_dir, config) = setup_test_directory().await?;
661
662 let assets_dir = config.content_dir.join("assets");
663 fs::create_dir(&assets_dir).await?;
664
665 let engine = Engine::new()?;
666 engine.copy_assets(&config).await?;
667
668 assert!(config.output_dir.join("assets").exists());
669 assert!(fs::read_dir(config.output_dir.join("assets"))
670 .await?
671 .next_entry()
672 .await?
673 .is_none());
674
675 temp_dir.close()?;
676 Ok(())
677 }
678}
679
680#[cfg(all(test, feature = "ssg"))]
681mod exhaustive_engine_tests {
682 use super::*;
687 use std::path::Path;
688
689 #[test]
690 fn size_cache_evicts_when_it_is_full_and_clears_on_request() {
691 let mut cache: SizeCache<String, u32> = SizeCache {
692 items: HashMap::new(),
693 max_size: 2,
694 };
695 assert!(cache.insert("a".into(), 1).is_none());
696 assert!(cache.insert("b".into(), 2).is_none());
697 assert_eq!(cache.items.len(), 2);
698
699 let _ = cache.insert("c".into(), 3);
701 assert_eq!(
702 cache.items.len(),
703 2,
704 "the cache grew past max_size"
705 );
706 assert!(cache.items.contains_key("c"), "the new entry is kept");
707
708 assert_eq!(cache.insert("c".into(), 4), Some(3));
711 assert_eq!(
712 cache.items.len(),
713 2,
714 "a replacement evicted an entry"
715 );
716
717 cache.clear();
718 assert!(cache.items.is_empty());
719
720 let mut zero_cache: SizeCache<String, u32> = SizeCache::new(0);
721 let _ = zero_cache.insert("z".into(), 0);
722 }
723
724 #[test]
725 fn front_matter_is_split_from_the_body() {
726 let engine = Engine::new().expect("engine");
727
728 let (metadata, body) = engine
729 .extract_front_matter(
730 "---\ntitle: T\ncount: 2\n---\nBody text",
731 )
732 .expect("front matter splits");
733 assert_eq!(
734 metadata.get("title").and_then(|v| v.as_str()),
735 Some("T")
736 );
737 assert_eq!(body.trim(), "Body text");
738
739 let (metadata, body) = engine
741 .extract_front_matter("Just content")
742 .expect("no front matter is not an error");
743 assert!(metadata.is_empty());
744 assert_eq!(body.trim(), "Just content");
745 }
746
747 #[test]
748 fn malformed_front_matter_is_reported() {
749 let engine = Engine::new().expect("engine");
750 assert!(engine
751 .extract_front_matter("---\ntitle: [\n---\nBody")
752 .is_err());
753 }
754
755 #[test]
756 fn templates_render_with_content_and_metadata_in_scope() {
757 let engine = Engine::new().expect("engine");
758 let mut metadata = HashMap::new();
759 let _ = metadata.insert(
760 "title".to_string(),
761 serde_json::Value::String("Hello".into()),
762 );
763 let content = ContentFile {
764 dest_path: PathBuf::from("out.html"),
765 metadata,
766 content: "<p>body</p>".to_string(),
767 };
768
769 let rendered = engine
770 .render_template(
771 "<h1>{{ title }}</h1>{{ content }}",
772 &content,
773 )
774 .expect("render");
775 assert!(rendered.contains("Hello"), "{rendered}");
776 assert!(rendered.contains("<p>body</p>"), "{rendered}");
777 }
778
779 #[test]
780 fn a_broken_template_is_an_error_not_a_panic() {
781 let engine = Engine::new().expect("engine");
782 let content = ContentFile {
783 dest_path: PathBuf::from("out.html"),
784 metadata: HashMap::new(),
785 content: String::new(),
786 };
787 assert!(engine.render_template("{% if %}", &content).is_err());
788 }
789
790 async fn config_in(dir: &Path) -> Config {
791 for sub in ["content", "templates", "public"] {
792 std::fs::create_dir_all(dir.join(sub)).expect("mkdir");
793 }
794 Config::builder()
795 .site_name("test")
796 .site_title("test")
797 .site_description("test")
798 .language("en-GB")
799 .base_url("https://example.com")
800 .content_dir(dir.join("content"))
801 .output_dir(dir.join("public"))
802 .template_dir(dir.join("templates"))
803 .build()
804 .expect("config")
805 }
806
807 #[tokio::test]
808 async fn load_templates_reads_every_file_in_the_template_dir() {
809 let dir = tempfile::tempdir().expect("tempdir");
810 let config = config_in(dir.path()).await;
811 std::fs::write(
812 config.template_dir.join("page.html"),
813 b"<p>{{ content }}</p>",
814 )
815 .expect("write template");
816
817 let engine = Engine::new().expect("engine");
818 engine.load_templates(&config).await.expect("load");
819 }
820
821 #[tokio::test]
822 async fn copy_assets_is_a_no_op_without_an_assets_directory() {
823 let dir = tempfile::tempdir().expect("tempdir");
824 let config = config_in(dir.path()).await;
825 let engine = Engine::new().expect("engine");
826 engine
827 .copy_assets(&config)
828 .await
829 .expect("no assets directory is not an error");
830 }
831
832 #[tokio::test]
833 async fn generate_pages_is_currently_a_no_op() {
834 let dir = tempfile::tempdir().expect("tempdir");
835 let config = config_in(dir.path()).await;
836 let engine = Engine::new().expect("engine");
837 engine.generate_pages(&config).await.expect("no-op");
838 }
839
840 #[tokio::test]
841 async fn engine_covers_cache_zero_and_filesystem_edge_cases() {
842 log::set_max_level(log::LevelFilter::Trace);
843
844 let mut zero_cache: SizeCache<&str, &str> = SizeCache::new(0);
846 assert_eq!(zero_cache.insert("k", "v"), None);
847
848 let dir = tempfile::tempdir().expect("tempdir");
849 let config = config_in(dir.path()).await;
850 let engine = Engine::new().expect("engine");
851
852 assert!(engine
854 .process_content_file(
855 &config.content_dir.join("missing.md"),
856 &config
857 )
858 .await
859 .is_err());
860
861 std::fs::write(config.content_dir.join("notes.txt"), b"skip")
863 .expect("write txt");
864 std::fs::write(
865 config.content_dir.join("post.md"),
866 b"---\ntitle: Hello\n---\nBody",
867 )
868 .expect("write md");
869 engine
870 .process_content_files(&config)
871 .await
872 .expect("process content files");
873
874 let cf = ContentFile {
876 dest_path: config.output_dir.join("post.html"),
877 metadata: HashMap::new(),
878 content: "<p>Body</p>".into(),
879 };
880 assert!(engine
881 .render_template("{{ undefined_var }}", &cf)
882 .is_err());
883
884 let nested_asset_dir =
886 config.content_dir.join("assets").join("css");
887 std::fs::create_dir_all(&nested_asset_dir)
888 .expect("mkdir assets/css");
889 std::fs::write(
890 nested_asset_dir.join("site.css"),
891 b"body { margin: 0; }",
892 )
893 .expect("write css");
894 engine.copy_assets(&config).await.expect("first copy");
895 engine
896 .copy_assets(&config)
897 .await
898 .expect("second copy removes existing dest_assets_dir");
899 assert!(config
900 .output_dir
901 .join("assets")
902 .join("css")
903 .join("site.css")
904 .exists());
905
906 std::fs::create_dir_all(
908 config.template_dir.join("not_a_file.html"),
909 )
910 .expect("mkdir html");
911 assert!(engine.load_templates(&config).await.is_err());
912 }
913}