1use 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#[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 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
53fn 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
66fn 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
92fn 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 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
129fn 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
152fn 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 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
169pub(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 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 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 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 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 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 assert_eq!(
338 canonicalise_loc_urls("<loc>https://example.com</loc>"),
339 "<loc>https://example.com/</loc>"
340 );
341 assert_eq!(
343 canonicalise_loc_urls("<loc>relative/index.html</loc>"),
344 "<loc>relative/index.html</loc>"
345 );
346 assert_eq!(
348 canonicalise_loc_urls("<loc>https://example.com/a"),
349 "<loc>https://example.com/a"
350 );
351 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 #[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 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 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 #[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 assert_eq!(extract_best_date(&meta), None);
541 }
542
543 #[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 #[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 #[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 #[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}