1use std::fmt::Write as _;
6
7use crate::core::escape::escape_xml;
8
9#[derive(Debug, Clone, PartialEq, Default)]
21pub struct SitemapUrl {
22 pub loc: String,
24 pub lastmod: Option<String>,
26 pub changefreq: Option<String>,
29 pub priority: Option<f64>,
31}
32
33impl SitemapUrl {
34 pub fn new(loc: impl Into<String>) -> Self {
36 Self {
37 loc: loc.into(),
38 ..Self::default()
39 }
40 }
41
42 #[must_use]
44 pub fn lastmod(mut self, lastmod: impl Into<String>) -> Self {
45 self.lastmod = Some(lastmod.into());
46 self
47 }
48
49 #[must_use]
51 pub fn changefreq(mut self, changefreq: impl Into<String>) -> Self {
52 self.changefreq = Some(changefreq.into());
53 self
54 }
55
56 #[must_use]
58 pub fn priority(mut self, priority: f64) -> Self {
59 self.priority = Some(priority);
60 self
61 }
62}
63
64#[derive(Debug, Clone, Copy)]
66pub struct SitemapGenerator;
67
68impl SitemapGenerator {
69 #[must_use]
73 pub fn generate(urls: &[SitemapUrl]) -> String {
74 let mut xml = String::with_capacity(256 + urls.len() * 160);
75 xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
76 xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
77
78 for url in urls {
79 xml.push_str(" <url>\n");
80 let _ = writeln!(xml, " <loc>{}</loc>", escape_xml(&url.loc));
81 if let Some(lastmod) = &url.lastmod {
82 let _ = writeln!(xml, " <lastmod>{}</lastmod>", escape_xml(lastmod));
83 }
84 if let Some(changefreq) = &url.changefreq {
85 let _ = writeln!(
86 xml,
87 " <changefreq>{}</changefreq>",
88 escape_xml(changefreq)
89 );
90 }
91 if let Some(priority) = url.priority {
92 let _ = writeln!(
93 xml,
94 " <priority>{}</priority>",
95 format_priority(priority)
96 );
97 }
98 xml.push_str(" </url>\n");
99 }
100
101 xml.push_str("</urlset>");
102 xml
103 }
104
105 #[must_use]
107 pub fn generate_index(sitemaps: &[(String, Option<String>)]) -> String {
108 let mut xml = String::with_capacity(256 + sitemaps.len() * 96);
109 xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
110 xml.push_str("<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
111
112 for (loc, lastmod) in sitemaps {
113 xml.push_str(" <sitemap>\n");
114 let _ = writeln!(xml, " <loc>{}</loc>", escape_xml(loc));
115 if let Some(lastmod) = lastmod {
116 let _ = writeln!(xml, " <lastmod>{}</lastmod>", escape_xml(lastmod));
117 }
118 xml.push_str(" </sitemap>\n");
119 }
120
121 xml.push_str("</sitemapindex>");
122 xml
123 }
124}
125
126fn format_priority(priority: f64) -> String {
132 if priority.fract() == 0.0 && priority.is_finite() {
133 format!("{priority:.1}")
134 } else {
135 format!("{priority}")
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
145 fn a_sitemap_has_an_xml_declaration_and_a_urlset() {
146 let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://example.com/")]);
147 assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset "));
148 assert!(xml.ends_with("</urlset>"));
149 }
150
151 #[test]
153 fn a_loc_containing_an_ampersand_is_escaped() {
154 let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/a?x=1&y=2")]);
155 assert!(xml.contains("<loc>https://e.com/a?x=1&y=2</loc>"));
156 }
157
158 #[test]
161 fn whole_priorities_keep_one_decimal_place() {
162 assert_eq!(format_priority(1.0), "1.0");
163 assert_eq!(format_priority(0.0), "0.0");
164 assert_eq!(format_priority(0.7), "0.7");
165 assert_eq!(format_priority(0.25), "0.25");
166 }
167
168 #[test]
170 fn optional_fields_are_omitted_entirely() {
171 let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/")]);
172 assert!(!xml.contains("<lastmod>"));
173 assert!(!xml.contains("<changefreq>"));
174 assert!(!xml.contains("<priority>"));
175 }
176
177 #[test]
179 fn an_empty_sitemap_is_still_well_formed() {
180 let xml = SitemapGenerator::generate(&[]);
181 assert!(xml.contains("<urlset"));
182 assert!(xml.ends_with("</urlset>"));
183 }
184
185 #[test]
187 fn an_index_lists_each_sitemap() {
188 let xml = SitemapGenerator::generate_index(&[
189 (
190 "https://e.com/sitemap-posts.xml".into(),
191 Some("2026-01-15".into()),
192 ),
193 ("https://e.com/sitemap-pages.xml".into(), None),
194 ]);
195 assert!(xml.contains("<sitemapindex"));
196 assert!(xml.contains("<loc>https://e.com/sitemap-posts.xml</loc>"));
197 assert!(xml.contains("<lastmod>2026-01-15</lastmod>"));
198 assert_eq!(xml.matches("<sitemap>").count(), 2);
199 assert_eq!(xml.matches("<lastmod>").count(), 1);
200
201 let escaped = SitemapGenerator::generate_index(&[(
204 "https://e.com/sitemap-pages.xml?v=2&full=1".into(),
205 None,
206 )]);
207 assert!(escaped.contains("v=2&full=1"));
208 }
209
210 #[test]
212 fn an_index_entry_without_a_lastmod_emits_no_element() {
213 let xml = SitemapGenerator::generate_index(&[("https://e.com/sitemap.xml".into(), None)]);
214
215 assert!(!xml.contains("<lastmod>"));
216 }
217}