1use std::fs;
23use std::path::{Path, PathBuf};
24
25use ssg_core::{build_entry, CachePolicy, Manifest, ManifestEntry};
26
27use crate::error::SsgError;
28use crate::plugin::{Plugin, PluginContext};
29
30pub const MANIFEST_RELATIVE_PATH: &str = ".ssg/manifest.json";
32
33pub const CONTENT_RELATIVE_DIR: &str = ".ssg/content";
36
37#[derive(Debug, Clone, Copy)]
48pub struct IsrManifestPlugin;
49
50impl IsrManifestPlugin {
51 #[must_use]
60 pub const fn new() -> Self {
61 Self
62 }
63}
64
65impl Default for IsrManifestPlugin {
66 fn default() -> Self {
67 Self::new()
68 }
69}
70
71impl Plugin for IsrManifestPlugin {
72 fn name(&self) -> &'static str {
73 "isr-manifest"
74 }
75
76 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
77 if ctx.dry_run {
78 return Ok(());
79 }
80
81 let manifest =
82 build_manifest(&ctx.content_dir, &ctx.template_dir, &ctx.site_dir)?;
83
84 write_manifest(&manifest, &ctx.site_dir)?;
85 copy_sources(
86 &ctx.content_dir,
87 &ctx.template_dir,
88 &ctx.site_dir,
89 &manifest,
90 )?;
91
92 Ok(())
93 }
94}
95
96pub fn build_manifest(
128 content_dir: &Path,
129 template_dir: &Path,
130 site_dir: &Path,
131) -> Result<Manifest, SsgError> {
132 let mut manifest = Manifest::new(build_stamp());
133
134 let md_files = collect_md_files(content_dir)?;
135 for md_path in md_files {
136 let entry = build_entry_for_markdown(
137 &md_path,
138 content_dir,
139 template_dir,
140 site_dir,
141 )?;
142 let Some((url, entry)) = entry else { continue };
143 manifest.insert(url, entry);
144 }
145
146 Ok(manifest)
147}
148
149fn build_stamp() -> String {
152 let version = env!("CARGO_PKG_VERSION");
153 format!("ssg-{version}")
154}
155
156fn collect_md_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
160 let mut out = Vec::new();
161 if !dir.exists() {
162 return Ok(out);
163 }
164 visit(dir, &mut out)?;
165 out.sort();
166 Ok(out)
167}
168
169fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), SsgError> {
170 let entries = fs::read_dir(dir).map_err(|e| SsgError::Io {
171 path: dir.to_path_buf(),
172 source: e,
173 })?;
174 for entry in entries.flatten() {
175 let path = entry.path();
176 let name = entry.file_name();
177 let name_str = name.to_string_lossy();
178 if name_str.starts_with('.') {
179 continue;
180 }
181 if path.is_dir() {
182 visit(&path, out)?;
183 } else if path.extension().is_some_and(|e| e == "md") {
184 out.push(path);
185 }
186 }
187 Ok(())
188}
189
190fn build_entry_for_markdown(
192 md_path: &Path,
193 content_dir: &Path,
194 template_dir: &Path,
195 _site_dir: &Path,
196) -> Result<Option<(String, ManifestEntry)>, SsgError> {
197 let bytes = fs::read(md_path).map_err(|e| SsgError::Io {
198 path: md_path.to_path_buf(),
199 source: e,
200 })?;
201
202 let rel = md_path
203 .strip_prefix(content_dir)
204 .unwrap_or(md_path)
205 .to_string_lossy()
206 .replace('\\', "/");
207
208 let url = derive_url(&rel);
210
211 let text = String::from_utf8_lossy(&bytes);
213 let cache = extract_isr_cache(&text);
214
215 let templates = collect_templates(template_dir);
219 let mut template_bytes_owned: Vec<Vec<u8>> =
220 Vec::with_capacity(templates.len());
221 let mut sources: Vec<String> = Vec::with_capacity(1 + templates.len());
222 sources.push(format!("content/{rel}"));
223
224 for (tpl_rel, tpl_path) in &templates {
225 let tb = fs::read(tpl_path).map_err(|e| SsgError::Io {
226 path: tpl_path.clone(),
227 source: e,
228 })?;
229 template_bytes_owned.push(tb);
230 sources.push(format!("templates/{tpl_rel}"));
231 }
232
233 let mut byte_refs: Vec<&[u8]> = Vec::with_capacity(sources.len());
234 byte_refs.push(&bytes);
235 for tb in &template_bytes_owned {
236 byte_refs.push(tb);
237 }
238
239 let entry = build_entry(sources, &byte_refs, cache);
240 Ok(Some((url, entry)))
241}
242
243fn collect_templates(template_dir: &Path) -> Vec<(String, PathBuf)> {
247 let mut out = Vec::new();
248 if !template_dir.exists() {
249 return out;
250 }
251 let candidates = ["index.html", "page.html"];
252 for name in candidates {
253 let p = template_dir.join(name);
254 if p.exists() {
255 out.push((name.to_string(), p));
256 }
257 }
258 out
259}
260
261fn derive_url(rel: &str) -> String {
265 let stripped = rel.strip_suffix(".md").unwrap_or(rel);
266 if stripped == "index" {
267 return "/index.html".to_string();
268 }
269 if let Some(trim) = stripped.strip_suffix("/index") {
270 return format!("/{trim}/index.html");
271 }
272 format!("/{stripped}/index.html")
273}
274
275fn extract_isr_cache(text: &str) -> Option<CachePolicy> {
286 let fm = extract_frontmatter_block(text)?;
288
289 let mut in_isr = false;
294 let mut s_maxage: Option<u32> = None;
295 let mut swr: Option<u32> = None;
296
297 for raw_line in fm.lines() {
298 let line = raw_line.trim_end();
299 if line.starts_with("isr:") {
300 in_isr = true;
301 continue;
302 }
303 if in_isr {
304 let trimmed = line.trim_start();
305 if line.starts_with(' ') || line.starts_with('\t') {
307 if let Some((k, v)) = trimmed.split_once(':') {
308 let k = k.trim();
309 let v = v.trim();
310 match k {
311 "s_maxage" | "s-maxage" => {
312 s_maxage = v.parse::<u32>().ok();
313 }
314 "swr" | "stale-while-revalidate" => {
315 swr = v.parse::<u32>().ok();
316 }
317 _ => {}
318 }
319 }
320 } else if !line.is_empty() {
321 in_isr = false;
322 }
323 }
324 }
325
326 if s_maxage.is_none() && swr.is_none() {
327 return None;
328 }
329 Some(CachePolicy {
330 s_maxage: s_maxage.unwrap_or(ssg_core::DEFAULT_S_MAXAGE),
331 swr: swr.unwrap_or(ssg_core::DEFAULT_SWR),
332 })
333}
334
335fn extract_frontmatter_block(text: &str) -> Option<&str> {
338 let trimmed = text.trim_start();
339 if let Some(after) = trimmed.strip_prefix("---") {
340 if let Some(end) = after.find("---") {
341 return Some(&after[..end]);
342 }
343 }
344 if let Some(after) = trimmed.strip_prefix("+++") {
345 if let Some(end) = after.find("+++") {
346 return Some(&after[..end]);
347 }
348 }
349 None
350}
351
352fn write_manifest(
357 manifest: &Manifest,
358 site_dir: &Path,
359) -> Result<(), SsgError> {
360 let manifest_path = site_dir.join(MANIFEST_RELATIVE_PATH);
361 if let Some(parent) = manifest_path.parent() {
362 fs::create_dir_all(parent).map_err(|e| SsgError::Io {
363 path: parent.to_path_buf(),
364 source: e,
365 })?;
366 }
367 let json = manifest.to_pretty_json().map_err(|e| SsgError::Io {
368 path: manifest_path.clone(),
369 source: std::io::Error::other(e),
370 })?;
371 fs::write(&manifest_path, json).map_err(|e| SsgError::Io {
372 path: manifest_path.clone(),
373 source: e,
374 })?;
375 Ok(())
376}
377
378fn copy_sources(
379 content_dir: &Path,
380 template_dir: &Path,
381 site_dir: &Path,
382 manifest: &Manifest,
383) -> Result<(), SsgError> {
384 let content_out = site_dir.join(CONTENT_RELATIVE_DIR);
385 fs::create_dir_all(&content_out).map_err(|e| SsgError::Io {
386 path: content_out.clone(),
387 source: e,
388 })?;
389
390 let mut all_sources = std::collections::BTreeSet::new();
392 for entry in manifest.entries.values() {
393 for s in &entry.sources {
394 let _ = all_sources.insert(s.clone());
395 }
396 }
397
398 for source in all_sources {
399 let src_path = if let Some(rel) = source.strip_prefix("content/") {
400 content_dir.join(rel)
401 } else if let Some(rel) = source.strip_prefix("templates/") {
402 template_dir.join(rel)
403 } else {
404 continue;
405 };
406 if !src_path.exists() {
407 continue;
408 }
409
410 let dst_path = content_out.join(&source);
411 if let Some(parent) = dst_path.parent() {
412 fs::create_dir_all(parent).map_err(|e| SsgError::Io {
413 path: parent.to_path_buf(),
414 source: e,
415 })?;
416 }
417 let _bytes_copied =
418 fs::copy(&src_path, &dst_path).map_err(|e| SsgError::Io {
419 path: dst_path.clone(),
420 source: e,
421 })?;
422 }
423
424 Ok(())
425}
426
427#[cfg(test)]
432mod tests {
433 use super::*;
434 use tempfile::tempdir;
435
436 #[test]
437 fn derive_url_index() {
438 assert_eq!(derive_url("index.md"), "/index.html");
439 }
440
441 #[test]
442 fn derive_url_post() {
443 assert_eq!(derive_url("posts/foo.md"), "/posts/foo/index.html");
444 }
445
446 #[test]
447 fn derive_url_already_index() {
448 assert_eq!(derive_url("about/index.md"), "/about/index.html");
449 }
450
451 #[test]
452 fn derive_url_nested() {
453 assert_eq!(derive_url("a/b/c.md"), "/a/b/c/index.html");
454 }
455
456 #[test]
457 fn derive_url_without_md_suffix_uses_input_verbatim() {
458 assert_eq!(derive_url("notes"), "/notes/index.html");
462 }
463
464 #[test]
465 fn extract_isr_cache_yaml() {
466 let text =
467 "---\ntitle: Foo\nisr:\n s_maxage: 600\n swr: 3600\n---\n# Body";
468 let c = extract_isr_cache(text).unwrap();
469 assert_eq!(c.s_maxage, 600);
470 assert_eq!(c.swr, 3600);
471 }
472
473 #[test]
474 fn extract_isr_cache_only_s_maxage() {
475 let text = "---\nisr:\n s_maxage: 30\n---\n";
476 let c = extract_isr_cache(text).unwrap();
477 assert_eq!(c.s_maxage, 30);
478 assert_eq!(c.swr, ssg_core::DEFAULT_SWR);
479 }
480
481 #[test]
482 fn extract_isr_cache_only_swr() {
483 let text = "---\nisr:\n swr: 7200\n---\n";
484 let c = extract_isr_cache(text).unwrap();
485 assert_eq!(c.s_maxage, ssg_core::DEFAULT_S_MAXAGE);
486 assert_eq!(c.swr, 7200);
487 }
488
489 #[test]
490 fn extract_isr_cache_none() {
491 let text = "---\ntitle: Foo\n---\n";
492 assert!(extract_isr_cache(text).is_none());
493 }
494
495 #[test]
496 fn extract_isr_cache_no_frontmatter() {
497 assert!(extract_isr_cache("# Hello").is_none());
498 }
499
500 #[test]
501 fn collect_md_files_recursive_and_sorted() {
502 let dir = tempdir().unwrap();
503 fs::write(dir.path().join("b.md"), "b").unwrap();
504 fs::create_dir_all(dir.path().join("sub")).unwrap();
505 fs::write(dir.path().join("sub/a.md"), "a").unwrap();
506 fs::write(dir.path().join("ignore.txt"), "no").unwrap();
507
508 let files = collect_md_files(dir.path()).unwrap();
509 assert_eq!(files.len(), 2);
510 assert!(files[0].ends_with("b.md"));
512 assert!(files[1].ends_with("sub/a.md"));
513 }
514
515 #[test]
516 fn collect_md_files_skips_hidden_dirs() {
517 let dir = tempdir().unwrap();
518 fs::create_dir_all(dir.path().join(".hidden")).unwrap();
519 fs::write(dir.path().join(".hidden/a.md"), "x").unwrap();
520 fs::write(dir.path().join("real.md"), "y").unwrap();
521
522 let files = collect_md_files(dir.path()).unwrap();
523 assert_eq!(files.len(), 1);
524 assert!(files[0].ends_with("real.md"));
525 }
526
527 #[test]
528 fn build_manifest_emits_entries() {
529 let dir = tempdir().unwrap();
530 let content_dir = dir.path().join("content");
531 let template_dir = dir.path().join("templates");
532 let site_dir = dir.path().join("public");
533
534 fs::create_dir_all(&content_dir).unwrap();
535 fs::create_dir_all(&template_dir).unwrap();
536 fs::create_dir_all(&site_dir).unwrap();
537
538 fs::write(content_dir.join("index.md"), "# Home").unwrap();
539 fs::write(
540 content_dir.join("post.md"),
541 "---\nisr:\n s_maxage: 30\n---\n# Post",
542 )
543 .unwrap();
544 fs::write(template_dir.join("index.html"), "<html/>").unwrap();
545 fs::write(template_dir.join("page.html"), "<page/>").unwrap();
546
547 let m = build_manifest(&content_dir, &template_dir, &site_dir).unwrap();
548 assert_eq!(m.len(), 2);
549 assert!(m.get("/index.html").is_some());
550 let post = m.get("/post/index.html").unwrap();
551 assert_eq!(post.cache.as_ref().unwrap().s_maxage, 30);
552 assert_eq!(post.sources[0], "content/post.md");
553 assert!(post.sources.iter().any(|s| s == "templates/index.html"));
555 assert!(post.sources.iter().any(|s| s == "templates/page.html"));
556 assert_eq!(post.hash.len(), 64);
557 }
558
559 #[test]
560 fn build_entry_for_markdown_falls_back_to_full_path_outside_content_dir() {
561 let dir = tempdir().unwrap();
566 let elsewhere = dir.path().join("elsewhere");
567 fs::create_dir_all(&elsewhere).unwrap();
568 let md_path = elsewhere.join("orphan.md");
569 fs::write(&md_path, "# Orphan").unwrap();
570
571 let content_dir = dir.path().join("content");
572 let template_dir = dir.path().join("templates");
573 let site_dir = dir.path().join("site");
574 fs::create_dir_all(&content_dir).unwrap();
575 fs::create_dir_all(&template_dir).unwrap();
576 fs::create_dir_all(&site_dir).unwrap();
577
578 let (url, entry) = build_entry_for_markdown(
579 &md_path,
580 &content_dir,
581 &template_dir,
582 &site_dir,
583 )
584 .unwrap()
585 .expect("entry is always produced");
586 assert!(
587 entry.sources[0].contains("orphan.md"),
588 "source should reference the full path: {:?}",
589 entry.sources
590 );
591 assert!(url.ends_with("/index.html"));
592 }
593
594 #[test]
595 fn write_manifest_creates_parent_dirs() {
596 let dir = tempdir().unwrap();
597 let m = Manifest::default();
598 write_manifest(&m, dir.path()).unwrap();
599 let p = dir.path().join(MANIFEST_RELATIVE_PATH);
600 assert!(p.exists());
601 let parsed: Manifest =
602 serde_json::from_str(&fs::read_to_string(&p).unwrap()).unwrap();
603 assert_eq!(parsed, m);
604 }
605
606 #[test]
607 fn plugin_after_compile_writes_manifest_and_copies_sources() {
608 let dir = tempdir().unwrap();
609 let content_dir = dir.path().join("content");
610 let template_dir = dir.path().join("templates");
611 let site_dir = dir.path().join("public");
612
613 fs::create_dir_all(&content_dir).unwrap();
614 fs::create_dir_all(&template_dir).unwrap();
615 fs::create_dir_all(&site_dir).unwrap();
616
617 fs::write(content_dir.join("a.md"), "# A").unwrap();
618 fs::write(template_dir.join("index.html"), "<x/>").unwrap();
619
620 let ctx = PluginContext {
621 content_dir: content_dir.clone(),
622 build_dir: site_dir.clone(),
623 site_dir: site_dir.clone(),
624 template_dir: template_dir.clone(),
625 config: None,
626 cache: None,
627 memory_budget: None,
628 html_files: None,
629 dep_graph: None,
630 dry_run: false,
631 };
632
633 IsrManifestPlugin.after_compile(&ctx).unwrap();
634
635 let manifest_path = site_dir.join(MANIFEST_RELATIVE_PATH);
636 assert!(manifest_path.exists());
637
638 let content_dst =
639 site_dir.join(CONTENT_RELATIVE_DIR).join("content/a.md");
640 assert!(content_dst.exists(), "raw markdown should be staged");
641
642 let template_dst = site_dir
643 .join(CONTENT_RELATIVE_DIR)
644 .join("templates/index.html");
645 assert!(template_dst.exists(), "template should be staged");
646 }
647
648 #[test]
649 fn plugin_after_compile_dry_run_writes_nothing() {
650 let dir = tempdir().unwrap();
651 let content_dir = dir.path().join("content");
652 let template_dir = dir.path().join("templates");
653 let site_dir = dir.path().join("public");
654 fs::create_dir_all(&content_dir).unwrap();
655 fs::create_dir_all(&template_dir).unwrap();
656 fs::create_dir_all(&site_dir).unwrap();
657 fs::write(content_dir.join("a.md"), "x").unwrap();
658
659 let ctx = PluginContext {
660 content_dir,
661 build_dir: site_dir.clone(),
662 site_dir: site_dir.clone(),
663 template_dir,
664 config: None,
665 cache: None,
666 memory_budget: None,
667 html_files: None,
668 dep_graph: None,
669 dry_run: true,
670 };
671
672 IsrManifestPlugin.after_compile(&ctx).unwrap();
673 assert!(!site_dir.join(MANIFEST_RELATIVE_PATH).exists());
674 }
675
676 #[test]
677 fn plugin_name() {
678 assert_eq!(IsrManifestPlugin.name(), "isr-manifest");
679 }
680
681 #[test]
682 fn plugin_default_constructs() {
683 let _p = <IsrManifestPlugin as Default>::default();
684 }
685
686 #[test]
687 fn plugin_after_compile_full_run_writes_manifest_and_copies_sources() {
688 let dir = tempdir().unwrap();
692 let content_dir = dir.path().join("content");
693 let template_dir = dir.path().join("templates");
694 let site_dir = dir.path().join("public");
695 fs::create_dir_all(&content_dir).unwrap();
696 fs::create_dir_all(&template_dir).unwrap();
697 fs::create_dir_all(&site_dir).unwrap();
698 fs::write(content_dir.join("hello.md"), "hello world").unwrap();
699 fs::write(template_dir.join("index.html"), "<html/>").unwrap();
700
701 let ctx = PluginContext {
702 content_dir: content_dir.clone(),
703 build_dir: site_dir.clone(),
704 site_dir: site_dir.clone(),
705 template_dir,
706 config: None,
707 cache: None,
708 memory_budget: None,
709 html_files: None,
710 dep_graph: None,
711 dry_run: false,
712 };
713
714 IsrManifestPlugin.after_compile(&ctx).unwrap();
715 assert!(site_dir.join(MANIFEST_RELATIVE_PATH).exists());
716 }
717
718 #[test]
719 fn collect_md_files_nonexistent_dir_returns_empty() {
720 let out = collect_md_files(Path::new("/nonexistent/xxx")).unwrap();
722 assert!(out.is_empty());
723 }
724
725 #[test]
726 fn collect_md_files_skips_hidden_dirs_v2() {
727 let dir = tempdir().unwrap();
731 fs::create_dir_all(dir.path().join(".hidden")).unwrap();
732 fs::write(dir.path().join(".hidden/secret.md"), "x").unwrap();
733 fs::write(dir.path().join("visible.md"), "x").unwrap();
734 let out = collect_md_files(dir.path()).unwrap();
735 assert_eq!(out.len(), 1);
736 assert_eq!(
737 out[0].file_name().unwrap(),
738 std::ffi::OsStr::new("visible.md")
739 );
740 }
741
742 #[test]
743 fn collect_md_files_walks_nested_v2() {
744 let dir = tempdir().unwrap();
745 let sub = dir.path().join("a/b/c");
746 fs::create_dir_all(&sub).unwrap();
747 fs::write(sub.join("deep.md"), "x").unwrap();
748 fs::write(dir.path().join("shallow.md"), "x").unwrap();
749 let out = collect_md_files(dir.path()).unwrap();
750 assert_eq!(out.len(), 2);
751 }
752
753 #[test]
754 fn collect_templates_nonexistent_dir_returns_empty() {
755 let out = collect_templates(Path::new("/nonexistent/yyy"));
757 assert!(out.is_empty());
758 }
759
760 #[test]
761 fn extract_isr_cache_yaml_both_keys() {
762 let text = "---\nisr:\n s_maxage: 600\n swr: 3600\n---\n";
764 let p = extract_isr_cache(text).unwrap();
765 assert_eq!(p.s_maxage, 600);
766 assert_eq!(p.swr, 3600);
767 }
768
769 #[test]
770 fn extract_isr_cache_yaml_dash_variants() {
771 let text =
773 "---\nisr:\n s-maxage: 42\n stale-while-revalidate: 99\n---\n";
774 let p = extract_isr_cache(text).unwrap();
775 assert_eq!(p.s_maxage, 42);
776 assert_eq!(p.swr, 99);
777 }
778
779 #[test]
780 fn extract_isr_cache_ignores_unknown_keys_in_isr_block() {
781 let text = "---\nisr:\n unknown_key: 5\n s_maxage: 7\n---\n";
783 let p = extract_isr_cache(text).unwrap();
784 assert_eq!(p.s_maxage, 7);
785 }
786
787 #[test]
788 fn extract_isr_cache_isr_block_exits_on_non_indented_line() {
789 let text = "---\nisr:\n s_maxage: 5\ntitle: Hi\nswr: 8\n---\n";
791 let p = extract_isr_cache(text).unwrap();
792 assert_eq!(p.s_maxage, 5);
794 assert_eq!(p.swr, ssg_core::DEFAULT_SWR);
795 }
796
797 #[test]
798 fn extract_isr_cache_no_frontmatter_returns_none() {
799 assert!(extract_isr_cache("just body text").is_none());
800 }
801
802 #[test]
803 fn extract_isr_cache_no_isr_block_returns_none() {
804 let text = "---\ntitle: Hi\n---\n";
805 assert!(extract_isr_cache(text).is_none());
806 }
807
808 #[test]
809 fn extract_frontmatter_block_toml_fences() {
810 let text = "+++\ntitle = \"X\"\n+++\nbody";
812 let body = extract_frontmatter_block(text).unwrap();
813 assert!(body.contains("title"));
814 }
815
816 #[test]
817 fn derive_url_index_md_maps_to_root_index_html() {
818 assert_eq!(derive_url("index.md"), "/index.html");
819 }
820
821 #[test]
822 fn derive_url_nested_index_maps_to_dir_slash_index_html() {
823 assert_eq!(derive_url("about/index.md"), "/about/index.html");
824 }
825
826 #[test]
827 fn derive_url_regular_md_maps_to_dir_slash_index_html() {
828 assert_eq!(derive_url("posts/hello.md"), "/posts/hello/index.html");
829 }
830
831 fn make_ctx(
833 content_dir: &Path,
834 template_dir: &Path,
835 site_dir: &Path,
836 ) -> PluginContext {
837 PluginContext {
838 content_dir: content_dir.to_path_buf(),
839 build_dir: site_dir.to_path_buf(),
840 site_dir: site_dir.to_path_buf(),
841 template_dir: template_dir.to_path_buf(),
842 config: None,
843 cache: None,
844 memory_budget: None,
845 html_files: None,
846 dep_graph: None,
847 dry_run: false,
848 }
849 }
850
851 #[cfg(unix)]
852 fn deny_access(p: &Path) {
853 use std::os::unix::fs::PermissionsExt;
854 fs::set_permissions(p, fs::Permissions::from_mode(0o000)).unwrap();
855 }
856
857 #[cfg(unix)]
858 fn restore_access(p: &Path) {
859 use std::os::unix::fs::PermissionsExt;
860 let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o755));
861 }
862
863 #[test]
864 #[cfg(unix)]
865 fn after_compile_propagates_unreadable_subdir_error() {
866 let dir = tempdir().unwrap();
870 let content_dir = dir.path().join("content");
871 let template_dir = dir.path().join("templates");
872 let site_dir = dir.path().join("public");
873 fs::create_dir_all(content_dir.join("locked")).unwrap();
874 fs::create_dir_all(&template_dir).unwrap();
875 fs::create_dir_all(&site_dir).unwrap();
876 deny_access(&content_dir.join("locked"));
877
878 let ctx = make_ctx(&content_dir, &template_dir, &site_dir);
879 let res = IsrManifestPlugin.after_compile(&ctx);
880
881 restore_access(&content_dir.join("locked"));
882 if let Err(e) = res {
884 assert!(!format!("{e}").is_empty());
885 }
886 }
887
888 #[test]
889 #[cfg(unix)]
890 fn build_manifest_propagates_unreadable_md_error() {
891 let dir = tempdir().unwrap();
894 let content_dir = dir.path().join("content");
895 let template_dir = dir.path().join("templates");
896 let site_dir = dir.path().join("public");
897 fs::create_dir_all(&content_dir).unwrap();
898 fs::create_dir_all(&template_dir).unwrap();
899 fs::create_dir_all(&site_dir).unwrap();
900 let md = content_dir.join("locked.md");
901 fs::write(&md, "# locked").unwrap();
902 deny_access(&md);
903
904 let res = build_manifest(&content_dir, &template_dir, &site_dir);
905
906 restore_access(&md);
907 if let Err(e) = res {
908 assert!(!format!("{e}").is_empty());
909 }
910 }
911
912 #[test]
913 #[cfg(unix)]
914 fn build_manifest_propagates_unreadable_template_error() {
915 let dir = tempdir().unwrap();
917 let content_dir = dir.path().join("content");
918 let template_dir = dir.path().join("templates");
919 let site_dir = dir.path().join("public");
920 fs::create_dir_all(&content_dir).unwrap();
921 fs::create_dir_all(&template_dir).unwrap();
922 fs::create_dir_all(&site_dir).unwrap();
923 fs::write(content_dir.join("a.md"), "# a").unwrap();
924 let tpl = template_dir.join("index.html");
925 fs::write(&tpl, "<html/>").unwrap();
926 deny_access(&tpl);
927
928 let res = build_manifest(&content_dir, &template_dir, &site_dir);
929
930 restore_access(&tpl);
931 if let Err(e) = res {
932 assert!(!format!("{e}").is_empty());
933 }
934 }
935
936 #[test]
937 fn after_compile_fails_when_ssg_dir_is_a_file() {
938 let dir = tempdir().unwrap();
942 let content_dir = dir.path().join("content");
943 let template_dir = dir.path().join("templates");
944 let site_dir = dir.path().join("public");
945 fs::create_dir_all(&content_dir).unwrap();
946 fs::create_dir_all(&template_dir).unwrap();
947 fs::create_dir_all(&site_dir).unwrap();
948 fs::write(site_dir.join(".ssg"), "not a dir").unwrap();
949
950 let ctx = make_ctx(&content_dir, &template_dir, &site_dir);
951 let err = IsrManifestPlugin.after_compile(&ctx).unwrap_err();
952 assert!(!format!("{err}").is_empty());
953 }
954
955 #[test]
956 fn write_manifest_fails_when_manifest_path_is_a_dir() {
957 let dir = tempdir().unwrap();
960 fs::create_dir_all(dir.path().join(MANIFEST_RELATIVE_PATH)).unwrap();
961 let err = write_manifest(&Manifest::default(), dir.path()).unwrap_err();
962 assert!(!format!("{err}").is_empty());
963 }
964
965 #[test]
966 fn after_compile_fails_when_content_out_is_a_file() {
967 let dir = tempdir().unwrap();
970 let content_dir = dir.path().join("content");
971 let template_dir = dir.path().join("templates");
972 let site_dir = dir.path().join("public");
973 fs::create_dir_all(&content_dir).unwrap();
974 fs::create_dir_all(&template_dir).unwrap();
975 fs::create_dir_all(site_dir.join(".ssg")).unwrap();
976 fs::write(site_dir.join(CONTENT_RELATIVE_DIR), "not a dir").unwrap();
977
978 let ctx = make_ctx(&content_dir, &template_dir, &site_dir);
979 let err = IsrManifestPlugin.after_compile(&ctx).unwrap_err();
980 assert!(!format!("{err}").is_empty());
981 }
982
983 fn manifest_with_sources(sources: Vec<String>) -> Manifest {
985 let byte_refs: Vec<&[u8]> = vec![b"x"; sources.len()];
986 let entry = build_entry(sources, &byte_refs, None);
987 let mut m = Manifest::new(build_stamp());
988 m.insert("/index.html".to_string(), entry);
989 m
990 }
991
992 #[test]
993 fn copy_sources_skips_unknown_prefix_and_missing_files() {
994 let dir = tempdir().unwrap();
995 let content_dir = dir.path().join("content");
996 let template_dir = dir.path().join("templates");
997 let site_dir = dir.path().join("public");
998 fs::create_dir_all(&content_dir).unwrap();
999 fs::create_dir_all(&template_dir).unwrap();
1000 fs::create_dir_all(&site_dir).unwrap();
1001
1002 let m = manifest_with_sources(vec![
1003 "bogus/thing".to_string(),
1004 "content/missing.md".to_string(),
1005 "templates/missing.html".to_string(),
1006 ]);
1007 copy_sources(&content_dir, &template_dir, &site_dir, &m).unwrap();
1008 let staged = site_dir.join(CONTENT_RELATIVE_DIR);
1010 assert_eq!(fs::read_dir(staged).unwrap().count(), 0);
1011 }
1012
1013 #[test]
1014 fn copy_sources_fails_when_dst_parent_is_a_file() {
1015 let dir = tempdir().unwrap();
1018 let content_dir = dir.path().join("content");
1019 let template_dir = dir.path().join("templates");
1020 let site_dir = dir.path().join("public");
1021 fs::create_dir_all(&content_dir).unwrap();
1022 fs::create_dir_all(&template_dir).unwrap();
1023 fs::write(content_dir.join("a.md"), "# a").unwrap();
1024 let content_out = site_dir.join(CONTENT_RELATIVE_DIR);
1025 fs::create_dir_all(&content_out).unwrap();
1026 fs::write(content_out.join("content"), "not a dir").unwrap();
1027
1028 let m = manifest_with_sources(vec!["content/a.md".to_string()]);
1029 let err = copy_sources(&content_dir, &template_dir, &site_dir, &m)
1030 .unwrap_err();
1031 assert!(!format!("{err}").is_empty());
1032 }
1033
1034 #[test]
1035 fn copy_sources_fails_when_dst_path_is_a_dir() {
1036 let dir = tempdir().unwrap();
1039 let content_dir = dir.path().join("content");
1040 let template_dir = dir.path().join("templates");
1041 let site_dir = dir.path().join("public");
1042 fs::create_dir_all(&content_dir).unwrap();
1043 fs::create_dir_all(&template_dir).unwrap();
1044 fs::write(content_dir.join("a.md"), "# a").unwrap();
1045 let dst = site_dir.join(CONTENT_RELATIVE_DIR).join("content/a.md");
1046 fs::create_dir_all(&dst).unwrap();
1047
1048 let m = manifest_with_sources(vec!["content/a.md".to_string()]);
1049 let err = copy_sources(&content_dir, &template_dir, &site_dir, &m)
1050 .unwrap_err();
1051 assert!(!format!("{err}").is_empty());
1052 }
1053
1054 #[test]
1055 fn extract_isr_cache_ignores_indented_line_without_colon() {
1056 let text = "---\nisr:\n nocolonhere\n s_maxage: 3\n---\n";
1057 let p = extract_isr_cache(text).unwrap();
1058 assert_eq!(p.s_maxage, 3);
1059 }
1060
1061 #[test]
1062 fn extract_isr_cache_blank_line_keeps_isr_block_open() {
1063 let text = "---\nisr:\n\n s_maxage: 4\n---\n";
1066 let p = extract_isr_cache(text).unwrap();
1067 assert_eq!(p.s_maxage, 4);
1068 }
1069
1070 #[test]
1071 fn extract_frontmatter_block_unclosed_yaml_returns_none() {
1072 assert!(extract_frontmatter_block("---\nno closing fence").is_none());
1073 }
1074
1075 #[test]
1076 fn extract_frontmatter_block_unclosed_toml_returns_none() {
1077 assert!(extract_frontmatter_block("+++\nno closing fence").is_none());
1078 }
1079}