1use std::fmt::Write;
17
18const BASE_URL: &str = "https://logicaffeine.com";
19const CURRENT_DATE: &str = "2026-07-08";
20
21pub struct SitemapEntry {
23 pub path: String,
24 pub changefreq: &'static str,
25 pub priority: f32,
26}
27
28pub fn get_static_routes() -> Vec<SitemapEntry> {
30 [
31 ("/", "weekly", 1.0),
32 ("/guide", "monthly", 0.9),
33 ("/learn", "monthly", 0.9),
34 ("/crates", "monthly", 0.8),
35 ("/studio", "weekly", 0.8),
36 ("/benchmarks", "weekly", 0.8),
37 ("/pricing", "monthly", 0.8),
38 ("/roadmap", "monthly", 0.7),
39 ("/news", "weekly", 0.7),
40 ("/registry", "weekly", 0.6),
41 ("/profile", "monthly", 0.5),
42 ("/privacy", "yearly", 0.3),
43 ("/terms", "yearly", 0.3),
44 ]
45 .into_iter()
46 .map(|(path, changefreq, priority)| SitemapEntry { path: path.to_string(), changefreq, priority })
47 .collect()
48}
49
50pub fn get_news_routes() -> Vec<SitemapEntry> {
53 crate::ui::pages::news::get_articles()
54 .into_iter()
55 .map(|article| SitemapEntry {
56 path: format!("/news/{}", article.slug),
57 changefreq: "monthly",
58 priority: 0.6,
59 })
60 .collect()
61}
62
63pub fn prerender_routes() -> Vec<String> {
67 get_static_routes()
68 .into_iter()
69 .chain(get_news_routes())
70 .map(|entry| entry.path)
71 .collect()
72}
73
74pub fn generate_sitemap() -> String {
76 let mut xml = String::new();
77
78 writeln!(xml, r#"<?xml version="1.0" encoding="UTF-8"?>"#).unwrap();
79 writeln!(xml, r#"<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">"#).unwrap();
80
81 writeln!(xml, " <!-- Main Pages -->").unwrap();
82 for entry in get_static_routes() {
83 write_url_entry(&mut xml, entry);
84 }
85
86 writeln!(xml, "\n <!-- News Articles -->").unwrap();
87 for entry in get_news_routes() {
88 write_url_entry(&mut xml, entry);
89 }
90
91 writeln!(xml, "</urlset>").unwrap();
92
93 xml
94}
95
96fn write_url_entry(xml: &mut String, entry: SitemapEntry) {
97 writeln!(xml, " <url>").unwrap();
98 writeln!(xml, " <loc>{}{}</loc>", BASE_URL, entry.path).unwrap();
99 writeln!(xml, " <lastmod>{}</lastmod>", CURRENT_DATE).unwrap();
100 writeln!(xml, " <changefreq>{}</changefreq>", entry.changefreq).unwrap();
101 writeln!(xml, " <priority>{:.1}</priority>", entry.priority).unwrap();
102 writeln!(xml, " </url>").unwrap();
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::ui::router::Route;
109 use std::str::FromStr;
110
111 #[test]
112 fn every_prerender_route_is_a_real_page() {
113 for path in prerender_routes() {
116 let route = Route::from_str(&path)
117 .unwrap_or_else(|e| panic!("sitemap path '{path}' does not parse: {e}"));
118 assert!(
119 !matches!(route, Route::NotFound { .. }),
120 "sitemap path '{path}' falls through to the 404 catch-all"
121 );
122 }
123 }
124
125 #[test]
129 fn every_static_route_is_sitemapped_or_excluded() {
130 use dioxus::prelude::Routable;
131 use dioxus::router::routable::SegmentType;
132
133 const EXCLUDED: &[&str] = &["/success"];
135
136 let sitemapped: std::collections::BTreeSet<String> =
137 prerender_routes().into_iter().collect();
138
139 let mut static_paths = std::collections::BTreeSet::new();
140 for segment in Route::SITE_MAP {
141 for chain in segment.flatten() {
142 let parts: Option<Vec<&str>> =
143 chain.iter().map(SegmentType::to_static).collect();
144 if let Some(parts) = parts {
145 let joined = parts
146 .iter()
147 .filter(|p| !p.is_empty())
148 .copied()
149 .collect::<Vec<_>>()
150 .join("/");
151 static_paths.insert(format!("/{joined}"));
152 }
153 }
154 }
155 assert!(
156 static_paths.contains("/") && static_paths.contains("/studio"),
157 "site-map walk looks broken, found only: {static_paths:?}"
158 );
159
160 for path in &static_paths {
161 assert!(
162 sitemapped.contains(path.as_str()) || EXCLUDED.contains(&path.as_str()),
163 "route '{path}' exists but is neither in the sitemap nor \
164 explicitly excluded — add it to get_static_routes() or EXCLUDED"
165 );
166 }
167 for path in EXCLUDED {
170 assert!(
171 static_paths.contains(*path),
172 "EXCLUDED entry '{path}' is not a static route anymore — drop it"
173 );
174 assert!(
175 !sitemapped.contains(*path),
176 "'{path}' is both excluded and sitemapped — pick one"
177 );
178 }
179 }
180
181 #[test]
185 fn sitemap_urls_survive_boot_normalization() {
186 for path in prerender_routes() {
187 let first = Route::from_str(&path)
188 .unwrap_or_else(|e| panic!("sitemap path '{path}' does not parse: {e}"));
189 let second = Route::from_str(&first.to_string()).unwrap_or_else(|e| {
190 panic!("'{path}' re-serialized to something unparseable: {e}")
191 });
192 assert_eq!(first, second, "'{path}' does not round-trip to the same page");
193 }
194 }
195
196 #[test]
198 fn sitemap_lastmod_is_current() {
199 let date_ok = |d: &str| {
200 d.len() == 10
201 && d.bytes().enumerate().all(|(i, b)| match i {
202 4 | 7 => b == b'-',
203 _ => b.is_ascii_digit(),
204 })
205 };
206 assert!(date_ok(CURRENT_DATE), "CURRENT_DATE '{CURRENT_DATE}' is not YYYY-MM-DD");
207
208 for article in crate::ui::pages::news::get_articles() {
209 assert!(
210 date_ok(article.date),
211 "article '{}' has malformed date '{}'",
212 article.slug,
213 article.date
214 );
215 assert!(
217 CURRENT_DATE >= article.date,
218 "sitemap lastmod {CURRENT_DATE} lags article '{}' ({}) — bump \
219 CURRENT_DATE and regenerate the shipped sitemap",
220 article.slug,
221 article.date
222 );
223 }
224 }
225
226 #[test]
229 fn sitemap_entries_are_hygienic() {
230 const CHANGEFREQS: &[&str] =
231 &["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"];
232
233 let entries: Vec<SitemapEntry> =
234 get_static_routes().into_iter().chain(get_news_routes()).collect();
235
236 let mut seen = std::collections::BTreeSet::new();
237 for entry in &entries {
238 let path = &entry.path;
239 assert!(path.starts_with('/'), "'{path}' must be absolute");
240 assert!(
241 path == "/" || !path.ends_with('/'),
242 "'{path}' must not have a trailing slash"
243 );
244 assert!(
245 path.bytes().all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'/')),
246 "'{path}' contains characters that are not XML/URL-safe or not lowercase"
247 );
248 assert!(seen.insert(path.clone()), "'{path}' is listed twice");
249 assert!(
250 CHANGEFREQS.contains(&entry.changefreq),
251 "'{path}' has invalid changefreq '{}'",
252 entry.changefreq
253 );
254 assert!(
255 entry.priority > 0.0 && entry.priority <= 1.0,
256 "'{path}' has out-of-range priority {}",
257 entry.priority
258 );
259 }
260 }
261
262 #[test]
264 fn robots_txt_points_at_the_sitemap() {
265 let robots = std::fs::read_to_string(
266 concat!(env!("CARGO_MANIFEST_DIR"), "/public/robots.txt"),
267 )
268 .expect("public/robots.txt exists");
269 assert!(
270 robots.contains("Sitemap: https://logicaffeine.com/sitemap.xml"),
271 "robots.txt must advertise the sitemap URL"
272 );
273 }
274
275 #[test]
276 fn every_article_is_prerendered() {
277 let routes = prerender_routes();
278 for article in crate::ui::pages::news::get_articles() {
279 let path = format!("/news/{}", article.slug);
280 assert!(routes.contains(&path), "article '{}' missing from prerender/sitemap", article.slug);
281 }
282 }
283
284 #[test]
285 fn prerender_list_and_sitemap_agree() {
286 let xml = generate_sitemap();
287 for path in prerender_routes() {
288 let loc = format!("<loc>{BASE_URL}{path}</loc>");
289 assert!(xml.contains(&loc), "sitemap XML missing {path}");
290 }
291 assert!(!xml.contains("/success"), "sitemap must not list the checkout success page");
293 assert!(!xml.contains("/workspace"), "sitemap must not list workspace deep-links");
294 }
295
296 #[test]
297 fn shipped_sitemap_is_current() {
298 let shipped = std::fs::read_to_string(
301 concat!(env!("CARGO_MANIFEST_DIR"), "/public/sitemap.xml"),
302 )
303 .expect("public/sitemap.xml exists");
304 assert_eq!(
305 shipped.replace("\r\n", "\n"),
306 generate_sitemap(),
307 "public/sitemap.xml is stale — run the regenerate_shipped_sitemap test to refresh it"
308 );
309 }
310
311 #[test]
314 #[ignore = "writes public/sitemap.xml; run explicitly to regenerate"]
315 fn regenerate_shipped_sitemap() {
316 std::fs::write(
317 concat!(env!("CARGO_MANIFEST_DIR"), "/public/sitemap.xml"),
318 generate_sitemap(),
319 )
320 .expect("write public/sitemap.xml");
321 }
322}