Skip to main content

systemprompt_cli/commands/web/sitemap/
generate.rs

1//! `web sitemap generate` command collecting URLs into sitemap.xml.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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}