Skip to main content

ssg/plugins/postprocess/
sitemap.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Sitemap fix plugin.
5
6use super::helpers::{normalise_url_in_xml_line, read_meta_sidecars};
7use crate::dates::parse_flexible_date;
8use crate::error::{PathErrorExt, SsgError};
9use crate::plugin::{Plugin, PluginContext};
10use std::collections::HashMap;
11use std::fs;
12
13/// Repairs and canonicalises the generated `sitemap.xml`.
14///
15/// Removes duplicate XML declarations, normalises double-slash URLs,
16/// rewrites `<loc>` values onto the shared directory-URL convention
17/// (`…/foo/index.html` → `…/foo/`, via
18/// [`crate::urls::derive_page_url`] — spec A2/B1, plan §2 item 1.2),
19/// and updates per-page lastmod dates.
20#[derive(Debug, Clone, Copy)]
21pub struct SitemapFixPlugin;
22
23impl Plugin for SitemapFixPlugin {
24    fn name(&self) -> &'static str {
25        "sitemap-fix"
26    }
27
28    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
29        let sitemap_path = ctx.site_dir.join("sitemap.xml");
30        if !sitemap_path.exists() {
31            return Ok(());
32        }
33
34        let content =
35            fs::read_to_string(&sitemap_path).with_path(&sitemap_path)?;
36
37        let meta_entries =
38            read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
39        let date_map = collect_date_map(&meta_entries);
40
41        let result = strip_duplicate_xml_decls_and_fix_urls(&content);
42
43        // Second pass: update lastmod based on the <loc> in each <url> block
44        let updated = update_lastmod_from_loc(&result, &date_map);
45
46        fs::write(&sitemap_path, updated).with_path(&sitemap_path)?;
47
48        log::info!("[sitemap-fix] Repaired sitemap.xml");
49        Ok(())
50    }
51}
52
53/// Collects per-page date strings from meta sidecar entries.
54fn collect_date_map(
55    meta_entries: &[(String, HashMap<String, String>)],
56) -> HashMap<String, String> {
57    let mut date_map = HashMap::new();
58    for (rel_path, meta) in meta_entries {
59        if let Some(date) = extract_best_date(meta) {
60            let _ = date_map.insert(rel_path.clone(), date);
61        }
62    }
63    date_map
64}
65
66/// Extracts the best available date from a metadata map.
67///
68/// Issue #586 / plan §2 item 1.4 (spec A4): every field runs through
69/// the shared flexible date chain (RFC 2822 → long form → ISO 8601),
70/// so front matter like `date: July 1, 2026` now yields a valid
71/// `<lastmod>`. An unparseable `date` value still passes through
72/// verbatim, preserving the plugin's previous output for that case.
73fn extract_best_date(meta: &HashMap<String, String>) -> Option<String> {
74    let parse_field = |field: &str| {
75        let raw = meta.get(field)?;
76        match parse_flexible_date(raw) {
77            Ok(dt) => Some(dt.to_iso_date()),
78            Err(err) => {
79                if !raw.is_empty() {
80                    log::warn!("[sitemap-fix] '{field}': {err}");
81                }
82                None
83            }
84        }
85    };
86    parse_field("item_pub_date")
87        .or_else(|| parse_field("last_build_date"))
88        .or_else(|| parse_field("date"))
89        .or_else(|| meta.get("date").cloned())
90}
91
92/// Strips duplicate XML declarations and normalises URLs in the sitemap.
93fn strip_duplicate_xml_decls_and_fix_urls(content: &str) -> String {
94    let mut result = String::with_capacity(content.len());
95    let mut first_decl = true;
96
97    for line in content.lines() {
98        let trimmed = line.trim();
99
100        if trimmed.starts_with("<?xml") {
101            if first_decl {
102                first_decl = false;
103                result.push_str(line);
104                result.push('\n');
105            }
106            continue;
107        }
108
109        let processed = if line.contains("<loc>") {
110            // `<loc>` values go through the shared page-URL derivation
111            // so sitemap, canonical `<link>`, feed `<link>`, and the
112            // stager's injected `permalink:` all agree on the
113            // directory-URL convention (plan §2 item 1.2: one code
114            // path — `urls::derive_page_url`).
115            canonicalise_loc_urls(&normalise_url_in_xml_line(line))
116        } else if line.contains("<link>") || line.contains("<atom:link") {
117            normalise_url_in_xml_line(line)
118        } else {
119            line.to_string()
120        };
121
122        result.push_str(&processed);
123        result.push('\n');
124    }
125
126    result
127}
128
129/// Rewrites every `<loc>…</loc>` URL on `line` onto the canonical
130/// directory-URL convention via [`crate::urls::derive_page_url`]
131/// (spec A2/B1, plan §2 item 1.2): `…/foo/index.html` collapses to
132/// `…/foo/`, the root `…/index.html` to `…/`, and non-index paths
133/// pass through unchanged. URLs without a scheme are left untouched.
134fn canonicalise_loc_urls(line: &str) -> String {
135    let Some(open_idx) = line.find("<loc>") else {
136        return line.to_string();
137    };
138    let val_start = open_idx + "<loc>".len();
139    let Some(close_rel) = line[val_start..].find("</loc>") else {
140        return line.to_string();
141    };
142    let url = &line[val_start..val_start + close_rel];
143    let canonical = canonicalise_page_url(url);
144    format!(
145        "{}{}{}",
146        &line[..val_start],
147        canonical,
148        &line[val_start + close_rel..]
149    )
150}
151
152/// Splits an absolute URL into origin + path and re-derives it through
153/// [`crate::urls::derive_page_url`]. Non-absolute values (no scheme)
154/// are returned unchanged.
155fn canonicalise_page_url(url: &str) -> String {
156    let Some(scheme_end) = url.find("://") else {
157        return url.to_string();
158    };
159    let after_scheme = &url[scheme_end + 3..];
160    let Some(path_rel) = after_scheme.find('/') else {
161        // Bare origin (`https://example.com`) — the root URL.
162        return format!("{url}/");
163    };
164    let origin = &url[..scheme_end + 3 + path_rel];
165    let rel_path = &after_scheme[path_rel + 1..];
166    crate::urls::derive_page_url(origin, rel_path)
167}
168
169/// Update `<lastmod>` values based on the preceding `<loc>` URL in each
170/// `<url>` block.
171pub(super) fn update_lastmod_from_loc(
172    xml: &str,
173    date_map: &HashMap<String, String>,
174) -> String {
175    if date_map.is_empty() {
176        return xml.to_string();
177    }
178
179    let mut result = String::with_capacity(xml.len());
180    let mut current_loc = String::new();
181
182    for line in xml.lines() {
183        let trimmed = line.trim();
184
185        // Track current <loc> value
186        if trimmed.starts_with("<loc>") {
187            if let Some(url) = trimmed
188                .strip_prefix("<loc>")
189                .and_then(|s| s.strip_suffix("</loc>"))
190            {
191                current_loc = url.to_string();
192            }
193        }
194
195        // Replace <lastmod> using per-page date if available
196        if trimmed.starts_with("<lastmod>") && trimmed.ends_with("</lastmod>") {
197            let mut matched = false;
198            for (rel_path, date) in date_map {
199                if !rel_path.is_empty() && current_loc.contains(rel_path) {
200                    let indent = &line[..line.len() - line.trim_start().len()];
201                    result.push_str(&format!(
202                        "{indent}<lastmod>{date}</lastmod>\n"
203                    ));
204                    matched = true;
205                    break;
206                }
207            }
208            if !matched {
209                result.push_str(line);
210                result.push('\n');
211            }
212        } else {
213            result.push_str(line);
214            result.push('\n');
215        }
216    }
217    result
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::plugin::PluginContext;
224    use anyhow::Result;
225    use std::path::Path;
226    use tempfile::tempdir;
227
228    fn test_ctx(site_dir: &Path) -> PluginContext {
229        crate::test_support::init_logger();
230        PluginContext::new(
231            Path::new("content"),
232            Path::new("build"),
233            site_dir,
234            Path::new("templates"),
235        )
236    }
237
238    #[test]
239    fn test_sitemap_fix_removes_duplicate_xml_decls() -> Result<()> {
240        let tmp = tempdir().unwrap();
241        let sitemap = tmp.path().join("sitemap.xml");
242        fs::write(
243            &sitemap,
244            r#"<?xml version="1.0" encoding="UTF-8"?>
245<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
246    <?xml version="1.0" encoding="UTF-8"?>
247<url>
248  <loc>https://example.com/page1</loc>
249  <lastmod>2025-09-01</lastmod>
250</url>
251    <?xml version="1.0" encoding="UTF-8"?>
252<url>
253  <loc>https://example.com/page2</loc>
254  <lastmod>2025-09-01</lastmod>
255</url>
256</urlset>"#,
257        )
258        .unwrap();
259
260        let ctx = test_ctx(tmp.path());
261        SitemapFixPlugin.after_compile(&ctx).unwrap();
262
263        let result = fs::read_to_string(&sitemap).unwrap();
264        assert_eq!(result.matches("<?xml").count(), 1);
265        Ok(())
266    }
267
268    #[test]
269    fn test_sitemap_fix_normalises_double_slashes() -> Result<()> {
270        let tmp = tempdir().unwrap();
271        let sitemap = tmp.path().join("sitemap.xml");
272        fs::write(
273            &sitemap,
274            r#"<?xml version="1.0" encoding="UTF-8"?>
275<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
276<url>
277  <loc>https://example.com//index.html</loc>
278  <lastmod>2025-09-01</lastmod>
279</url>
280</urlset>"#,
281        )
282        .unwrap();
283
284        let ctx = test_ctx(tmp.path());
285        SitemapFixPlugin.after_compile(&ctx).unwrap();
286
287        let result = fs::read_to_string(&sitemap).unwrap();
288        // Double slash normalised AND `<loc>` collapsed onto the
289        // shared directory-URL convention (plan §2 item 1.2): the
290        // root `index.html` publishes as the bare base URL.
291        assert!(result.contains("<loc>https://example.com/</loc>"));
292        assert!(!result.contains("com//index"));
293        assert!(!result.contains("index.html"));
294        Ok(())
295    }
296
297    #[test]
298    fn test_sitemap_fix_collapses_index_html_locs() -> Result<()> {
299        // Plan §2 item 1.2: sitemap `<loc>` must agree with canonical
300        // `<link>` and feed `<link>` — all derive through
301        // `urls::derive_page_url`, so `…/foo/index.html` publishes as
302        // the pretty directory URL `…/foo/`.
303        let tmp = tempdir().unwrap();
304        let sitemap = tmp.path().join("sitemap.xml");
305        fs::write(
306            &sitemap,
307            r#"<?xml version="1.0" encoding="UTF-8"?>
308<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
309<url>
310  <loc>https://example.com/posts/hello/index.html</loc>
311  <lastmod>2025-09-01</lastmod>
312</url>
313<url>
314  <loc>https://example.com/feed.xml</loc>
315  <lastmod>2025-09-01</lastmod>
316</url>
317</urlset>"#,
318        )
319        .unwrap();
320
321        let ctx = test_ctx(tmp.path());
322        SitemapFixPlugin.after_compile(&ctx).unwrap();
323
324        let result = fs::read_to_string(&sitemap).unwrap();
325        assert!(
326            result.contains("<loc>https://example.com/posts/hello/</loc>"),
327            "index.html should collapse to the directory URL: {result}"
328        );
329        // Non-index outputs keep their file name.
330        assert!(result.contains("<loc>https://example.com/feed.xml</loc>"));
331        Ok(())
332    }
333
334    #[test]
335    fn canonicalise_loc_urls_handles_edge_shapes() {
336        // Bare origin → root URL with trailing slash.
337        assert_eq!(
338            canonicalise_loc_urls("<loc>https://example.com</loc>"),
339            "<loc>https://example.com/</loc>"
340        );
341        // Schemeless values pass through untouched.
342        assert_eq!(
343            canonicalise_loc_urls("<loc>relative/index.html</loc>"),
344            "<loc>relative/index.html</loc>"
345        );
346        // Lines without a closing tag pass through untouched.
347        assert_eq!(
348            canonicalise_loc_urls("<loc>https://example.com/a"),
349            "<loc>https://example.com/a"
350        );
351        // Indentation is preserved.
352        assert_eq!(
353            canonicalise_loc_urls(
354                "  <loc>https://example.com/a/index.html</loc>"
355            ),
356            "  <loc>https://example.com/a/</loc>"
357        );
358    }
359
360    #[test]
361    fn test_update_lastmod_from_loc_empty_map() {
362        let xml = "<url><loc>https://example.com</loc><lastmod>2025-01-01</lastmod></url>";
363        let result = update_lastmod_from_loc(xml, &HashMap::new());
364        assert_eq!(result, xml);
365    }
366
367    #[test]
368    fn test_update_lastmod_from_loc_with_match() {
369        let xml = "<url>\n<loc>https://example.com/blog/</loc>\n<lastmod>2025-01-01</lastmod>\n</url>";
370        let mut map = HashMap::new();
371        let _ = map.insert("blog".to_string(), "2026-04-11".to_string());
372        let result = update_lastmod_from_loc(xml, &map);
373        assert!(
374            result.contains("<lastmod>2026-04-11</lastmod>"),
375            "Should update lastmod: {result}"
376        );
377    }
378
379    #[test]
380    fn name_is_stable() {
381        assert_eq!(SitemapFixPlugin.name(), "sitemap-fix");
382    }
383
384    #[test]
385    fn after_compile_no_op_when_sitemap_missing() -> Result<()> {
386        let tmp = tempdir().unwrap();
387        let ctx = test_ctx(tmp.path());
388        SitemapFixPlugin.after_compile(&ctx).unwrap();
389        assert!(!tmp.path().join("sitemap.xml").exists());
390        Ok(())
391    }
392
393    #[test]
394    fn extract_best_date_prefers_item_pub_date() {
395        let mut meta = HashMap::new();
396        let _ = meta.insert(
397            "item_pub_date".to_string(),
398            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
399        );
400        let _ = meta.insert(
401            "last_build_date".to_string(),
402            "Mon, 01 Sep 2025 06:06:06 +0000".to_string(),
403        );
404        let _ = meta.insert("date".to_string(), "2024-01-01".to_string());
405        let date = extract_best_date(&meta);
406        assert!(
407            date.as_deref().is_some_and(|d| d.contains("2026-04-11")),
408            "should prefer item_pub_date, got: {date:?}"
409        );
410    }
411
412    #[test]
413    fn extract_best_date_falls_back_to_last_build_date() {
414        let mut meta = HashMap::new();
415        let _ = meta.insert(
416            "last_build_date".to_string(),
417            "Mon, 01 Sep 2025 06:06:06 +0000".to_string(),
418        );
419        let date = extract_best_date(&meta);
420        assert!(
421            date.as_deref().is_some_and(|d| d.contains("2025-09-01")),
422            "should use last_build_date when item_pub_date absent: {date:?}"
423        );
424    }
425
426    #[test]
427    fn extract_best_date_falls_back_to_date_field() {
428        let mut meta = HashMap::new();
429        let _ = meta.insert("date".to_string(), "2024-01-01".to_string());
430        let date = extract_best_date(&meta);
431        assert_eq!(date.as_deref(), Some("2024-01-01"));
432    }
433
434    #[test]
435    fn extract_best_date_returns_none_when_no_dates() {
436        let meta = HashMap::new();
437        assert!(extract_best_date(&meta).is_none());
438    }
439
440    // -----------------------------------------------------------------
441    // Flexible date chain (issue #586 / plan §2 item 1.4, spec A4)
442    // -----------------------------------------------------------------
443
444    #[test]
445    fn extract_best_date_parses_long_form_date_field() {
446        crate::test_support::init_logger();
447        let mut meta = HashMap::new();
448        let _ = meta.insert("date".to_string(), "July 1, 2026".to_string());
449        let date = extract_best_date(&meta);
450        assert_eq!(date.as_deref(), Some("2026-07-01"));
451    }
452
453    #[test]
454    fn extract_best_date_parses_iso_item_pub_date() {
455        let mut meta = HashMap::new();
456        let _ =
457            meta.insert("item_pub_date".to_string(), "2026-07-01".to_string());
458        let date = extract_best_date(&meta);
459        assert_eq!(date.as_deref(), Some("2026-07-01"));
460    }
461
462    #[test]
463    fn extract_best_date_unparseable_date_passes_through() {
464        crate::test_support::init_logger();
465        let mut meta = HashMap::new();
466        let _ = meta.insert("date".to_string(), "not-a-date".to_string());
467        // Verbatim fallback preserves the plugin's previous output.
468        let date = extract_best_date(&meta);
469        assert_eq!(date.as_deref(), Some("not-a-date"));
470    }
471
472    #[test]
473    fn extract_best_date_skips_unparseable_pub_date_for_next_field() {
474        crate::test_support::init_logger();
475        let mut meta = HashMap::new();
476        let _ = meta.insert("item_pub_date".to_string(), "garbage".to_string());
477        let _ = meta.insert(
478            "last_build_date".to_string(),
479            "Mon, 01 Sep 2025 06:06:06 +0000".to_string(),
480        );
481        let date = extract_best_date(&meta);
482        assert_eq!(date.as_deref(), Some("2025-09-01"));
483    }
484
485    #[test]
486    fn collect_date_map_includes_only_pages_with_dates() {
487        let mut m1 = HashMap::new();
488        let _ = m1.insert("date".to_string(), "2025-01-01".to_string());
489        let mut m2 = HashMap::new();
490        let _ = m2.insert("title".to_string(), "no date here".to_string());
491        let entries =
492            vec![("page-a".to_string(), m1), ("page-b".to_string(), m2)];
493        let map = collect_date_map(&entries);
494        assert_eq!(map.len(), 1);
495        assert_eq!(map.get("page-a").unwrap(), "2025-01-01");
496    }
497
498    #[test]
499    fn strip_duplicate_xml_decls_preserves_first_only() {
500        let input = "<?xml version=\"1.0\"?>\n<root>\n<?xml version=\"1.0\"?>\n<x/>\n</root>";
501        let out = strip_duplicate_xml_decls_and_fix_urls(input);
502        assert_eq!(out.matches("<?xml").count(), 1);
503        assert!(out.contains("<x/>"));
504    }
505
506    #[test]
507    fn update_lastmod_no_match_leaves_line_unchanged() {
508        let xml = "<url>\n<loc>https://example.com/other/</loc>\n<lastmod>2025-01-01</lastmod>\n</url>";
509        let mut map = HashMap::new();
510        let _ = map.insert("blog".to_string(), "2026-04-11".to_string());
511        let result = update_lastmod_from_loc(xml, &map);
512        assert!(
513            result.contains("<lastmod>2025-01-01</lastmod>"),
514            "non-matching loc should leave lastmod unchanged: {result}"
515        );
516    }
517
518    #[test]
519    fn update_lastmod_skips_empty_rel_path_match() {
520        // Edge case: empty rel_path entries shouldn't match anything.
521        let xml = "<url>\n<loc>https://example.com/x/</loc>\n<lastmod>2025-01-01</lastmod>\n</url>";
522        let mut map = HashMap::new();
523        let _ = map.insert(String::new(), "should-not-match".to_string());
524        let result = update_lastmod_from_loc(xml, &map);
525        assert!(result.contains("<lastmod>2025-01-01</lastmod>"));
526        assert!(!result.contains("should-not-match"));
527    }
528
529    // -----------------------------------------------------------------
530    // extract_best_date: empty date fields fail parsing silently
531    // -----------------------------------------------------------------
532
533    #[test]
534    fn test_extract_best_date_empty_field_yields_verbatim_date() {
535        crate::test_support::init_logger();
536        let mut meta = HashMap::new();
537        let _ = meta.insert("item_pub_date".to_string(), String::new());
538        // The empty value fails the flexible chain without warning and
539        // falls through to the verbatim `date` fallback (also absent).
540        assert_eq!(extract_best_date(&meta), None);
541    }
542
543    // -----------------------------------------------------------------
544    // strip_duplicate_xml_decls_and_fix_urls: <atom:link> lines
545    // -----------------------------------------------------------------
546
547    #[test]
548    fn test_strip_normalises_atom_link_lines() {
549        let content = "<?xml version=\"1.0\"?>\n<atom:link href=\"https://example.com//rss.xml\"/>\n";
550        let out = strip_duplicate_xml_decls_and_fix_urls(content);
551        assert!(
552            out.contains("https://example.com/rss.xml"),
553            "double slash in atom:link must be normalised: {out}"
554        );
555    }
556
557    // -----------------------------------------------------------------
558    // canonicalise_loc_urls: defensive early returns
559    // -----------------------------------------------------------------
560
561    #[test]
562    fn test_canonicalise_loc_urls_without_loc_tag() {
563        let line = "  <lastmod>2026-01-01</lastmod>";
564        assert_eq!(canonicalise_loc_urls(line), line);
565    }
566
567    #[test]
568    fn test_canonicalise_loc_urls_with_unclosed_loc() {
569        let line = "  <loc>https://example.com/page";
570        assert_eq!(canonicalise_loc_urls(line), line);
571    }
572
573    // -----------------------------------------------------------------
574    // update_lastmod_from_loc: unclosed <loc> keeps previous state
575    // -----------------------------------------------------------------
576
577    #[test]
578    fn test_update_lastmod_ignores_unclosed_loc_line() {
579        let mut date_map = HashMap::new();
580        let _ = date_map.insert("page".to_string(), "2026-02-02".to_string());
581        let xml = "<url>\n<loc>https://example.com/page\n<lastmod>2020-01-01</lastmod>\n</url>\n";
582        let out = update_lastmod_from_loc(xml, &date_map);
583        assert!(
584            out.contains("<lastmod>2020-01-01</lastmod>"),
585            "unclosed <loc> must not update current_loc: {out}"
586        );
587    }
588
589    // -----------------------------------------------------------------
590    // Error paths
591    // -----------------------------------------------------------------
592
593    #[test]
594    fn test_after_compile_errors_on_invalid_utf8_sitemap() {
595        let tmp = tempdir().unwrap();
596        let sitemap_path = tmp.path().join("sitemap.xml");
597        fs::write(&sitemap_path, [0xFF, 0xFE, 0xFD]).unwrap();
598        let ctx = test_ctx(tmp.path());
599        let err = SitemapFixPlugin.after_compile(&ctx).unwrap_err();
600        assert!(format!("{err}").contains("sitemap.xml"));
601    }
602
603    #[test]
604    #[cfg(unix)]
605    fn test_after_compile_write_failure_on_readonly_sitemap() {
606        use std::os::unix::fs::PermissionsExt;
607        let tmp = tempdir().unwrap();
608        let sitemap_path = tmp.path().join("sitemap.xml");
609        fs::write(
610            &sitemap_path,
611            "<?xml version=\"1.0\"?>\n<urlset></urlset>\n",
612        )
613        .unwrap();
614        fs::set_permissions(&sitemap_path, fs::Permissions::from_mode(0o444))
615            .unwrap();
616
617        let ctx = test_ctx(tmp.path());
618        let result = SitemapFixPlugin.after_compile(&ctx);
619        let _ = fs::set_permissions(
620            &sitemap_path,
621            fs::Permissions::from_mode(0o644),
622        );
623        let err = result.unwrap_err();
624        assert!(format!("{err}").contains("sitemap.xml"));
625    }
626}