1use super::helpers::{extract_xml_value, read_meta_sidecars, xml_escape};
7use crate::dates::{parse_flexible_date, DateFormat};
8use crate::error::{PathErrorExt, SsgError};
9use crate::plugin::{Plugin, PluginContext};
10use std::fs;
11
12#[derive(Debug, Clone, Copy)]
14pub struct RssAggregatePlugin;
15
16fn collect_articles(
18 meta_entries: &[(String, std::collections::HashMap<String, String>)],
19 base_url: &str,
20) -> Vec<(String, String)> {
21 let mut articles: Vec<(String, String)> = Vec::new();
22 for (rel_path, meta) in meta_entries {
23 if rel_path.is_empty() {
24 continue;
25 }
26
27 let title = meta.get("title").cloned().unwrap_or_default();
28 let description = meta.get("description").cloned().unwrap_or_default();
29 let pub_date = meta.get("item_pub_date").cloned().unwrap_or_default();
30 let author = meta.get("author").cloned().unwrap_or_default();
31 let banner = meta.get("banner").or_else(|| meta.get("image")).cloned();
32 let category = meta.get("category").cloned();
33 let tags = meta.get("tags").cloned();
34
35 if title.is_empty() {
36 continue;
37 }
38
39 let link = if base_url.is_empty() {
40 format!("{rel_path}/")
41 } else {
42 format!("{base_url}/{rel_path}/")
43 };
44
45 let (sort_key, pub_date) = match parse_flexible_date(&pub_date) {
51 Ok(dt) => {
52 let rfc2822 = if dt.format == DateFormat::Rfc2822 {
53 pub_date.clone()
54 } else {
55 dt.to_rfc2822()
56 };
57 (dt.to_rfc3339(), rfc2822)
58 }
59 Err(err) => {
60 if !pub_date.is_empty() {
61 log::warn!(
62 "[rss-aggregate] 'item_pub_date' for '{rel_path}': {err}"
63 );
64 }
65 (pub_date.clone(), pub_date)
66 }
67 };
68
69 let escaped_desc = xml_escape(&description);
70
71 let mut extras = String::new();
73
74 if let Some(ref img) = banner {
76 let img_url = if img.starts_with("http") {
77 img.clone()
78 } else if !base_url.is_empty() {
79 format!("{base_url}/{}", img.trim_start_matches('/'))
80 } else {
81 img.clone()
82 };
83 let mime = if img_url.ends_with(".webp") {
84 "image/webp"
85 } else if img_url.ends_with(".png") {
86 "image/png"
87 } else {
88 "image/jpeg"
89 };
90 extras.push_str(&format!(
91 "\n <enclosure url=\"{img_url}\" type=\"{mime}\" length=\"0\"/>"
92 ));
93 }
94
95 if let Some(ref cat) = category {
97 extras.push_str(&format!(
98 "\n <category>{}</category>",
99 xml_escape(cat)
100 ));
101 }
102 if let Some(ref t) = tags {
103 for tag in t.split(',') {
104 let tag = tag.trim();
105 if !tag.is_empty() {
106 extras.push_str(&format!(
107 "\n <category>{}</category>",
108 xml_escape(tag)
109 ));
110 }
111 }
112 }
113
114 let item = format!(
115 r#" <item>
116 <title>{title}</title>
117 <link>{link}</link>
118 <description>{escaped_desc}</description>
119 <guid isPermaLink="true">{link}</guid>
120 <pubDate>{pub_date}</pubDate>
121 <author>{author}</author>{extras}
122 </item>"#
123 );
124
125 articles.push((sort_key, item));
126 }
127 articles
128}
129
130fn build_rss_channel(
132 channel_title: &str,
133 channel_link: &str,
134 channel_desc: &str,
135 base_url: &str,
136 language: &str,
137 last_build_date: &str,
138 copyright: &str,
139 items_xml: &str,
140) -> String {
141 let mut channel_extras = String::new();
142 if !language.is_empty() {
143 channel_extras
144 .push_str(&format!("\n <language>{language}</language>"));
145 }
146 if !last_build_date.is_empty() {
147 channel_extras.push_str(&format!(
148 "\n <lastBuildDate>{last_build_date}</lastBuildDate>"
149 ));
150 }
151 if !copyright.is_empty() {
152 channel_extras.push_str(&format!(
153 "\n <copyright>{}</copyright>",
154 xml_escape(copyright)
155 ));
156 }
157
158 format!(
159 r#"<?xml version="1.0" encoding="UTF-8"?>
160<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
161 <channel>
162 <title>{channel_title}</title>
163 <link>{channel_link}</link>
164 <description>{channel_desc}</description>
165 <atom:link href="{base_url}/rss.xml" rel="self" type="application/rss+xml"/>{channel_extras}
166{items_xml}
167 </channel>
168</rss>
169"#
170 )
171}
172
173impl Plugin for RssAggregatePlugin {
174 fn name(&self) -> &'static str {
175 "rss-aggregate"
176 }
177
178 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
179 let rss_path = ctx.site_dir.join("rss.xml");
180 if !rss_path.exists() {
181 return Ok(());
182 }
183
184 let content = fs::read_to_string(&rss_path).with_path(&rss_path)?;
185
186 if content.matches("<item>").count() > 1 {
187 return Ok(());
188 }
189
190 let meta_entries =
191 read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
192
193 let base_url = ctx
194 .config
195 .as_ref()
196 .map(|c| c.base_url.trim_end_matches('/').to_string())
197 .unwrap_or_default();
198
199 let language = extract_language(ctx);
200 let copyright = extract_copyright(&meta_entries);
201
202 let mut articles = collect_articles(&meta_entries, &base_url);
203 articles.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
212 articles.truncate(50);
213
214 if articles.is_empty() {
215 return Ok(());
216 }
217
218 let last_build_date = extract_last_build_date(&articles);
219
220 let items_xml: String = articles
221 .iter()
222 .map(|(_, xml)| xml.as_str())
223 .collect::<Vec<_>>()
224 .join("\n");
225
226 let channel_title = extract_xml_value(&content, "title")
227 .unwrap_or_else(|| "Untitled".to_string());
228 let channel_link = extract_xml_value(&content, "link")
229 .unwrap_or_else(|| base_url.clone());
230 let channel_desc =
231 extract_xml_value(&content, "description").unwrap_or_default();
232
233 let rebuilt = build_rss_channel(
234 &channel_title,
235 &channel_link,
236 &channel_desc,
237 &base_url,
238 &language,
239 &last_build_date,
240 ©right,
241 &items_xml,
242 );
243
244 fs::write(&rss_path, rebuilt).with_path(&rss_path)?;
245
246 log::info!(
247 "[rss-aggregate] Rebuilt rss.xml with {} article items",
248 articles.len()
249 );
250 Ok(())
251 }
252}
253
254fn extract_language(ctx: &PluginContext) -> String {
256 ctx.config
257 .as_ref()
258 .and_then(|c| {
259 if c.site_name.is_empty() {
260 None
261 } else {
262 Some("en".to_string())
263 }
264 })
265 .unwrap_or_else(|| "en".to_string())
266}
267
268fn extract_copyright(
270 meta_entries: &[(String, std::collections::HashMap<String, String>)],
271) -> String {
272 meta_entries
273 .iter()
274 .find_map(|(_, m)| m.get("copyright").cloned())
275 .unwrap_or_default()
276}
277
278fn extract_last_build_date(articles: &[(String, String)]) -> String {
280 articles
281 .first()
282 .and_then(|(_, xml)| {
283 xml.find("<pubDate>").and_then(|s| {
284 let after = &xml[s + 9..];
285 after.find("</pubDate>").map(|e| after[..e].to_string())
286 })
287 })
288 .unwrap_or_default()
289}
290
291#[cfg(test)]
292mod tests {
293
294 use super::*;
295 use crate::plugin::PluginContext;
296 use anyhow::Result;
297 use std::collections::HashMap;
298 use std::path::Path;
299 use tempfile::tempdir;
300
301 fn write_meta_sidecar(
302 dir: &Path,
303 slug: &str,
304 meta: &HashMap<String, String>,
305 ) {
306 let page_dir = dir.join(slug);
307 fs::create_dir_all(&page_dir).expect("create page dir");
308 let meta_path = page_dir.join("index.meta.json");
309 let json = serde_json::to_string(meta).expect("serialize meta");
310 fs::write(&meta_path, json).expect("write meta");
311 }
312
313 fn make_atom_ctx(site_dir: &Path) -> PluginContext {
314 crate::test_support::init_logger();
315 let config = crate::cmd::SsgConfig {
316 listings: Vec::new(),
317 base_url: "https://example.com".to_string(),
318 site_name: "Test Site".to_string(),
319 site_title: "Test Site".to_string(),
320 site_description: "A test site".to_string(),
321 language: "en".to_string(),
322 content_dir: std::path::PathBuf::from("content"),
323 output_dir: std::path::PathBuf::from("build"),
324 template_dir: std::path::PathBuf::from("templates"),
325 theme: None,
326 serve_dir: None,
327 #[cfg(feature = "i18n")]
328 i18n: None,
329 cdn_prefix: None,
330 og_image: None,
331 image: crate::cmd::ImageConfig::default(),
332 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
333 agents: None,
334 transitions: false,
335 security: crate::cmd::SecurityConfig::default(),
336 no_taxonomy_pages: false,
337 };
338 PluginContext::with_config(
339 Path::new("content"),
340 Path::new("build"),
341 site_dir,
342 Path::new("templates"),
343 config,
344 )
345 }
346
347 fn test_ctx(site_dir: &Path) -> PluginContext {
348 crate::test_support::init_logger();
349 PluginContext::new(
350 Path::new("content"),
351 Path::new("build"),
352 site_dir,
353 Path::new("templates"),
354 )
355 }
356
357 #[test]
358 fn test_rss_aggregate_single_item_trigger() -> Result<()> {
359 let tmp = tempdir().unwrap();
360 let rss_path = tmp.path().join("rss.xml");
361 fs::write(
362 &rss_path,
363 r#"<?xml version="1.0" encoding="UTF-8"?>
364<rss version="2.0">
365 <channel>
366 <title>My Site</title>
367 <link>https://example.com</link>
368 <description>A test site</description>
369 <item>
370 <title>Feed itself</title>
371 <link>https://example.com/rss.xml</link>
372 </item>
373 </channel>
374</rss>"#,
375 )
376 .unwrap();
377
378 let ctx = test_ctx(tmp.path());
379 RssAggregatePlugin.after_compile(&ctx).unwrap();
380 Ok(())
381 }
382
383 #[test]
384 fn test_rss_aggregate_with_full_metadata() -> Result<()> {
385 let tmp = tempdir().unwrap();
386
387 let rss_path = tmp.path().join("rss.xml");
388 fs::write(
389 &rss_path,
390 r#"<?xml version="1.0" encoding="UTF-8"?>
391<rss version="2.0">
392 <channel>
393 <title>Test Blog</title>
394 <link>https://example.com</link>
395 <description>A test blog</description>
396 <item>
397 <title>Placeholder</title>
398 </item>
399 </channel>
400</rss>"#,
401 )
402 .unwrap();
403
404 let mut meta = HashMap::new();
405 let _ = meta.insert("title".to_string(), "Article One".to_string());
406 let _ = meta.insert(
407 "description".to_string(),
408 "First article desc".to_string(),
409 );
410 let _ = meta.insert(
411 "item_pub_date".to_string(),
412 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
413 );
414 let _ = meta.insert("author".to_string(), "Alice".to_string());
415 let _ = meta
416 .insert("banner".to_string(), "/images/banner.webp".to_string());
417 let _ = meta.insert("category".to_string(), "Technology".to_string());
418 let _ = meta.insert("tags".to_string(), "rust, web".to_string());
419 let _ = meta.insert(
420 "copyright".to_string(),
421 "Copyright 2026 Alice".to_string(),
422 );
423 write_meta_sidecar(tmp.path(), "article-one", &meta);
424
425 let ctx = make_atom_ctx(tmp.path());
426 RssAggregatePlugin.after_compile(&ctx).unwrap();
427
428 let result = fs::read_to_string(&rss_path).unwrap();
429
430 assert!(
431 result.contains(
432 "<enclosure url=\"https://example.com/images/banner.webp\""
433 ),
434 "Should have enclosure with base_url prefix: {result}"
435 );
436 assert!(
437 result.contains("type=\"image/webp\""),
438 "Should detect webp MIME type: {result}"
439 );
440 assert!(
441 result.contains("<category>Technology</category>"),
442 "Should have category element: {result}"
443 );
444 assert!(
445 result.contains("<category>rust</category>"),
446 "Should have tag category 'rust': {result}"
447 );
448 assert!(
449 result.contains("<category>web</category>"),
450 "Should have tag category 'web': {result}"
451 );
452 assert!(
453 result.contains("<language>en</language>"),
454 "Should have language element: {result}"
455 );
456 assert!(
457 result.contains("<lastBuildDate>"),
458 "Should have lastBuildDate: {result}"
459 );
460 assert!(
461 result.contains("<copyright>Copyright 2026 Alice</copyright>"),
462 "Should have copyright: {result}"
463 );
464
465 Ok(())
466 }
467
468 #[test]
469 fn test_rss_aggregate_banner_with_image_field() -> Result<()> {
470 let tmp = tempdir().unwrap();
471
472 let rss_path = tmp.path().join("rss.xml");
473 fs::write(
474 &rss_path,
475 r#"<?xml version="1.0" encoding="UTF-8"?>
476<rss version="2.0"><channel><title>T</title><link>https://example.com</link><description>D</description><item><title>X</title></item></channel></rss>"#,
477 ).unwrap();
478
479 let mut meta = HashMap::new();
480 let _ = meta.insert("title".to_string(), "Image Test".to_string());
481 let _ =
482 meta.insert("description".to_string(), "Testing image".to_string());
483 let _ = meta.insert(
484 "item_pub_date".to_string(),
485 "Mon, 01 Sep 2025 12:00:00 +0000".to_string(),
486 );
487 let _ = meta.insert("author".to_string(), "Bob".to_string());
488 let _ = meta.insert(
489 "image".to_string(),
490 "https://cdn.example.com/photo.png".to_string(),
491 );
492 write_meta_sidecar(tmp.path(), "img-test", &meta);
493
494 let ctx = make_atom_ctx(tmp.path());
495 RssAggregatePlugin.after_compile(&ctx).unwrap();
496
497 let result = fs::read_to_string(&rss_path).unwrap();
498 assert!(
499 result.contains("url=\"https://cdn.example.com/photo.png\""),
500 "Should use absolute image URL as-is: {result}"
501 );
502 assert!(
503 result.contains("type=\"image/png\""),
504 "Should detect png MIME type: {result}"
505 );
506 Ok(())
507 }
508
509 #[test]
510 fn test_rss_aggregate_jpeg_mime() -> Result<()> {
511 let tmp = tempdir().unwrap();
512
513 let rss_path = tmp.path().join("rss.xml");
514 fs::write(
515 &rss_path,
516 r#"<?xml version="1.0" encoding="UTF-8"?>
517<rss version="2.0"><channel><title>T</title><link>https://example.com</link><description>D</description><item><title>X</title></item></channel></rss>"#,
518 ).unwrap();
519
520 let mut meta = HashMap::new();
521 let _ = meta.insert("title".to_string(), "JPEG Test".to_string());
522 let _ = meta.insert("description".to_string(), "desc".to_string());
523 let _ = meta.insert(
524 "item_pub_date".to_string(),
525 "Mon, 01 Sep 2025 12:00:00 +0000".to_string(),
526 );
527 let _ = meta.insert("author".to_string(), "Carol".to_string());
528 let _ = meta.insert("banner".to_string(), "/img/photo.jpg".to_string());
529 write_meta_sidecar(tmp.path(), "jpeg-test", &meta);
530
531 let ctx = make_atom_ctx(tmp.path());
532 RssAggregatePlugin.after_compile(&ctx).unwrap();
533
534 let result = fs::read_to_string(&rss_path).unwrap();
535 assert!(
536 result.contains("type=\"image/jpeg\""),
537 "Should default to image/jpeg for .jpg: {result}"
538 );
539 Ok(())
540 }
541
542 #[test]
543 fn test_rss_aggregate_skips_multi_item() -> Result<()> {
544 let tmp = tempdir().unwrap();
545
546 let rss_path = tmp.path().join("rss.xml");
547 let original = r#"<?xml version="1.0" encoding="UTF-8"?>
548<rss version="2.0"><channel><title>T</title><link>x</link><description>D</description>
549<item><title>A</title></item>
550<item><title>B</title></item>
551</channel></rss>"#;
552 fs::write(&rss_path, original).unwrap();
553
554 let ctx = test_ctx(tmp.path());
555 RssAggregatePlugin.after_compile(&ctx).unwrap();
556
557 let result = fs::read_to_string(&rss_path).unwrap();
558 assert_eq!(result, original, "Should not modify feed with >1 items");
559 Ok(())
560 }
561
562 #[test]
563 fn test_collect_articles_empty_entries() {
564 let articles = collect_articles(&[], "https://example.com");
565 assert!(
566 articles.is_empty(),
567 "no meta entries should produce no articles"
568 );
569 }
570
571 #[test]
572 fn test_collect_articles_skips_empty_title() {
573 let mut meta = HashMap::new();
574 let _ =
575 meta.insert("description".to_string(), "no title here".to_string());
576 let entries = vec![("page".to_string(), meta)];
577 let articles = collect_articles(&entries, "https://example.com");
578 assert!(
579 articles.is_empty(),
580 "entries without title should be skipped"
581 );
582 }
583
584 #[test]
585 fn test_collect_articles_skips_empty_path() {
586 let mut meta = HashMap::new();
587 let _ = meta.insert("title".to_string(), "Has Title".to_string());
588 let entries = vec![(String::new(), meta)];
589 let articles = collect_articles(&entries, "https://example.com");
590 assert!(
591 articles.is_empty(),
592 "entries with empty path should be skipped"
593 );
594 }
595
596 #[test]
597 fn test_collect_articles_multiple_entries_sorted() {
598 let mut meta1 = HashMap::new();
599 let _ = meta1.insert("title".to_string(), "Older".to_string());
600 let _ = meta1.insert("description".to_string(), "old".to_string());
601 let _ = meta1.insert(
602 "item_pub_date".to_string(),
603 "Mon, 01 Jan 2024 00:00:00 +0000".to_string(),
604 );
605 let _ = meta1.insert("author".to_string(), "A".to_string());
606
607 let mut meta2 = HashMap::new();
608 let _ = meta2.insert("title".to_string(), "Newer".to_string());
609 let _ = meta2.insert("description".to_string(), "new".to_string());
610 let _ = meta2.insert(
611 "item_pub_date".to_string(),
612 "Wed, 01 Jan 2025 00:00:00 +0000".to_string(),
613 );
614 let _ = meta2.insert("author".to_string(), "B".to_string());
615
616 let entries = vec![
617 ("old-post".to_string(), meta1),
618 ("new-post".to_string(), meta2),
619 ];
620 let mut articles = collect_articles(&entries, "https://example.com");
621 assert_eq!(articles.len(), 2);
622
623 articles.sort_by(|a, b| b.0.cmp(&a.0));
625 assert!(
626 articles[0].1.contains("<title>Newer</title>"),
627 "newest article should sort first"
628 );
629 }
630
631 #[test]
632 fn test_collect_articles_xml_escapes_description() {
633 let mut meta = HashMap::new();
634 let _ = meta.insert("title".to_string(), "Escape Test".to_string());
635 let _ = meta.insert(
636 "description".to_string(),
637 "Use <b>bold</b> & \"quotes\"".to_string(),
638 );
639 let _ = meta.insert("author".to_string(), "X".to_string());
640 let entries = vec![("esc".to_string(), meta)];
641 let articles = collect_articles(&entries, "");
642 assert_eq!(articles.len(), 1);
643 let xml = &articles[0].1;
644 assert!(
645 xml.contains("<b>bold</b>"),
646 "angle brackets should be escaped: {xml}"
647 );
648 assert!(xml.contains("&"), "ampersands should be escaped: {xml}");
649 }
650
651 #[test]
656 fn test_collect_articles_rfc2822_date_passes_through_verbatim() {
657 let mut meta = HashMap::new();
658 let _ = meta.insert("title".to_string(), "RFC".to_string());
659 let _ = meta.insert(
662 "item_pub_date".to_string(),
663 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
664 );
665 let entries = vec![("rfc".to_string(), meta)];
666 let articles = collect_articles(&entries, "https://example.com");
667 let item = &articles[0].1;
668 assert!(
669 item.contains("<pubDate>Thu, 11 Apr 2026 06:06:06 +0000</pubDate>"),
670 "RFC 2822 input must pass through unchanged: {item}"
671 );
672 assert_eq!(articles[0].0, "2026-04-11T06:06:06+00:00");
673 }
674
675 #[test]
676 fn test_collect_articles_iso_date_becomes_rfc2822_pubdate() {
677 let mut meta = HashMap::new();
678 let _ = meta.insert("title".to_string(), "ISO".to_string());
679 let _ =
680 meta.insert("item_pub_date".to_string(), "2026-07-01".to_string());
681 let entries = vec![("iso".to_string(), meta)];
682 let articles = collect_articles(&entries, "https://example.com");
683 let item = &articles[0].1;
684 assert!(
685 item.contains("<pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate>"),
686 "ISO input should be normalised to RFC 2822: {item}"
687 );
688 assert_eq!(articles[0].0, "2026-07-01T00:00:00+00:00");
689 }
690
691 #[test]
692 fn test_collect_articles_long_form_date_becomes_rfc2822_pubdate() {
693 let mut meta = HashMap::new();
694 let _ = meta.insert("title".to_string(), "Long".to_string());
695 let _ = meta
696 .insert("item_pub_date".to_string(), "July 1, 2026".to_string());
697 let entries = vec![("long".to_string(), meta)];
698 let articles = collect_articles(&entries, "");
699 let item = &articles[0].1;
700 assert!(
701 item.contains("<pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate>"),
702 "long-form input should be normalised to RFC 2822: {item}"
703 );
704 }
705
706 #[test]
707 fn test_collect_articles_unparseable_date_passes_through() {
708 crate::test_support::init_logger();
709 let mut meta = HashMap::new();
710 let _ = meta.insert("title".to_string(), "Bad".to_string());
711 let _ =
712 meta.insert("item_pub_date".to_string(), "not-a-date".to_string());
713 let entries = vec![("bad".to_string(), meta)];
714 let articles = collect_articles(&entries, "");
715 let item = &articles[0].1;
716 assert!(
717 item.contains("<pubDate>not-a-date</pubDate>"),
718 "unparseable input keeps previous passthrough behaviour: {item}"
719 );
720 assert_eq!(articles[0].0, "not-a-date");
721 }
722
723 #[test]
724 fn test_build_rss_channel_minimal() {
725 let result = build_rss_channel(
726 "Title",
727 "https://x.example",
728 "Desc",
729 "https://x.example",
730 "",
731 "",
732 "",
733 "",
734 );
735 assert!(result.contains("<title>Title</title>"));
736 assert!(result.contains("<link>https://x.example</link>"));
737 assert!(result.contains("<description>Desc</description>"));
738 assert!(
739 !result.contains("<language>"),
740 "no language when empty string supplied"
741 );
742 assert!(
743 !result.contains("<lastBuildDate>"),
744 "no lastBuildDate when empty string supplied"
745 );
746 }
747
748 #[test]
749 fn test_build_rss_channel_with_all_extras() {
750 let result = build_rss_channel(
751 "T",
752 "L",
753 "D",
754 "https://x.example",
755 "en",
756 "Mon, 01 Jan 2024 00:00:00 +0000",
757 "Copyright 2024 X",
758 "<item><title>A</title></item>",
759 );
760 assert!(result.contains("<language>en</language>"));
761 assert!(result.contains(
762 "<lastBuildDate>Mon, 01 Jan 2024 00:00:00 +0000</lastBuildDate>"
763 ));
764 assert!(result.contains("<copyright>Copyright 2024 X</copyright>"));
765 assert!(result.contains("<item><title>A</title></item>"));
766 }
767
768 #[test]
769 fn test_extract_last_build_date_from_articles() {
770 let articles = vec![
771 ("2025".to_string(), "<item><pubDate>Mon, 01 Sep 2025 12:00:00 +0000</pubDate></item>".to_string()),
772 ("2024".to_string(), "<item><pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate></item>".to_string()),
773 ];
774 let date = extract_last_build_date(&articles);
775 assert_eq!(date, "Mon, 01 Sep 2025 12:00:00 +0000");
776 }
777
778 #[test]
779 fn test_extract_last_build_date_empty() {
780 let articles: Vec<(String, String)> = vec![];
781 let date = extract_last_build_date(&articles);
782 assert!(date.is_empty());
783 }
784
785 #[test]
786 fn test_rss_no_file_is_noop() -> Result<()> {
787 let tmp = tempdir().unwrap();
788 let ctx = test_ctx(tmp.path());
790 RssAggregatePlugin.after_compile(&ctx).unwrap();
791 assert!(!tmp.path().join("rss.xml").exists());
792 Ok(())
793 }
794
795 #[test]
801 fn test_rss_aggregate_keeps_page_with_numeric_sidecar_field() {
802 let tmp = tempdir().unwrap();
803 let rss_path = tmp.path().join("rss.xml");
804 fs::write(
805 &rss_path,
806 r#"<?xml version="1.0" encoding="UTF-8"?>
807<rss version="2.0"><channel><title>T</title><link>https://example.com</link><description>D</description><item><title>X</title></item></channel></rss>"#,
808 )
809 .unwrap();
810
811 let page_dir = tmp.path().join("counted");
816 fs::create_dir_all(&page_dir).unwrap();
817 fs::write(
818 page_dir.join("index.meta.json"),
819 r#"{"title":"Counted Post","description":"Has word_count","item_pub_date":"Thu, 11 Apr 2026 06:06:06 +0000","word_count":342}"#,
820 )
821 .unwrap();
822
823 let ctx = make_atom_ctx(tmp.path());
824 RssAggregatePlugin.after_compile(&ctx).unwrap();
825
826 let result = fs::read_to_string(&rss_path).unwrap();
827 assert!(
828 result.contains("<title>Counted Post</title>"),
829 "page with numeric sidecar field must appear in feed: {result}"
830 );
831 }
832
833 #[test]
838 fn test_collect_articles_relative_banner_without_base_url() {
839 let mut meta = HashMap::new();
840 let _ = meta.insert("title".to_string(), "Img".to_string());
841 let _ = meta.insert("banner".to_string(), "/img/pic.png".to_string());
842 let entries = vec![("img".to_string(), meta)];
843 let articles = collect_articles(&entries, "");
844 let item = &articles[0].1;
845 assert!(
846 item.contains("url=\"/img/pic.png\""),
847 "relative banner is kept verbatim when base_url empty: {item}"
848 );
849 }
850
851 #[test]
852 fn test_collect_articles_skips_blank_tag_segments() {
853 let mut meta = HashMap::new();
854 let _ = meta.insert("title".to_string(), "Tags".to_string());
855 let _ = meta.insert("tags".to_string(), "rust,, web".to_string());
856 let entries = vec![("tags".to_string(), meta)];
857 let articles = collect_articles(&entries, "");
858 let item = &articles[0].1;
859 assert!(item.contains("<category>rust</category>"));
860 assert!(item.contains("<category>web</category>"));
861 assert_eq!(
862 item.matches("<category>").count(),
863 2,
864 "blank tag segment must not emit an empty category: {item}"
865 );
866 }
867
868 #[test]
873 fn test_rss_aggregate_sorts_multiple_articles_newest_first() {
874 let tmp = tempdir().unwrap();
875 let rss_path = tmp.path().join("rss.xml");
876 fs::write(
877 &rss_path,
878 r#"<?xml version="1.0" encoding="UTF-8"?>
879<rss version="2.0"><channel><title>T</title><link>https://example.com</link><description>D</description><item><title>X</title></item></channel></rss>"#,
880 )
881 .unwrap();
882
883 let mut older = HashMap::new();
884 let _ = older.insert("title".to_string(), "Older".to_string());
885 let _ = older.insert(
886 "item_pub_date".to_string(),
887 "Mon, 01 Jan 2024 00:00:00 +0000".to_string(),
888 );
889 write_meta_sidecar(tmp.path(), "older", &older);
890
891 let mut newer = HashMap::new();
892 let _ = newer.insert("title".to_string(), "Newer".to_string());
893 let _ = newer.insert(
894 "item_pub_date".to_string(),
895 "Wed, 01 Jan 2025 00:00:00 +0000".to_string(),
896 );
897 write_meta_sidecar(tmp.path(), "newer", &newer);
898
899 let ctx = make_atom_ctx(tmp.path());
900 RssAggregatePlugin.after_compile(&ctx).unwrap();
901
902 let result = fs::read_to_string(&rss_path).unwrap();
903 let newer_pos = result.find("<title>Newer</title>").unwrap();
904 let older_pos = result.find("<title>Older</title>").unwrap();
905 assert!(newer_pos < older_pos, "newest article must sort first");
906 }
907
908 #[test]
913 fn test_rss_aggregate_falls_back_to_untitled_channel() {
914 let tmp = tempdir().unwrap();
915 let rss_path = tmp.path().join("rss.xml");
916 fs::write(
918 &rss_path,
919 "<rss version=\"2.0\"><channel><item><guid>g</guid></item></channel></rss>",
920 )
921 .unwrap();
922
923 let mut meta = HashMap::new();
924 let _ = meta.insert("title".to_string(), "Post".to_string());
925 write_meta_sidecar(tmp.path(), "post", &meta);
926
927 let ctx = make_atom_ctx(tmp.path());
928 RssAggregatePlugin.after_compile(&ctx).unwrap();
929
930 let result = fs::read_to_string(&rss_path).unwrap();
931 assert!(
932 result.contains("<title>Untitled</title>"),
933 "missing channel title falls back to Untitled: {result}"
934 );
935 assert!(
936 result.contains("<link>https://example.com</link>"),
937 "missing channel link falls back to base_url: {result}"
938 );
939 }
940
941 #[test]
946 fn test_after_compile_errors_on_invalid_utf8_rss() {
947 let tmp = tempdir().unwrap();
948 let rss_path = tmp.path().join("rss.xml");
949 fs::write(&rss_path, [0xFF, 0xFE, 0xFD]).unwrap();
950 let ctx = test_ctx(tmp.path());
951 let err = RssAggregatePlugin.after_compile(&ctx).unwrap_err();
952 assert!(format!("{err}").contains("rss.xml"));
953 }
954
955 #[test]
956 #[cfg(unix)]
957 fn test_after_compile_write_failure_on_readonly_rss() {
958 use std::os::unix::fs::PermissionsExt;
959 let tmp = tempdir().unwrap();
960 let rss_path = tmp.path().join("rss.xml");
961 fs::write(
962 &rss_path,
963 r#"<rss version="2.0"><channel><title>T</title><link>x</link><description>D</description><item><title>X</title></item></channel></rss>"#,
964 )
965 .unwrap();
966 let mut meta = HashMap::new();
967 let _ = meta.insert("title".to_string(), "Post".to_string());
968 write_meta_sidecar(tmp.path(), "post", &meta);
969 fs::set_permissions(&rss_path, fs::Permissions::from_mode(0o444))
970 .unwrap();
971
972 let ctx = make_atom_ctx(tmp.path());
973 let result = RssAggregatePlugin.after_compile(&ctx);
974 let _ =
975 fs::set_permissions(&rss_path, fs::Permissions::from_mode(0o644));
976 let err = result.unwrap_err();
977 assert!(format!("{err}").contains("rss.xml"));
978 }
979
980 #[test]
985 fn test_extract_language_with_empty_site_name() {
986 crate::test_support::init_logger();
987 let config = crate::cmd::SsgConfig {
988 listings: Vec::new(),
989 base_url: String::new(),
990 site_name: String::new(),
991 site_title: String::new(),
992 site_description: String::new(),
993 language: String::new(),
994 content_dir: std::path::PathBuf::from("c"),
995 output_dir: std::path::PathBuf::from("b"),
996 template_dir: std::path::PathBuf::from("t"),
997 theme: None,
998 serve_dir: None,
999 #[cfg(feature = "i18n")]
1000 i18n: None,
1001 cdn_prefix: None,
1002 og_image: None,
1003 image: crate::cmd::ImageConfig::default(),
1004 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
1005 agents: None,
1006 transitions: false,
1007 security: crate::cmd::SecurityConfig::default(),
1008 no_taxonomy_pages: false,
1009 };
1010 let ctx = PluginContext::with_config(
1011 Path::new("c"),
1012 Path::new("b"),
1013 Path::new("s"),
1014 Path::new("t"),
1015 config,
1016 );
1017 assert_eq!(extract_language(&ctx), "en");
1018 }
1019}