Skip to main content

ssg/plugins/postprocess/
news_sitemap.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! News sitemap fix plugin.
5
6use 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/// Repairs news-sitemap.xml by populating entries from front-matter
13/// metadata instead of using placeholder values.
14#[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 no placeholder issues, skip
31        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        // Get base_url from config
42        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        // Build news entries from metadata
49        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        // Rebuild the news sitemap
61        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
81/// Builds a single `<url>` entry for the news sitemap from metadata.
82fn 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    // Issue #586 / plan §2 item 1.4 (spec A4): shared flexible date
103    // chain for <news:publication_date> — RFC 2822, long-form, and
104    // ISO 8601 inputs all normalise to a W3C datetime now. This is
105    // the ssg-side half of the chain that retires the upstream
106    // "'day' component could not be parsed" warning spam.
107    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    // Spec A2/B1 (plan §2 item 1.2): `<loc>` goes through the shared
124    // page-URL derivation so news-sitemap, sitemap, canonical `<link>`
125    // and feed `<link>` all publish the same directory-URL convention
126    // (`{base}/{rel_path}/`, never `…/index.html`).
127    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        // Directory-URL convention shared with canonical/feed/sitemap
394        // (plan §2 item 1.2, `urls::derive_page_url`).
395        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    // -----------------------------------------------------------------
402    // Flexible date chain (issue #586 / plan §2 item 1.4, spec A4)
403    // -----------------------------------------------------------------
404
405    #[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        // Has placeholder but no meta sidecars to rebuild from
507        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    // -----------------------------------------------------------------
523    // build_news_entry: empty item_pub_date parses to nothing silently
524    // -----------------------------------------------------------------
525
526    #[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    // -----------------------------------------------------------------
541    // Error paths
542    // -----------------------------------------------------------------
543
544    #[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}