systemprompt_cli/commands/web/sitemap/
generate.rs1use anyhow::{Context, Result};
7use chrono::Utc;
8use clap::Args;
9use std::fs;
10use std::path::PathBuf;
11
12use crate::CliConfig;
13use crate::shared::CommandOutput;
14use systemprompt_config::ProfileBootstrap;
15use systemprompt_generator::{SitemapUrl, build_sitemap_xml};
16use systemprompt_logging::CliService;
17use systemprompt_models::Profile;
18use systemprompt_models::content_config::ContentConfigRaw;
19
20use super::super::types::SitemapGenerateOutput;
21
22#[derive(Debug, Args)]
23pub struct GenerateArgs {
24 #[arg(long, help = "Output path (default: {web_path}/dist/sitemap.xml)")]
25 pub output: Option<PathBuf>,
26
27 #[arg(long, help = "Base URL for sitemap (e.g., https://example.com)")]
28 pub base_url: Option<String>,
29
30 #[arg(long, help = "Include dynamic content from database")]
31 pub include_dynamic: bool,
32}
33
34pub(super) fn execute(args: &GenerateArgs, _config: &CliConfig) -> Result<CommandOutput> {
35 let profile = ProfileBootstrap::get().context("Failed to get profile")?;
36 execute_with_profile(args, profile)
37}
38
39pub fn execute_with_profile(args: &GenerateArgs, profile: &Profile) -> Result<CommandOutput> {
40 let content_config = load_content_config(profile)?;
41 let base_url = resolve_base_url(args, profile);
42 let output_path = resolve_output_path(args, profile)?;
43
44 CliService::info("Generating sitemap...");
45
46 let urls = collect_urls(&content_config, &base_url, args.include_dynamic);
47 let xml = build_sitemap_xml(&urls);
48
49 fs::write(&output_path, &xml)
50 .with_context(|| format!("Failed to write sitemap to {}", output_path.display()))?;
51
52 let message = format!(
53 "Sitemap generated with {} URLs at {}",
54 urls.len(),
55 output_path.display()
56 );
57 CliService::success(&message);
58
59 let output = SitemapGenerateOutput {
60 output_path: output_path.to_string_lossy().to_string(),
61 routes_count: urls.len(),
62 message,
63 };
64
65 Ok(CommandOutput::card_value("Sitemap Generated", &output))
66}
67
68fn load_content_config(profile: &Profile) -> Result<ContentConfigRaw> {
69 let content_config_path = profile.paths.content_config();
70
71 let content = fs::read_to_string(&content_config_path)
72 .with_context(|| format!("Failed to read content config at {}", content_config_path))?;
73
74 serde_yaml::from_str(&content)
75 .with_context(|| format!("Failed to parse content config at {}", content_config_path))
76}
77
78fn resolve_base_url(args: &GenerateArgs, profile: &Profile) -> String {
79 args.base_url.clone().unwrap_or_else(|| {
80 let metadata_path = profile.paths.web_metadata();
81 fs::read_to_string(&metadata_path)
82 .ok()
83 .and_then(|content| extract_base_url(&content))
84 .unwrap_or_else(|| "https://example.com".to_owned())
85 })
86}
87
88fn resolve_output_path(args: &GenerateArgs, profile: &Profile) -> Result<PathBuf> {
89 let web_path = profile.paths.web_path_resolved();
90 let output_path = args
91 .output
92 .clone()
93 .unwrap_or_else(|| PathBuf::from(&web_path).join("dist").join("sitemap.xml"));
94
95 if let Some(parent) = output_path.parent() {
96 fs::create_dir_all(parent)
97 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
98 }
99
100 Ok(output_path)
101}
102
103fn collect_urls(
104 content_config: &ContentConfigRaw,
105 base_url: &str,
106 include_dynamic: bool,
107) -> Vec<SitemapUrl> {
108 let mut urls: Vec<SitemapUrl> = Vec::new();
109 let today = Utc::now().format("%Y-%m-%d").to_string();
110
111 for (name, source) in &content_config.content_sources {
112 if !source.enabled {
113 continue;
114 }
115
116 let Some(sitemap) = &source.sitemap else {
117 continue;
118 };
119 if !sitemap.enabled {
120 continue;
121 }
122
123 if let Some(parent) = &sitemap.parent_route
124 && parent.enabled
125 {
126 urls.push(SitemapUrl {
127 loc: format!("{}{}", base_url, parent.url),
128 lastmod: today.clone(),
129 changefreq: parent.changefreq.clone(),
130 priority: parent.priority,
131 alternates: Vec::new(),
132 });
133 }
134
135 if !include_dynamic && sitemap.url_pattern.contains("{slug}") {
136 CliService::warning(&format!(
137 "Skipping dynamic route '{}' for source '{}'. Use --include-dynamic to fetch \
138 from database.",
139 sitemap.url_pattern, name
140 ));
141 } else if !sitemap.url_pattern.contains("{slug}") {
142 urls.push(SitemapUrl {
143 loc: format!("{}{}", base_url, sitemap.url_pattern),
144 lastmod: today.clone(),
145 changefreq: sitemap.changefreq.clone(),
146 priority: sitemap.priority,
147 alternates: Vec::new(),
148 });
149 }
150 }
151
152 urls.sort_by(|a, b| {
153 b.priority
154 .partial_cmp(&a.priority)
155 .unwrap_or(std::cmp::Ordering::Equal)
156 });
157
158 urls
159}
160
161fn extract_base_url(metadata_content: &str) -> Option<String> {
162 for line in metadata_content.lines() {
163 let line = line.trim();
164 if line.starts_with("baseUrl:") {
165 let url = line.trim_start_matches("baseUrl:").trim();
166 let url = url.trim_matches('"').trim_matches('\'');
167 return Some(url.to_owned());
168 }
169 }
170 None
171}