Skip to main content

logicaffeine_web/
sitemap.rs

1//! Sitemap + prerender route enumeration for SEO.
2//!
3//! [`prerender_routes`] is the canonical list of crawlable pages: the SSG build
4//! prerenders exactly these routes (served via `/api/static_routes`), the sitemap
5//! is generated from them, and the tests below hold `public/sitemap.xml` byte-equal
6//! to [`generate_sitemap`] so the shipped file can never go stale.
7//!
8//! # Usage
9//!
10//! ```
11//! use logicaffeine_web::sitemap::generate_sitemap;
12//! let xml = generate_sitemap();
13//! assert!(!xml.is_empty());
14//! ```
15
16use std::fmt::Write;
17
18const BASE_URL: &str = "https://logicaffeine.com";
19const CURRENT_DATE: &str = "2026-07-08";
20
21/// Sitemap entry with URL and metadata
22pub struct SitemapEntry {
23    pub path: String,
24    pub changefreq: &'static str,
25    pub priority: f32,
26}
27
28/// The curated static pages (path, changefreq, priority).
29pub 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
50/// One entry per published news article, derived from the article registry so a
51/// new post can never be forgotten here.
52pub 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
63/// Every route the SSG build prerenders — the sitemap in path form. `/success`
64/// (session-specific, noindex), `/workspace/:subject`, and
65/// `/registry/package/:name` (not enumerable at build time) stay client-side.
66pub 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
74/// Generate the complete sitemap XML
75pub 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        // Each enumerated path must parse to a real Route variant — the catch-all
114        // NotFound counts as a broken entry, not a match.
115        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    /// The completeness ratchet: every fully-static route in the [`Route`]
126    /// enum must appear in the sitemap or on the explicit exclusion list — a
127    /// new page can never be silently forgotten.
128    #[test]
129    fn every_static_route_is_sitemapped_or_excluded() {
130        use dioxus::prelude::Routable;
131        use dioxus::router::routable::SegmentType;
132
133        // Session-specific pages that must NOT be crawled or prerendered.
134        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        // Keep the exclusion list honest: entries must be real routes that we
168        // deliberately hide, not leftovers.
169        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    /// Crawlers must land exactly where the sitemap points: each entry parses
182    /// to a real page and survives the router's boot normalization unchanged
183    /// in meaning (parse → serialize → parse is identity).
184    #[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    /// `lastmod` must never lag the newest published article.
197    #[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            // ISO dates compare correctly as strings.
216            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    /// Every URL we publish is XML-safe, URL-safe, canonical (lowercase, no
227    /// trailing slash), unique, and carries valid metadata.
228    #[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    /// Cloudflare serves robots.txt; it must advertise the sitemap we ship.
263    #[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        // And nothing session-specific leaks in.
292        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        // public/sitemap.xml is what Cloudflare serves; hold it byte-equal to the
299        // generator so route or article changes can never ship a stale file.
300        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    /// Not a check — the regenerator for the shipped file. Run explicitly:
312    /// `cargo nextest run -p logicaffeine-web -E 'test(regenerate_shipped_sitemap)' --run-ignored all`
313    #[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}