1use super::helpers::{read_meta_sidecars, xml_escape};
7use crate::dates::parse_flexible_date;
8use crate::error::{PathErrorExt, SsgError};
9use crate::plugin::{Plugin, PluginContext};
10use std::fs;
11
12#[derive(Debug, Clone, Copy)]
15pub struct NewsSitemapFixPlugin;
16
17impl Plugin for NewsSitemapFixPlugin {
18 fn name(&self) -> &'static str {
19 "news-sitemap-fix"
20 }
21
22 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
23 let path = ctx.site_dir.join("news-sitemap.xml");
24 if !path.exists() {
25 return Ok(());
26 }
27
28 let content = fs::read_to_string(&path).with_path(&path)?;
29
30 if !content.contains("Unnamed Publication")
32 && !content.contains("Untitled Article")
33 && !content.contains("<loc></loc>")
34 {
35 return Ok(());
36 }
37
38 let meta_entries =
39 read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
40
41 let base_url = ctx
43 .config
44 .as_ref()
45 .map(|c| c.base_url.trim_end_matches('/').to_string())
46 .unwrap_or_default();
47
48 let news_entries: Vec<String> = meta_entries
50 .iter()
51 .filter_map(|(rel_path, meta)| {
52 build_news_entry(rel_path, meta, &base_url)
53 })
54 .collect();
55
56 if news_entries.is_empty() {
57 return Ok(());
58 }
59
60 let rebuilt = format!(
62 r#"<?xml version="1.0" encoding="UTF-8"?>
63<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
64 xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
65{}
66</urlset>
67"#,
68 news_entries.join("\n")
69 );
70
71 fs::write(&path, rebuilt).with_path(&path)?;
72
73 log::info!(
74 "[news-sitemap-fix] Rebuilt news-sitemap.xml with {} entries",
75 news_entries.len()
76 );
77 Ok(())
78 }
79}
80
81fn build_news_entry(
83 rel_path: &str,
84 meta: &std::collections::HashMap<String, String>,
85 base_url: &str,
86) -> Option<String> {
87 let title = meta.get("title").cloned().unwrap_or_default();
88 let name = meta
89 .get("author")
90 .or_else(|| meta.get("name"))
91 .cloned()
92 .unwrap_or_default();
93 let language = meta
94 .get("language")
95 .cloned()
96 .unwrap_or_else(|| "en".to_string());
97
98 if title.is_empty() || rel_path.is_empty() {
99 return None;
100 }
101
102 let pub_date = meta
108 .get("item_pub_date")
109 .map(|d| match parse_flexible_date(d) {
110 Ok(dt) => dt.to_w3c_date(),
111 Err(err) => {
112 if !d.is_empty() {
113 log::warn!(
114 "[news-sitemap-fix] 'item_pub_date' for \
115 '{rel_path}': {err}"
116 );
117 }
118 d.clone()
119 }
120 })
121 .unwrap_or_default();
122
123 let loc = crate::urls::derive_page_url(
128 base_url,
129 &format!("{rel_path}/index.html"),
130 );
131
132 let keywords = meta
133 .get("keywords")
134 .or_else(|| meta.get("tags"))
135 .cloned()
136 .unwrap_or_default();
137 let extras = if keywords.is_empty() {
138 String::new()
139 } else {
140 format!(
141 "\n <news:keywords>{}</news:keywords>",
142 xml_escape(&keywords)
143 )
144 };
145
146 Some(format!(
147 r"<url>
148 <loc>{loc}</loc>
149 <news:news>
150 <news:publication>
151 <news:name>{name}</news:name>
152 <news:language>{language}</news:language>
153 </news:publication>
154 <news:publication_date>{pub_date}</news:publication_date>
155 <news:title>{title}</news:title>{extras}
156 </news:news>
157</url>"
158 ))
159}
160
161#[cfg(test)]
162mod tests {
163
164 use super::*;
165 use crate::plugin::PluginContext;
166 use anyhow::Result;
167 use std::collections::HashMap;
168 use std::path::Path;
169 use tempfile::tempdir;
170
171 fn write_meta_sidecar(
172 dir: &Path,
173 slug: &str,
174 meta: &HashMap<String, String>,
175 ) {
176 let page_dir = dir.join(slug);
177 fs::create_dir_all(&page_dir).expect("create page dir");
178 let meta_path = page_dir.join("index.meta.json");
179 let json = serde_json::to_string(meta).expect("serialize meta");
180 fs::write(&meta_path, json).expect("write meta");
181 }
182
183 fn make_atom_ctx(site_dir: &Path) -> PluginContext {
184 crate::test_support::init_logger();
185 let config = crate::cmd::SsgConfig {
186 listings: Vec::new(),
187 base_url: "https://example.com".to_string(),
188 site_name: "Test Site".to_string(),
189 site_title: "Test Site".to_string(),
190 site_description: "A test site".to_string(),
191 language: "en".to_string(),
192 content_dir: std::path::PathBuf::from("content"),
193 output_dir: std::path::PathBuf::from("build"),
194 template_dir: std::path::PathBuf::from("templates"),
195 theme: None,
196 serve_dir: None,
197 #[cfg(feature = "i18n")]
198 i18n: None,
199 cdn_prefix: None,
200 og_image: None,
201 image: crate::cmd::ImageConfig::default(),
202 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
203 agents: None,
204 transitions: false,
205 security: crate::cmd::SecurityConfig::default(),
206 no_taxonomy_pages: false,
207 };
208 PluginContext::with_config(
209 Path::new("content"),
210 Path::new("build"),
211 site_dir,
212 Path::new("templates"),
213 config,
214 )
215 }
216
217 fn test_ctx(site_dir: &Path) -> PluginContext {
218 crate::test_support::init_logger();
219 PluginContext::new(
220 Path::new("content"),
221 Path::new("build"),
222 site_dir,
223 Path::new("templates"),
224 )
225 }
226
227 #[test]
228 fn test_news_sitemap_with_keywords() -> Result<()> {
229 let tmp = tempdir().unwrap();
230
231 let news_path = tmp.path().join("news-sitemap.xml");
232 fs::write(
233 &news_path,
234 r#"<?xml version="1.0" encoding="UTF-8"?>
235<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
236 xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
237<url>
238 <loc></loc>
239 <news:news>
240 <news:publication>
241 <news:name>Unnamed Publication</news:name>
242 <news:language>en</news:language>
243 </news:publication>
244 <news:title>Untitled Article</news:title>
245 </news:news>
246</url>
247</urlset>"#,
248 )
249 .unwrap();
250
251 let mut meta = HashMap::new();
252 let _ = meta.insert("title".to_string(), "Breaking News".to_string());
253 let _ = meta.insert("author".to_string(), "Reporter".to_string());
254 let _ = meta.insert(
255 "item_pub_date".to_string(),
256 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
257 );
258 let _ = meta.insert(
259 "keywords".to_string(),
260 "rust, programming, web".to_string(),
261 );
262 let _ = meta.insert("language".to_string(), "fr".to_string());
263 write_meta_sidecar(tmp.path(), "breaking", &meta);
264
265 let ctx = make_atom_ctx(tmp.path());
266 NewsSitemapFixPlugin.after_compile(&ctx).unwrap();
267
268 let result = fs::read_to_string(&news_path).unwrap();
269 assert!(
270 result.contains(
271 "<news:keywords>rust, programming, web</news:keywords>"
272 ),
273 "Should inject keywords: {result}"
274 );
275 assert!(
276 result.contains("<news:name>Reporter</news:name>"),
277 "Should use author name: {result}"
278 );
279 assert!(
280 result.contains("<news:language>fr</news:language>"),
281 "Should use custom language: {result}"
282 );
283 assert!(
284 !result.contains("Unnamed Publication"),
285 "Should not have placeholder: {result}"
286 );
287 assert!(
288 !result.contains("Untitled Article"),
289 "Should not have placeholder: {result}"
290 );
291 Ok(())
292 }
293
294 #[test]
295 fn test_news_sitemap_with_tags_fallback() -> Result<()> {
296 let tmp = tempdir().unwrap();
297
298 let news_path = tmp.path().join("news-sitemap.xml");
299 fs::write(
300 &news_path,
301 r#"<?xml version="1.0" encoding="UTF-8"?>
302<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
303 xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
304<url>
305 <loc></loc>
306 <news:news>
307 <news:title>Untitled Article</news:title>
308 </news:news>
309</url>
310</urlset>"#,
311 )
312 .unwrap();
313
314 let mut meta = HashMap::new();
315 let _ = meta.insert("title".to_string(), "Tagged Post".to_string());
316 let _ = meta.insert("author".to_string(), "Writer".to_string());
317 let _ = meta.insert(
318 "item_pub_date".to_string(),
319 "Mon, 01 Sep 2025 12:00:00 +0000".to_string(),
320 );
321 let _ = meta.insert("tags".to_string(), "tech, science".to_string());
322 write_meta_sidecar(tmp.path(), "tagged", &meta);
323
324 let ctx = make_atom_ctx(tmp.path());
325 NewsSitemapFixPlugin.after_compile(&ctx).unwrap();
326
327 let result = fs::read_to_string(&news_path).unwrap();
328 assert!(
329 result.contains("<news:keywords>tech, science</news:keywords>"),
330 "Should fall back to tags for keywords: {result}"
331 );
332 Ok(())
333 }
334
335 #[test]
336 fn test_news_sitemap_skips_when_no_placeholders() -> Result<()> {
337 let tmp = tempdir().unwrap();
338
339 let news_path = tmp.path().join("news-sitemap.xml");
340 let original = r#"<?xml version="1.0" encoding="UTF-8"?>
341<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
342<url>
343 <loc>https://example.com/good</loc>
344 <news:news>
345 <news:title>Good Article</news:title>
346 </news:news>
347</url>
348</urlset>"#;
349 fs::write(&news_path, original).unwrap();
350
351 let ctx = test_ctx(tmp.path());
352 NewsSitemapFixPlugin.after_compile(&ctx).unwrap();
353
354 let result = fs::read_to_string(&news_path).unwrap();
355 assert_eq!(
356 result, original,
357 "Should not modify well-formed news sitemap"
358 );
359 Ok(())
360 }
361
362 #[test]
363 fn test_build_news_entry_returns_none_for_empty_title() {
364 let meta = HashMap::new();
365 assert!(
366 build_news_entry("slug", &meta, "https://example.com").is_none(),
367 "empty title should produce None"
368 );
369 }
370
371 #[test]
372 fn test_build_news_entry_returns_none_for_empty_path() {
373 let mut meta = HashMap::new();
374 let _ = meta.insert("title".to_string(), "Hello".to_string());
375 assert!(
376 build_news_entry("", &meta, "https://example.com").is_none(),
377 "empty rel_path should produce None"
378 );
379 }
380
381 #[test]
382 fn test_build_news_entry_valid() {
383 let mut meta = HashMap::new();
384 let _ = meta.insert("title".to_string(), "My Article".to_string());
385 let _ = meta.insert("author".to_string(), "Author".to_string());
386 let _ = meta.insert(
387 "item_pub_date".to_string(),
388 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
389 );
390 let entry =
391 build_news_entry("my-article", &meta, "https://example.com")
392 .expect("valid metadata should produce an entry");
393 assert!(entry.contains("<loc>https://example.com/my-article/</loc>"));
396 assert!(entry.contains("<news:name>Author</news:name>"));
397 assert!(entry.contains("<news:title>My Article</news:title>"));
398 assert!(entry.contains("<news:language>en</news:language>"));
399 }
400
401 #[test]
406 fn test_build_news_entry_rfc2822_date_is_w3c() {
407 let mut meta = HashMap::new();
408 let _ = meta.insert("title".to_string(), "RFC".to_string());
409 let _ = meta.insert(
410 "item_pub_date".to_string(),
411 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
412 );
413 let entry = build_news_entry("rfc", &meta, "https://example.com")
414 .expect("valid entry");
415 assert!(
416 entry.contains(
417 "<news:publication_date>2026-04-11T06:06:06+00:00\
418 </news:publication_date>"
419 ),
420 "RFC 2822 input should produce a W3C datetime: {entry}"
421 );
422 }
423
424 #[test]
425 fn test_build_news_entry_iso_date_is_w3c() {
426 let mut meta = HashMap::new();
427 let _ = meta.insert("title".to_string(), "ISO".to_string());
428 let _ =
429 meta.insert("item_pub_date".to_string(), "2026-07-01".to_string());
430 let entry = build_news_entry("iso", &meta, "https://example.com")
431 .expect("valid entry");
432 assert!(
433 entry.contains(
434 "<news:publication_date>2026-07-01T00:00:00+00:00\
435 </news:publication_date>"
436 ),
437 "ISO input should produce a W3C datetime: {entry}"
438 );
439 }
440
441 #[test]
442 fn test_build_news_entry_long_form_date_is_w3c() {
443 let mut meta = HashMap::new();
444 let _ = meta.insert("title".to_string(), "Long".to_string());
445 let _ = meta
446 .insert("item_pub_date".to_string(), "July 1, 2026".to_string());
447 let entry = build_news_entry("long", &meta, "https://example.com")
448 .expect("valid entry");
449 assert!(
450 entry.contains(
451 "<news:publication_date>2026-07-01T00:00:00+00:00\
452 </news:publication_date>"
453 ),
454 "long-form input should produce a W3C datetime: {entry}"
455 );
456 }
457
458 #[test]
459 fn test_build_news_entry_unparseable_date_passes_through() {
460 crate::test_support::init_logger();
461 let mut meta = HashMap::new();
462 let _ = meta.insert("title".to_string(), "Bad".to_string());
463 let _ =
464 meta.insert("item_pub_date".to_string(), "not-a-date".to_string());
465 let entry = build_news_entry("bad", &meta, "https://example.com")
466 .expect("valid entry");
467 assert!(
468 entry.contains(
469 "<news:publication_date>not-a-date</news:publication_date>"
470 ),
471 "unparseable input keeps previous passthrough behaviour: {entry}"
472 );
473 }
474
475 #[test]
476 fn test_build_news_entry_without_base_url() {
477 let mut meta = HashMap::new();
478 let _ = meta.insert("title".to_string(), "Post".to_string());
479 let _ = meta.insert("name".to_string(), "Writer".to_string());
480 let entry = build_news_entry("post", &meta, "")
481 .expect("should produce entry without base_url");
482 assert!(
483 entry.contains("<loc>/post/</loc>"),
484 "loc should be a rooted directory URL when base_url is \
485 empty: {entry}"
486 );
487 assert!(
488 entry.contains("<news:name>Writer</news:name>"),
489 "should fall back to 'name' field: {entry}"
490 );
491 }
492
493 #[test]
494 fn test_news_sitemap_no_file_is_noop() -> Result<()> {
495 let tmp = tempdir().unwrap();
496 let ctx = test_ctx(tmp.path());
497 NewsSitemapFixPlugin.after_compile(&ctx).unwrap();
498 assert!(!tmp.path().join("news-sitemap.xml").exists());
499 Ok(())
500 }
501
502 #[test]
503 fn test_news_sitemap_empty_entries_no_rebuild() -> Result<()> {
504 let tmp = tempdir().unwrap();
505 let news_path = tmp.path().join("news-sitemap.xml");
506 let original = r#"<?xml version="1.0" encoding="UTF-8"?>
508<urlset><url><loc></loc><news:news><news:title>Untitled Article</news:title></news:news></url></urlset>"#;
509 fs::write(&news_path, original).unwrap();
510
511 let ctx = test_ctx(tmp.path());
512 NewsSitemapFixPlugin.after_compile(&ctx).unwrap();
513
514 let result = fs::read_to_string(&news_path).unwrap();
515 assert_eq!(
516 result, original,
517 "should not modify when no meta entries produce valid news entries"
518 );
519 Ok(())
520 }
521
522 #[test]
527 fn test_build_news_entry_with_empty_pub_date() {
528 crate::test_support::init_logger();
529 let mut meta = HashMap::new();
530 let _ = meta.insert("title".to_string(), "T".to_string());
531 let _ = meta.insert("item_pub_date".to_string(), String::new());
532 let entry =
533 build_news_entry("post", &meta, "https://example.com").unwrap();
534 assert!(
535 entry.contains("<news:publication_date></news:publication_date>"),
536 "empty date passes through empty: {entry}"
537 );
538 }
539
540 #[test]
545 fn test_after_compile_errors_on_invalid_utf8_news_sitemap() {
546 let tmp = tempdir().unwrap();
547 let news_path = tmp.path().join("news-sitemap.xml");
548 fs::write(&news_path, [0xFF, 0xFE, 0xFD]).unwrap();
549 let ctx = test_ctx(tmp.path());
550 let err = NewsSitemapFixPlugin.after_compile(&ctx).unwrap_err();
551 assert!(format!("{err}").contains("news-sitemap.xml"));
552 }
553
554 #[test]
555 #[cfg(unix)]
556 fn test_after_compile_write_failure_on_readonly_news_sitemap() {
557 use std::os::unix::fs::PermissionsExt;
558 let tmp = tempdir().unwrap();
559 let news_path = tmp.path().join("news-sitemap.xml");
560 fs::write(
561 &news_path,
562 "<urlset><url><news:title>Untitled Article</news:title></url></urlset>",
563 )
564 .unwrap();
565 let mut meta = HashMap::new();
566 let _ = meta.insert("title".to_string(), "Real Title".to_string());
567 write_meta_sidecar(tmp.path(), "post", &meta);
568 fs::set_permissions(&news_path, fs::Permissions::from_mode(0o444))
569 .unwrap();
570
571 let ctx = make_atom_ctx(tmp.path());
572 let result = NewsSitemapFixPlugin.after_compile(&ctx);
573 let _ =
574 fs::set_permissions(&news_path, fs::Permissions::from_mode(0o644));
575 let err = result.unwrap_err();
576 assert!(format!("{err}").contains("news-sitemap.xml"));
577 }
578}