1use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, Result};
9use staticdatagen::compile;
10
11use crate::cmd::SsgConfig;
12use crate::{
13 accessibility, ai, assets, content, csp, deploy, drafts, highlight, i18n,
14 islands, livereload, pagination, plugin, plugins as plugins_mod,
15 postprocess, search, seo, shortcodes, streaming, taxonomy, walk,
16};
17
18#[derive(Debug, Clone, serde::Serialize)]
24#[allow(dead_code)]
25pub struct BuildError {
26 pub file: Option<String>,
28 pub line: Option<usize>,
30 pub message: String,
32}
33
34impl BuildError {
35 #[must_use]
38 #[allow(dead_code)]
39 pub fn from_anyhow(err: &anyhow::Error) -> Self {
40 let message = format!("{err:#}");
41 let file = extract_file_from_error(&message);
42 Self {
43 file,
44 line: None,
45 message,
46 }
47 }
48
49 #[must_use]
51 #[allow(dead_code)]
52 pub fn to_ws_message(&self) -> String {
53 serde_json::json!({
54 "type": "error",
55 "file": self.file,
56 "line": self.line,
57 "message": self.message,
58 })
59 .to_string()
60 }
61}
62
63#[must_use]
65#[allow(dead_code)]
66pub fn clear_error_message() -> String {
67 r#"{"type":"clear-error"}"#.to_string()
68}
69
70#[allow(dead_code)]
73fn extract_file_from_error(msg: &str) -> Option<String> {
74 for word in msg.split_whitespace() {
75 let trimmed = word.trim_matches(|c: char| {
76 !c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
77 });
78 if trimmed.contains('/')
79 && (trimmed.ends_with(".md")
80 || trimmed.ends_with(".html")
81 || trimmed.ends_with(".toml")
82 || trimmed.ends_with(".yml")
83 || trimmed.ends_with(".yaml"))
84 {
85 return Some(trimmed.to_string());
86 }
87 }
88 None
89}
90
91#[derive(Debug, Clone)]
102#[allow(clippy::struct_excessive_bools)]
103pub struct RunOptions {
104 pub quiet: bool,
106 pub include_drafts: bool,
108 pub deploy_target: Option<String>,
110 pub validate_only: bool,
112 pub jobs: Option<usize>,
115 pub max_memory_mb: Option<usize>,
118 #[allow(dead_code)]
120 pub ai_fix: bool,
121 #[allow(dead_code)]
123 pub ai_fix_dry_run: bool,
124}
125
126impl RunOptions {
127 pub fn from_matches(matches: &clap::ArgMatches) -> Self {
129 Self {
130 quiet: matches.get_flag("quiet"),
131 include_drafts: matches.get_flag("drafts"),
132 deploy_target: matches.get_one::<String>("deploy").cloned(),
133 validate_only: matches.get_flag("validate"),
134 jobs: matches.get_one::<usize>("jobs").copied(),
135 max_memory_mb: matches.get_one::<usize>("max-memory").copied(),
136 ai_fix: matches.get_flag("ai-fix"),
137 ai_fix_dry_run: matches.get_flag("ai-fix-dry-run"),
138 }
139 }
140}
141
142pub fn resolve_build_and_site_dirs(config: &SsgConfig) -> (PathBuf, PathBuf) {
148 let site_dir = config
149 .serve_dir
150 .clone()
151 .unwrap_or_else(|| config.output_dir.clone());
152
153 let build_dir = if site_dir == config.output_dir {
154 config.output_dir.with_extension("build-tmp")
155 } else {
156 config.output_dir.clone()
157 };
158
159 (build_dir, site_dir)
160}
161
162pub fn build_pipeline(
167 config: &SsgConfig,
168 opts: &RunOptions,
169) -> (
170 plugin::PluginManager,
171 plugin::PluginContext,
172 PathBuf,
173 PathBuf,
174) {
175 let (build_dir, site_dir) = resolve_build_and_site_dirs(config);
176
177 let mut ctx = plugin::PluginContext::with_config(
178 &config.content_dir,
179 &build_dir,
180 &site_dir,
181 &config.template_dir,
182 config.clone(),
183 );
184
185 if let Some(mb) = opts.max_memory_mb {
187 ctx.memory_budget = Some(streaming::MemoryBudget::from_mb(mb));
188 }
189
190 let mut plugins = plugin::PluginManager::new();
191 register_default_plugins(
192 &mut plugins,
193 config,
194 opts.include_drafts,
195 opts.deploy_target.as_deref(),
196 );
197
198 (plugins, ctx, build_dir, site_dir)
199}
200
201#[cfg_attr(
207 feature = "otel",
208 tracing::instrument(skip(plugins, ctx), fields(
209 content_dir = %content_dir.display(),
210 site_dir = %site_dir.display(),
211 quiet,
212 ))
213)]
214pub fn execute_build_pipeline(
215 plugins: &plugin::PluginManager,
216 ctx: &plugin::PluginContext,
217 build_dir: &Path,
218 content_dir: &Path,
219 site_dir: &Path,
220 template_dir: &Path,
221 quiet: bool,
222) -> Result<()> {
223 let start = std::time::Instant::now();
224
225 let cache = plugin::PluginCache::load(site_dir);
227 let dep_graph = crate::depgraph::DepGraph::load(site_dir);
228 let mut ctx = ctx.clone();
229 ctx.cache = Some(cache);
230 ctx.dep_graph = Some(dep_graph);
231
232 plugins.run_before_compile(&ctx)?;
233
234 let budget = ctx
237 .memory_budget
238 .unwrap_or_else(streaming::MemoryBudget::default_budget);
239 let explicitly_set = ctx.memory_budget.is_some();
240
241 if streaming::should_stream(content_dir, &budget, explicitly_set) {
242 let batches = streaming::batched_content_files(content_dir, &budget)?;
243 for (i, batch) in batches.iter().enumerate() {
244 streaming::compile_batch(
245 batch,
246 content_dir,
247 build_dir,
248 site_dir,
249 template_dir,
250 i,
251 )?;
252 }
253 } else {
254 compile_site(build_dir, content_dir, site_dir, template_dir)?;
255 }
256
257 ctx.cache_html_files();
260
261 plugins.run_after_compile(&ctx)?;
262
263 plugins.run_fused_transforms(&ctx)?;
266
267 if let Some(ref mut cache) = ctx.cache {
269 if let Ok(files) = walk::walk_files(site_dir, "html") {
270 for file in &files {
271 cache.update(file);
272 }
273 }
274 if let Err(e) = cache.save(site_dir) {
275 log::warn!("Failed to save plugin cache: {e}");
276 }
277 }
278
279 if let Some(ref dg) = ctx.dep_graph {
281 if let Err(e) = dg.save(site_dir) {
282 log::warn!("Failed to save dependency graph: {e}");
283 }
284 }
285
286 let elapsed = start.elapsed();
287 if !quiet {
288 println!(
289 "Site built in {:.2}s ({} plugin(s))",
290 elapsed.as_secs_f64(),
291 plugins.len()
292 );
293 }
294 Ok(())
295}
296
297pub fn compile_site(
299 build_dir: &Path,
300 content_dir: &Path,
301 site_dir: &Path,
302 template_dir: &Path,
303) -> Result<()> {
304 compile(build_dir, content_dir, site_dir, template_dir).map_err(|e| {
305 eprintln!(" Error compiling site: {e:?}");
306 anyhow!("Failed to compile site: {e:?}")
307 })
308}
309
310pub fn register_default_plugins(
318 plugins: &mut plugin::PluginManager,
319 config: &SsgConfig,
320 include_drafts: bool,
321 deploy_target: Option<&str>,
322) {
323 let base_url = config.base_url.clone();
324
325 plugins.register(content::ContentValidationPlugin);
327 plugins.register(drafts::DraftPlugin::new(include_drafts));
328 plugins.register(shortcodes::ShortcodePlugin);
329
330 #[cfg(feature = "templates")]
332 plugins.register(
333 crate::template_plugin::TemplatePlugin::from_template_dir(
334 &config.template_dir,
335 ),
336 );
337
338 plugins.register(postprocess::SitemapFixPlugin);
341 plugins.register(postprocess::NewsSitemapFixPlugin);
342 plugins.register(postprocess::RssAggregatePlugin);
343 plugins.register(postprocess::AtomFeedPlugin);
344 plugins.register(postprocess::ManifestFixPlugin);
345 plugins.register(postprocess::HtmlFixPlugin);
346
347 plugins.register(highlight::HighlightPlugin::default());
349
350 plugins.register(seo::SeoPlugin);
352 plugins
353 .register(seo::JsonLdPlugin::from_site(&base_url, &config.site_name));
354 plugins.register(seo::CanonicalPlugin::new(base_url.clone()));
355 plugins.register(seo::RobotsPlugin::new(base_url));
356
357 plugins.register(ai::AiPlugin);
359
360 plugins.register(taxonomy::TaxonomyPlugin);
362 plugins.register(pagination::PaginationPlugin::default());
363
364 plugins.register(search::SearchPlugin);
366
367 plugins.register(accessibility::AccessibilityPlugin);
369
370 #[cfg(feature = "image-optimization")]
372 plugins.register(crate::image_plugin::ImageOptimizationPlugin::default());
373
374 if let Some(ref i18n_cfg) = config.i18n {
376 if i18n_cfg.locales.len() > 1 {
377 plugins.register(i18n::I18nPlugin::new(i18n_cfg.clone()));
378 }
379 }
380
381 plugins.register(islands::IslandPlugin);
383
384 plugins.register(csp::CspPlugin);
386
387 plugins.register(crate::sbom::SbomPlugin);
391
392 plugins.register(assets::FingerprintPlugin);
394
395 plugins.register(plugins_mod::MinifyPlugin);
397
398 if let Some(target) = deploy_target {
400 let dt = match target {
401 "netlify" => Some(deploy::DeployTarget::Netlify),
402 "vercel" => Some(deploy::DeployTarget::Vercel),
403 "cloudflare" => Some(deploy::DeployTarget::CloudflarePages),
404 "github" => Some(deploy::DeployTarget::GithubPages),
405 _ => {
406 log::warn!("Unknown deploy target: {target}");
407 None
408 }
409 };
410 if let Some(dt) = dt {
411 plugins.register(deploy::DeployPlugin::new(dt));
412 }
413 }
414
415 plugins.register(livereload::LiveReloadPlugin::default());
417}
418
419#[cfg(test)]
420#[allow(clippy::unwrap_used, clippy::expect_used)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_build_error_serialization() {
426 let err = BuildError {
427 file: Some("content/post.md".to_string()),
428 line: Some(42),
429 message: "unexpected token".to_string(),
430 };
431 let json = err.to_ws_message();
432 let parsed: serde_json::Value =
433 serde_json::from_str(&json).expect("valid JSON");
434 assert_eq!(parsed["type"], "error");
435 assert_eq!(parsed["file"], "content/post.md");
436 assert_eq!(parsed["line"], 42);
437 assert_eq!(parsed["message"], "unexpected token");
438 }
439
440 #[test]
441 fn test_clear_error_message() {
442 let msg = clear_error_message();
443 let parsed: serde_json::Value =
444 serde_json::from_str(&msg).expect("valid JSON");
445 assert_eq!(parsed["type"], "clear-error");
446 }
447
448 #[test]
449 fn test_extract_file_from_error_md() {
450 let msg = "cannot read content/posts/hello.md: permission denied";
451 assert_eq!(
452 extract_file_from_error(msg),
453 Some("content/posts/hello.md".to_string())
454 );
455 }
456
457 #[test]
458 fn test_extract_file_from_error_html() {
459 let msg = "template error in templates/base.html";
460 assert_eq!(
461 extract_file_from_error(msg),
462 Some("templates/base.html".to_string())
463 );
464 }
465
466 #[test]
467 fn test_extract_file_from_error_toml() {
468 let msg = "parse error in config/site.toml at line 5";
469 assert_eq!(
470 extract_file_from_error(msg),
471 Some("config/site.toml".to_string())
472 );
473 }
474
475 #[test]
476 fn test_extract_file_from_error_none() {
477 let msg = "something went wrong with no file path";
478 assert_eq!(extract_file_from_error(msg), None);
479 }
480
481 #[test]
482 fn test_build_error_from_anyhow() {
483 let err = anyhow::anyhow!("cannot write output/index.html: disk full");
484 let be = BuildError::from_anyhow(&err);
485 assert_eq!(be.file, Some("output/index.html".to_string()));
486 assert!(be.line.is_none());
487 assert!(be.message.contains("disk full"));
488 }
489
490 #[test]
495 fn test_build_error_no_file_no_line() {
496 let err = BuildError {
497 file: None,
498 line: None,
499 message: "something broke".to_string(),
500 };
501 let json = err.to_ws_message();
502 let parsed: serde_json::Value =
503 serde_json::from_str(&json).expect("valid JSON");
504 assert_eq!(parsed["type"], "error");
505 assert!(parsed["file"].is_null());
506 assert!(parsed["line"].is_null());
507 assert_eq!(parsed["message"], "something broke");
508 }
509
510 #[test]
511 fn test_build_error_clone() {
512 let err = BuildError {
513 file: Some("a/b.md".to_string()),
514 line: Some(10),
515 message: "oops".to_string(),
516 };
517 let cloned = err.clone();
518 assert_eq!(cloned.file, err.file);
519 assert_eq!(cloned.line, err.line);
520 assert_eq!(cloned.message, err.message);
521 }
522
523 #[test]
524 fn test_build_error_debug() {
525 let err = BuildError {
526 file: None,
527 line: None,
528 message: "debug test".to_string(),
529 };
530 let debug = format!("{err:?}");
531 assert!(debug.contains("BuildError"));
532 assert!(debug.contains("debug test"));
533 }
534
535 #[test]
536 fn test_build_error_from_anyhow_no_file() {
537 let err = anyhow::anyhow!("generic error without any file path");
538 let be = BuildError::from_anyhow(&err);
539 assert!(be.file.is_none());
540 assert!(be.message.contains("generic error"));
541 }
542
543 #[test]
544 fn test_build_error_from_anyhow_yml_extension() {
545 let err = anyhow::anyhow!("parse error in config/site.yml");
546 let be = BuildError::from_anyhow(&err);
547 assert_eq!(be.file, Some("config/site.yml".to_string()));
548 }
549
550 #[test]
551 fn test_build_error_from_anyhow_yaml_extension() {
552 let err = anyhow::anyhow!("error in data/settings.yaml at line 3");
553 let be = BuildError::from_anyhow(&err);
554 assert_eq!(be.file, Some("data/settings.yaml".to_string()));
555 }
556
557 #[test]
562 fn test_extract_file_with_punctuation_around_path() {
563 let msg = "error: 'templates/base.html' not found";
564 let result = extract_file_from_error(msg);
565 assert_eq!(result, Some("templates/base.html".to_string()));
566 }
567
568 #[test]
569 fn test_extract_file_no_slash_in_word() {
570 let msg = "file not found: base.html";
571 let result = extract_file_from_error(msg);
572 assert!(result.is_none(), "no slash means no file path extraction");
573 }
574
575 #[test]
576 fn test_extract_file_multiple_paths_returns_first() {
577 let msg = "failed to read src/a.md and src/b.html";
578 let result = extract_file_from_error(msg);
579 assert_eq!(result, Some("src/a.md".to_string()));
580 }
581
582 #[test]
583 fn test_extract_file_toml_with_trailing_colon() {
584 let msg = "invalid key in config/site.toml: 'foo'";
585 let result = extract_file_from_error(msg);
586 assert_eq!(result, Some("config/site.toml".to_string()));
587 }
588
589 #[test]
594 fn test_clear_error_message_is_valid_json() {
595 let msg = clear_error_message();
596 let parsed: serde_json::Value =
597 serde_json::from_str(&msg).expect("valid JSON");
598 assert_eq!(parsed["type"], "clear-error");
599 assert_eq!(parsed.as_object().unwrap().len(), 1);
601 }
602
603 #[test]
608 fn test_resolve_dirs_no_serve_dir() {
609 use crate::cmd::SsgConfig;
610 use std::path::PathBuf;
611 let mut config = SsgConfig::default();
612 config.output_dir = PathBuf::from("out");
613 config.serve_dir = None;
614
615 let (build, site) = resolve_build_and_site_dirs(&config);
616 assert_eq!(site, PathBuf::from("out"));
617 assert_ne!(build, site);
619 }
620
621 #[test]
622 fn test_resolve_dirs_serve_differs_from_output() {
623 use crate::cmd::SsgConfig;
624 use std::path::PathBuf;
625 let mut config = SsgConfig::default();
626 config.output_dir = PathBuf::from("build");
627 config.serve_dir = Some(PathBuf::from("public"));
628
629 let (build, site) = resolve_build_and_site_dirs(&config);
630 assert_eq!(build, PathBuf::from("build"));
631 assert_eq!(site, PathBuf::from("public"));
632 }
633
634 #[test]
635 fn test_resolve_dirs_serve_equals_output() {
636 use crate::cmd::SsgConfig;
637 use std::path::PathBuf;
638 let mut config = SsgConfig::default();
639 config.output_dir = PathBuf::from("dist");
640 config.serve_dir = Some(PathBuf::from("dist"));
641
642 let (build, site) = resolve_build_and_site_dirs(&config);
643 assert_eq!(site, PathBuf::from("dist"));
644 assert_ne!(build, site);
645 assert!(build.to_string_lossy().contains("build-tmp"));
646 }
647
648 #[test]
653 fn test_run_options_defaults() {
654 use crate::cmd::Cli;
655 let cli = Cli::build();
656 let matches = cli.try_get_matches_from(vec!["ssg"]).unwrap();
657 let opts = RunOptions::from_matches(&matches);
658
659 assert!(!opts.quiet);
660 assert!(!opts.include_drafts);
661 assert!(opts.deploy_target.is_none());
662 assert!(!opts.validate_only);
663 assert!(opts.jobs.is_none());
664 assert!(opts.max_memory_mb.is_none());
665 assert!(!opts.ai_fix);
666 assert!(!opts.ai_fix_dry_run);
667 }
668
669 #[test]
670 fn test_run_options_ai_fix_flags() {
671 use crate::cmd::Cli;
672 let cli = Cli::build();
673 let matches = cli
674 .try_get_matches_from(vec!["ssg", "--ai-fix", "--ai-fix-dry-run"])
675 .unwrap();
676 let opts = RunOptions::from_matches(&matches);
677 assert!(opts.ai_fix);
678 assert!(opts.ai_fix_dry_run);
679 }
680
681 #[test]
682 fn test_run_options_debug() {
683 use crate::cmd::Cli;
684 let cli = Cli::build();
685 let matches = cli.try_get_matches_from(vec!["ssg"]).unwrap();
686 let opts = RunOptions::from_matches(&matches);
687 let debug = format!("{opts:?}");
688 assert!(debug.contains("RunOptions"));
689 assert!(debug.contains("quiet"));
690 }
691
692 #[test]
693 fn test_run_options_clone() {
694 use crate::cmd::Cli;
695 let cli = Cli::build();
696 let matches = cli
697 .try_get_matches_from(vec!["ssg", "--quiet", "--jobs", "2"])
698 .unwrap();
699 let opts = RunOptions::from_matches(&matches);
700 let cloned = opts.clone();
701 assert_eq!(cloned.quiet, opts.quiet);
702 assert_eq!(cloned.jobs, opts.jobs);
703 }
704
705 #[test]
710 fn test_register_default_plugins_minimum_count() {
711 use crate::cmd::SsgConfig;
712 use crate::plugin::PluginManager;
713
714 let config = SsgConfig::default();
715 let mut pm = PluginManager::new();
716 register_default_plugins(&mut pm, &config, false, None);
717
718 assert!(
720 pm.len() >= 15,
721 "expected at least 15 default plugins, got {}",
722 pm.len()
723 );
724 }
725
726 #[test]
727 fn test_register_default_plugins_includes_key_plugins() {
728 use crate::cmd::SsgConfig;
729 use crate::plugin::PluginManager;
730
731 let config = SsgConfig::default();
732 let mut pm = PluginManager::new();
733 register_default_plugins(&mut pm, &config, false, None);
734
735 let names = pm.names();
736 assert!(names.contains(&"content-validation"));
737 assert!(names.contains(&"drafts"));
738 assert!(names.contains(&"shortcodes"));
739 assert!(names.contains(&"seo"));
740 assert!(names.contains(&"search"));
741 assert!(names.contains(&"minify"));
742 assert!(names.contains(&"livereload"));
743 }
744
745 #[test]
746 fn test_register_default_plugins_with_deploy_adds_deploy_plugin() {
747 use crate::cmd::SsgConfig;
748 use crate::plugin::PluginManager;
749
750 let config = SsgConfig::default();
751 let mut pm_without = PluginManager::new();
752 register_default_plugins(&mut pm_without, &config, false, None);
753 let count_without = pm_without.len();
754
755 let mut pm_with = PluginManager::new();
756 register_default_plugins(&mut pm_with, &config, false, Some("netlify"));
757
758 assert_eq!(pm_with.len(), count_without + 1);
759 assert!(pm_with.names().contains(&"deploy"));
760 }
761
762 #[test]
763 fn test_register_default_plugins_unknown_deploy_skipped() {
764 use crate::cmd::SsgConfig;
765 use crate::plugin::PluginManager;
766
767 let config = SsgConfig::default();
768 let mut pm = PluginManager::new();
769 register_default_plugins(
770 &mut pm,
771 &config,
772 false,
773 Some("nonexistent-platform"),
774 );
775
776 assert!(
777 !pm.names().contains(&"deploy"),
778 "unknown deploy target should not register a deploy plugin"
779 );
780 }
781
782 #[test]
787 fn test_build_pipeline_returns_valid_dirs() {
788 use crate::cmd::SsgConfig;
789
790 let temp = tempfile::tempdir().unwrap();
791 let mut config = SsgConfig::default();
792 config.content_dir = temp.path().join("content");
793 config.output_dir = temp.path().join("public");
794 config.template_dir = temp.path().join("templates");
795
796 let opts = RunOptions {
797 quiet: true,
798 include_drafts: false,
799 deploy_target: None,
800 validate_only: false,
801 jobs: None,
802 max_memory_mb: None,
803 ai_fix: false,
804 ai_fix_dry_run: false,
805 };
806
807 let (plugins, ctx, build_dir, site_dir) =
808 build_pipeline(&config, &opts);
809
810 assert!(!plugins.is_empty());
811 assert_ne!(build_dir, site_dir);
812 assert_eq!(ctx.content_dir, temp.path().join("content"));
813 }
814}