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 crate::util::head_dom::inject_before_head_close;
11use std::fs;
12use std::path::Path;
13
14#[derive(Debug, Clone, Copy)]
19pub struct AtomFeedPlugin;
20
21impl Plugin for AtomFeedPlugin {
22 fn name(&self) -> &'static str {
23 "atom-feed"
24 }
25
26 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
27 let mut meta_entries =
28 read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
29
30 if meta_entries.is_empty() {
34 let meta_dir = ctx.build_dir.join(".meta");
35 if meta_dir.exists() {
36 meta_entries =
37 read_meta_sidecars(&meta_dir).unwrap_or_default();
38 }
39 }
40
41 if meta_entries.is_empty() {
44 meta_entries = extract_entries_from_rss(&ctx.site_dir);
45 }
46
47 let base_url = ctx
48 .config
49 .as_ref()
50 .map(|c| c.base_url.trim_end_matches('/').to_string())
51 .unwrap_or_default();
52
53 let site_name = ctx
54 .config
55 .as_ref()
56 .map(|c| c.site_name.clone())
57 .unwrap_or_default();
58
59 let feed_title = if site_name.is_empty() {
60 "Untitled".to_string()
61 } else {
62 site_name
63 };
64
65 let mut articles = collect_atom_entries(&meta_entries, &base_url);
66 articles
74 .sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.id.cmp(&b.1.id)));
75 articles.truncate(50);
76
77 if articles.is_empty() {
78 return Ok(());
79 }
80
81 let feed_xml = build_atom_feed(&feed_title, &base_url, &articles);
82
83 let atom_path = ctx.site_dir.join("atom.xml");
84 fs::write(&atom_path, &feed_xml).with_path(&atom_path)?;
85
86 let atom_self_link = if base_url.is_empty() {
87 "atom.xml".to_string()
88 } else {
89 format!("{base_url}/atom.xml")
90 };
91 inject_atom_link(&ctx.site_dir, &atom_self_link)?;
92
93 log::info!(
94 "[atom-feed] Generated atom.xml with {} entries",
95 articles.len()
96 );
97 Ok(())
98 }
99}
100
101fn collect_atom_entries(
103 meta_entries: &[(String, std::collections::HashMap<String, String>)],
104 base_url: &str,
105) -> Vec<(String, AtomEntry)> {
106 let mut articles = Vec::new();
107 for (rel_path, meta) in meta_entries {
108 if let Some(entry) = build_atom_entry(rel_path, meta, base_url) {
109 articles.push(entry);
110 }
111 }
112 articles
113}
114
115fn build_atom_entry(
117 rel_path: &str,
118 meta: &std::collections::HashMap<String, String>,
119 base_url: &str,
120) -> Option<(String, AtomEntry)> {
121 if rel_path.is_empty() {
122 return None;
123 }
124
125 let title = meta.get("title").cloned().unwrap_or_default();
126 if title.is_empty() {
127 return None;
128 }
129
130 let description = meta.get("description").cloned().unwrap_or_default();
131 let pub_date = meta.get("item_pub_date").cloned().unwrap_or_default();
132 let author = meta.get("author").cloned().unwrap_or_default();
133
134 let link = if base_url.is_empty() {
135 format!("{rel_path}/")
136 } else {
137 format!("{base_url}/{rel_path}/")
138 };
139
140 let rfc3339 = match parse_flexible_date(&pub_date) {
146 Ok(dt) => dt.to_rfc3339(),
147 Err(err) => {
148 if !pub_date.is_empty() {
149 log::warn!(
150 "[atom-feed] 'item_pub_date' for '{rel_path}': {err}"
151 );
152 }
153 pub_date.clone()
154 }
155 };
156
157 Some((
158 rfc3339.clone(),
159 AtomEntry {
160 title,
161 link: link.clone(),
162 id: link,
163 updated: rfc3339.clone(),
164 published: rfc3339,
165 summary: description,
166 author,
167 },
168 ))
169}
170
171fn build_atom_feed(
173 feed_title: &str,
174 base_url: &str,
175 articles: &[(String, AtomEntry)],
176) -> String {
177 let feed_updated = &articles[0].0;
178 let entries_xml: String = articles
179 .iter()
180 .map(|(_, entry)| entry.to_xml())
181 .collect::<Vec<_>>()
182 .join("\n");
183
184 let atom_self_link = if base_url.is_empty() {
185 "atom.xml".to_string()
186 } else {
187 format!("{base_url}/atom.xml")
188 };
189
190 let feed_id = if base_url.is_empty() {
191 "/".to_string()
192 } else {
193 base_url.to_string()
194 };
195
196 format!(
197 r#"<?xml version="1.0" encoding="UTF-8"?>
198<feed xmlns="http://www.w3.org/2005/Atom">
199 <title>{feed_title}</title>
200 <link href="{atom_self_link}" rel="self" type="application/atom+xml"/>
201 <link href="{base_url}"/>
202 <id>{feed_id}</id>
203 <updated>{feed_updated}</updated>
204{entries_xml}
205</feed>
206"#,
207 feed_title = xml_escape(feed_title),
208 )
209}
210
211pub(super) struct AtomEntry {
213 pub title: String,
214 pub link: String,
215 pub id: String,
216 pub updated: String,
217 pub published: String,
218 pub summary: String,
219 pub author: String,
220}
221
222impl AtomEntry {
223 pub(super) fn to_xml(&self) -> String {
224 let author_name = if self.author.is_empty() {
225 "Unknown".to_string()
226 } else {
227 xml_escape(&self.author)
228 };
229 format!(
230 r#" <entry>
231 <title>{title}</title>
232 <link href="{link}"/>
233 <id>{id}</id>
234 <updated>{updated}</updated>
235 <published>{published}</published>
236 <summary>{summary}</summary>
237 <author><name>{author}</name></author>
238 </entry>"#,
239 title = xml_escape(&self.title),
240 link = xml_escape(&self.link),
241 id = xml_escape(&self.id),
242 updated = xml_escape(&self.updated),
243 published = xml_escape(&self.published),
244 summary = xml_escape(&self.summary),
245 author = author_name,
246 )
247 }
248}
249
250pub(super) fn extract_entries_from_rss(
253 site_dir: &Path,
254) -> Vec<(String, std::collections::HashMap<String, String>)> {
255 let rss_path = site_dir.join("rss.xml");
256 let Ok(rss_content) = fs::read_to_string(&rss_path) else {
257 return Vec::new();
258 };
259
260 if let Ok(doc) = oxml::parse(&rss_content) {
261 return extract_entries_from_rss_doc(&doc);
262 }
263
264 let mut entries = Vec::new();
265
266 let mut search_from = 0;
268 while let Some(item_start) = rss_content[search_from..].find("<item>") {
269 let abs_start = search_from + item_start;
270 let Some(item_end) = rss_content[abs_start..].find("</item>") else {
271 break;
272 };
273 let item = &rss_content[abs_start..abs_start + item_end + 7];
274
275 let mut meta = std::collections::HashMap::new();
276 if let Some(title) = extract_xml_tag(item, "title") {
277 let _ = meta.insert("title".to_string(), title);
278 }
279 if let Some(desc) = extract_xml_tag(item, "description") {
280 let _ = meta.insert("description".to_string(), desc);
281 }
282 if let Some(date) = extract_xml_tag(item, "pubDate") {
283 let _ = meta.insert("item_pub_date".to_string(), date);
284 }
285 if let Some(author) = extract_xml_tag(item, "author") {
286 let _ = meta.insert("author".to_string(), author);
287 }
288
289 let rel_path = extract_xml_tag(item, "link")
291 .map(|link| {
292 link.trim_end_matches('/')
293 .rsplit('/')
294 .next()
295 .unwrap_or("")
296 .to_string()
297 })
298 .unwrap_or_default();
299
300 if !rel_path.is_empty() && meta.contains_key("title") {
301 entries.push((rel_path, meta));
302 }
303
304 search_from = abs_start + item_end + 7;
305 }
306
307 entries
308}
309
310fn extract_entries_from_rss_doc(
312 doc: &oxml::Document,
313) -> Vec<(String, std::collections::HashMap<String, String>)> {
314 let mut entries = Vec::new();
315 let Some(root) = doc.root_element() else {
316 return entries;
317 };
318 let Some(&channel) = doc.children(root).iter().find(|&&child| {
319 doc.element_name(child)
320 .is_some_and(|n| n.local.eq_ignore_ascii_case("channel"))
321 }) else {
322 return entries;
323 };
324
325 for &item in doc.children(channel) {
326 if !doc
327 .element_name(item)
328 .is_some_and(|n| n.local.eq_ignore_ascii_case("item"))
329 {
330 continue;
331 }
332 let mut meta = std::collections::HashMap::new();
333 let mut link_val = None;
334
335 for &field in doc.children(item) {
336 let Some(name) = doc.element_name(field) else {
337 continue;
338 };
339 let text = element_text(doc, field);
340 let trimmed = text.trim();
341 if trimmed.is_empty() {
342 continue;
343 }
344 match name.local.as_str() {
345 "title" => {
346 let _ =
347 meta.insert("title".to_string(), xml_escape(trimmed));
348 }
349 "description" => {
350 let _ = meta
351 .insert("description".to_string(), xml_escape(trimmed));
352 }
353 "pubDate" => {
354 let _ = meta.insert(
355 "item_pub_date".to_string(),
356 xml_escape(trimmed),
357 );
358 }
359 "author" => {
360 let _ =
361 meta.insert("author".to_string(), xml_escape(trimmed));
362 }
363 "link" => {
364 link_val = Some(trimmed.to_string());
365 }
366 _ => {}
367 }
368 }
369
370 let rel_path = link_val
371 .map(|link| {
372 link.trim_end_matches('/')
373 .rsplit('/')
374 .next()
375 .unwrap_or("")
376 .to_string()
377 })
378 .unwrap_or_default();
379
380 if !rel_path.is_empty() && meta.contains_key("title") {
381 entries.push((rel_path, meta));
382 }
383 }
384
385 entries
386}
387
388fn element_text(doc: &oxml::Document, elem: oxml::NodeId) -> String {
390 let mut out = String::new();
391 for &child in doc.children(elem) {
392 if let Some(t) = doc.text_content(child) {
393 out.push_str(t);
394 }
395 }
396 out
397}
398
399fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
404 let open_plain = format!("<{tag}>");
406 let open_attr = format!("<{tag} ");
407 let close = format!("</{tag}>");
408
409 let (start, content_start) = if let Some(pos) = xml.find(&open_plain) {
410 (pos, pos + open_plain.len())
411 } else {
412 let pos = xml.find(&open_attr)?;
413 let gt = xml[pos..].find('>')?;
414 (pos, pos + gt + 1)
415 };
416
417 let _ = start; let end = xml[content_start..].find(&close)? + content_start;
419 let content = xml[content_start..end].trim();
420
421 let content = content
423 .strip_prefix("<![CDATA[")
424 .and_then(|s| s.strip_suffix("]]>"))
425 .unwrap_or(content);
426
427 let decoded = content
429 .replace("&", "&")
430 .replace("<", "<")
431 .replace(">", ">")
432 .replace(""", "\"")
433 .replace("'", "'");
434
435 let decoded = decoded.trim();
436 if decoded.is_empty() {
437 None
438 } else {
439 Some(xml_escape(decoded))
440 }
441}
442
443pub(super) fn inject_atom_link(
446 site_dir: &Path,
447 atom_url: &str,
448) -> Result<(), SsgError> {
449 let html_files = crate::walk::walk_files(site_dir, "html")
450 .map_err(|e| SsgError::io(e, site_dir))?;
451 for path in &html_files {
452 let html = fs::read_to_string(path).with_path(path)?;
453
454 if html.contains("application/atom+xml") {
455 continue;
456 }
457
458 let link_tag = format!(
459 " <link rel=\"alternate\" type=\"application/atom+xml\" title=\"Atom Feed\" href=\"{atom_url}\"/>\n"
460 );
461 let modified = inject_before_head_close(&html, &link_tag);
462 if modified != html {
463 fs::write(path, &modified).with_path(path)?;
464 }
465 }
466 Ok(())
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use crate::plugin::PluginContext;
473 use anyhow::Result;
474 use std::collections::HashMap;
475 use std::path::Path;
476 use tempfile::tempdir;
477
478 fn write_meta_sidecar(
479 dir: &Path,
480 slug: &str,
481 meta: &HashMap<String, String>,
482 ) {
483 let page_dir = dir.join(slug);
484 fs::create_dir_all(&page_dir).expect("create page dir");
485 let meta_path = page_dir.join("index.meta.json");
486 let json = serde_json::to_string(meta).expect("serialize meta");
487 fs::write(&meta_path, json).expect("write meta");
488 }
489
490 fn make_atom_ctx(site_dir: &Path) -> PluginContext {
491 crate::test_support::init_logger();
492 let config = crate::cmd::SsgConfig {
493 listings: Vec::new(),
494 base_url: "https://example.com".to_string(),
495 site_name: "Test Site".to_string(),
496 site_title: "Test Site".to_string(),
497 site_description: "A test site".to_string(),
498 language: "en".to_string(),
499 content_dir: std::path::PathBuf::from("content"),
500 output_dir: std::path::PathBuf::from("build"),
501 template_dir: std::path::PathBuf::from("templates"),
502 theme: None,
503 serve_dir: None,
504 #[cfg(feature = "i18n")]
505 i18n: None,
506 cdn_prefix: None,
507 og_image: None,
508 image: crate::cmd::ImageConfig::default(),
509 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
510 agents: None,
511 transitions: false,
512 security: crate::cmd::SecurityConfig::default(),
513 no_taxonomy_pages: false,
514 };
515 PluginContext::with_config(
516 Path::new("content"),
517 Path::new("build"),
518 site_dir,
519 Path::new("templates"),
520 config,
521 )
522 }
523
524 #[test]
525 fn test_atom_feed_valid_namespace_and_elements() -> Result<()> {
526 let tmp = tempdir().unwrap();
527
528 let mut meta = HashMap::new();
529 let _ = meta.insert("title".to_string(), "Hello World".to_string());
530 let _ =
531 meta.insert("description".to_string(), "A test post".to_string());
532 let _ = meta.insert(
533 "item_pub_date".to_string(),
534 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
535 );
536 let _ = meta.insert("author".to_string(), "Alice".to_string());
537 write_meta_sidecar(tmp.path(), "hello", &meta);
538
539 let ctx = make_atom_ctx(tmp.path());
540 AtomFeedPlugin.after_compile(&ctx).unwrap();
541
542 let atom_path = tmp.path().join("atom.xml");
543 assert!(atom_path.exists(), "atom.xml should be created");
544
545 let content = fs::read_to_string(&atom_path).unwrap();
546 assert!(
547 content.contains("xmlns=\"http://www.w3.org/2005/Atom\""),
548 "Missing Atom namespace"
549 );
550 assert!(content.contains("<feed"), "Missing <feed> element");
551 assert!(content.contains("<title>"), "Missing <title>");
552 assert!(content.contains("rel=\"self\""), "Missing self link");
553 assert!(content.contains("<id>"), "Missing <id>");
554 assert!(content.contains("<updated>"), "Missing <updated>");
555 assert!(content.contains("<entry>"), "Missing <entry>");
556 assert!(content.contains("<author>"), "Missing <author>");
557 assert!(
558 content.contains("<name>Alice</name>"),
559 "Missing author name"
560 );
561 assert!(content.contains("<summary>"), "Missing <summary>");
562 assert!(content.contains("<published>"), "Missing <published>");
563 Ok(())
564 }
565
566 #[test]
567 fn test_atom_feed_entry_count_matches() -> Result<()> {
568 let tmp = tempdir().unwrap();
569
570 for i in 0..5 {
571 let mut meta = HashMap::new();
572 let _ = meta.insert("title".to_string(), format!("Post {i}"));
573 let _ = meta.insert("description".to_string(), format!("Desc {i}"));
574 let _ = meta.insert(
575 "item_pub_date".to_string(),
576 format!("Thu, {:02} Apr 2026 06:06:06 +0000", 10 + i),
577 );
578 let _ = meta.insert("author".to_string(), "Bob".to_string());
579 write_meta_sidecar(tmp.path(), &format!("post-{i}"), &meta);
580 }
581
582 let ctx = make_atom_ctx(tmp.path());
583 AtomFeedPlugin.after_compile(&ctx).unwrap();
584
585 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
586 let entry_count = content.matches("<entry>").count();
587 assert_eq!(entry_count, 5, "Expected 5 entries, got {entry_count}");
588 Ok(())
589 }
590
591 #[test]
592 fn test_atom_feed_dates_are_rfc3339() -> Result<()> {
593 let tmp = tempdir().unwrap();
594
595 let mut meta = HashMap::new();
596 let _ = meta.insert("title".to_string(), "Date Test".to_string());
597 let _ =
598 meta.insert("description".to_string(), "Testing dates".to_string());
599 let _ = meta.insert(
600 "item_pub_date".to_string(),
601 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
602 );
603 let _ = meta.insert("author".to_string(), "Charlie".to_string());
604 write_meta_sidecar(tmp.path(), "datepost", &meta);
605
606 let ctx = make_atom_ctx(tmp.path());
607 AtomFeedPlugin.after_compile(&ctx).unwrap();
608
609 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
610 assert!(
611 content.contains("2026-04-11T06:06:06+00:00"),
612 "Expected RFC 3339 date in atom.xml, got:\n{content}"
613 );
614 Ok(())
615 }
616
617 #[test]
618 fn test_atom_feed_idempotent() -> Result<()> {
619 let tmp = tempdir().unwrap();
620
621 let mut meta = HashMap::new();
622 let _ = meta.insert("title".to_string(), "Idempotent".to_string());
623 let _ = meta.insert("description".to_string(), "Test".to_string());
624 let _ = meta.insert(
625 "item_pub_date".to_string(),
626 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
627 );
628 let _ = meta.insert("author".to_string(), "Dave".to_string());
629 write_meta_sidecar(tmp.path(), "idem", &meta);
630
631 let ctx = make_atom_ctx(tmp.path());
632 AtomFeedPlugin.after_compile(&ctx).unwrap();
633 let first = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
634
635 AtomFeedPlugin.after_compile(&ctx).unwrap();
636 let second = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
637
638 assert_eq!(first, second, "Atom feed should be idempotent");
639 Ok(())
640 }
641
642 #[test]
643 fn test_atom_feed_injects_link_into_html() -> Result<()> {
644 let tmp = tempdir().unwrap();
645
646 let mut meta = HashMap::new();
647 let _ = meta.insert("title".to_string(), "Link Test".to_string());
648 let _ = meta.insert("description".to_string(), "Test".to_string());
649 let _ = meta.insert(
650 "item_pub_date".to_string(),
651 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
652 );
653 let _ = meta.insert("author".to_string(), "Eve".to_string());
654 write_meta_sidecar(tmp.path(), "linktest", &meta);
655
656 let html_path = tmp.path().join("index.html");
657 fs::write(
658 &html_path,
659 "<html><head><title>Test</title></head><body></body></html>",
660 )
661 .unwrap();
662
663 let ctx = make_atom_ctx(tmp.path());
664 AtomFeedPlugin.after_compile(&ctx).unwrap();
665
666 let html = fs::read_to_string(&html_path).unwrap();
667 assert!(
668 html.contains("application/atom+xml"),
669 "HTML should have atom link tag"
670 );
671 Ok(())
672 }
673
674 #[test]
675 fn test_atom_plugin_registers() {
676 use crate::plugin::PluginManager;
677 let mut pm = PluginManager::new();
678 pm.register(AtomFeedPlugin);
679 assert_eq!(pm.len(), 1);
680 assert_eq!(pm.names(), vec!["atom-feed"]);
681 }
682
683 #[test]
684 fn test_atom_feed_sorts_descending() -> Result<()> {
685 let tmp = tempdir().unwrap();
686
687 let mut meta_old = HashMap::new();
688 let _ = meta_old.insert("title".to_string(), "Old Post".to_string());
689 let _ = meta_old.insert("description".to_string(), "old".to_string());
690 let _ = meta_old.insert(
691 "item_pub_date".to_string(),
692 "Mon, 01 Jan 2025 00:00:00 +0000".to_string(),
693 );
694 let _ = meta_old.insert("author".to_string(), "Alice".to_string());
695 write_meta_sidecar(tmp.path(), "old-post", &meta_old);
696
697 let mut meta_new = HashMap::new();
698 let _ = meta_new.insert("title".to_string(), "New Post".to_string());
699 let _ = meta_new.insert("description".to_string(), "new".to_string());
700 let _ = meta_new.insert(
701 "item_pub_date".to_string(),
702 "Fri, 11 Apr 2026 12:00:00 +0000".to_string(),
703 );
704 let _ = meta_new.insert("author".to_string(), "Bob".to_string());
705 write_meta_sidecar(tmp.path(), "new-post", &meta_new);
706
707 let ctx = make_atom_ctx(tmp.path());
708 AtomFeedPlugin.after_compile(&ctx).unwrap();
709
710 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
711 let first_entry_pos = content.find("<entry>").unwrap();
712 let new_title_pos = content.find("New Post").unwrap();
713 let old_title_pos = content.find("Old Post").unwrap();
714 assert!(
715 new_title_pos < old_title_pos,
716 "Newer post should come first"
717 );
718 assert!(
719 new_title_pos > first_entry_pos,
720 "Title should be inside an entry"
721 );
722 Ok(())
723 }
724
725 #[test]
726 fn test_atom_feed_empty_author_shows_unknown() -> Result<()> {
727 let tmp = tempdir().unwrap();
728
729 let mut meta = HashMap::new();
730 let _ = meta.insert("title".to_string(), "No Author".to_string());
731 let _ = meta.insert("description".to_string(), "test".to_string());
732 let _ = meta.insert(
733 "item_pub_date".to_string(),
734 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
735 );
736 write_meta_sidecar(tmp.path(), "no-author", &meta);
737
738 let ctx = make_atom_ctx(tmp.path());
739 AtomFeedPlugin.after_compile(&ctx).unwrap();
740
741 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
742 assert!(
743 content.contains("<name>Unknown</name>"),
744 "Empty author should show 'Unknown': {content}"
745 );
746 Ok(())
747 }
748
749 #[test]
750 fn test_atom_feed_skips_empty_title() -> Result<()> {
751 let tmp = tempdir().unwrap();
752
753 let mut meta = HashMap::new();
754 let _ = meta.insert("title".to_string(), String::new());
755 let _ = meta.insert("description".to_string(), "test".to_string());
756 let _ = meta.insert(
757 "item_pub_date".to_string(),
758 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
759 );
760 write_meta_sidecar(tmp.path(), "no-title", &meta);
761
762 let ctx = make_atom_ctx(tmp.path());
763 AtomFeedPlugin.after_compile(&ctx).unwrap();
764
765 let atom_path = tmp.path().join("atom.xml");
766 assert!(
767 !atom_path.exists(),
768 "Should not create atom.xml when all entries have empty titles"
769 );
770 Ok(())
771 }
772
773 #[test]
774 fn test_atom_feed_xml_escapes_content() -> Result<()> {
775 let tmp = tempdir().unwrap();
776
777 let mut meta = HashMap::new();
778 let _ = meta
779 .insert("title".to_string(), "Tom & Jerry <friends>".to_string());
780 let _ = meta
781 .insert("description".to_string(), "A \"great\" show".to_string());
782 let _ = meta.insert(
783 "item_pub_date".to_string(),
784 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
785 );
786 let _ = meta.insert("author".to_string(), "O'Brien".to_string());
787 write_meta_sidecar(tmp.path(), "escape-test", &meta);
788
789 let ctx = make_atom_ctx(tmp.path());
790 AtomFeedPlugin.after_compile(&ctx).unwrap();
791
792 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
793 assert!(content.contains("Tom & Jerry"), "& should be escaped");
794 assert!(
795 content.contains("<friends>"),
796 "< and > should be escaped"
797 );
798 assert!(
799 content.contains(""great""),
800 "quotes should be escaped"
801 );
802 assert!(
803 content.contains("O'Brien"),
804 "apostrophe should be escaped"
805 );
806 Ok(())
807 }
808
809 #[test]
814 fn test_atom_entry_to_xml() {
815 let entry = AtomEntry {
816 title: "Test Post".to_string(),
817 link: "https://example.com/test/".to_string(),
818 id: "https://example.com/test/".to_string(),
819 updated: "2026-04-11T06:06:06+00:00".to_string(),
820 published: "2026-04-11T06:06:06+00:00".to_string(),
821 summary: "A test summary".to_string(),
822 author: "Alice".to_string(),
823 };
824 let xml = entry.to_xml();
825 assert!(xml.contains("<entry>"));
826 assert!(xml.contains("</entry>"));
827 assert!(xml.contains("<title>Test Post</title>"));
828 assert!(xml.contains("href=\"https://example.com/test/\""));
829 assert!(xml.contains("<name>Alice</name>"));
830 assert!(xml.contains("<summary>A test summary</summary>"));
831 }
832
833 #[test]
834 fn test_atom_entry_empty_author() {
835 let entry = AtomEntry {
836 title: "No Author".to_string(),
837 link: "https://example.com/".to_string(),
838 id: "https://example.com/".to_string(),
839 updated: "2026-01-01T00:00:00+00:00".to_string(),
840 published: "2026-01-01T00:00:00+00:00".to_string(),
841 summary: String::new(),
842 author: String::new(),
843 };
844 let xml = entry.to_xml();
845 assert!(
846 xml.contains("<name>Unknown</name>"),
847 "Empty author should show 'Unknown'"
848 );
849 }
850
851 #[test]
856 fn test_inject_atom_link_adds_tag() -> Result<()> {
857 let tmp = tempdir().unwrap();
858 let html_path = tmp.path().join("page.html");
859 fs::write(
860 &html_path,
861 "<html><head><title>Test</title></head><body></body></html>",
862 )
863 .unwrap();
864
865 inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
866
867 let result = fs::read_to_string(&html_path).unwrap();
868 assert!(
869 result.contains("application/atom+xml"),
870 "Should inject atom link: {result}"
871 );
872 assert!(
873 result.contains("href=\"https://example.com/atom.xml\""),
874 "Should have correct href: {result}"
875 );
876 Ok(())
877 }
878
879 #[test]
880 fn test_inject_atom_link_idempotent() -> Result<()> {
881 let tmp = tempdir().unwrap();
882 let html_path = tmp.path().join("page.html");
883 fs::write(
884 &html_path,
885 "<html><head><title>Test</title></head><body></body></html>",
886 )
887 .unwrap();
888
889 inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
890 let first = fs::read_to_string(&html_path).unwrap();
891
892 inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
893 let second = fs::read_to_string(&html_path).unwrap();
894
895 assert_eq!(first, second, "inject_atom_link should be idempotent");
896 assert_eq!(
897 second.matches("application/atom+xml").count(),
898 1,
899 "Should have exactly one atom link"
900 );
901 Ok(())
902 }
903
904 #[test]
905 fn test_inject_atom_link_no_head() -> Result<()> {
906 let tmp = tempdir().unwrap();
907 let html_path = tmp.path().join("nohead.html");
908 fs::write(&html_path, "<html><body>No head</body></html>").unwrap();
909
910 inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
911
912 let result = fs::read_to_string(&html_path).unwrap();
913 assert!(
914 !result.contains("application/atom+xml"),
915 "Should not inject when there is no </head>"
916 );
917 Ok(())
918 }
919
920 #[test]
925 fn test_atom_feed_plugin_name() {
926 let plugin = AtomFeedPlugin;
927 assert_eq!(plugin.name(), "atom-feed");
928 }
929
930 #[test]
931 fn test_atom_feed_plugin_debug() {
932 let plugin = AtomFeedPlugin;
933 let debug = format!("{plugin:?}");
934 assert!(debug.contains("AtomFeedPlugin"));
935 }
936
937 #[test]
938 fn test_atom_feed_plugin_clone_copy() {
939 let plugin = AtomFeedPlugin;
940 let cloned = plugin;
941 assert_eq!(cloned.name(), "atom-feed");
942 }
943
944 #[test]
949 fn test_atom_feed_empty_site_dir() -> Result<()> {
950 let tmp = tempdir().unwrap();
951 let ctx = make_atom_ctx(tmp.path());
953 AtomFeedPlugin.after_compile(&ctx).unwrap();
954
955 let atom_path = tmp.path().join("atom.xml");
956 assert!(
957 !atom_path.exists(),
958 "Should not create atom.xml with no entries"
959 );
960 Ok(())
961 }
962
963 #[test]
968 fn test_atom_feed_falls_back_to_rss_xml() -> Result<()> {
969 let tmp = tempdir().unwrap();
970 let rss_content = r#"<?xml version="1.0"?>
972<rss version="2.0">
973<channel>
974<title>Test</title>
975<item>
976<title>From RSS</title>
977<description>Extracted from RSS</description>
978<link>https://example.com/rss-post/</link>
979<pubDate>Thu, 11 Apr 2026 06:06:06 +0000</pubDate>
980<author>Alice</author>
981</item>
982</channel>
983</rss>"#;
984 fs::write(tmp.path().join("rss.xml"), rss_content).unwrap();
985
986 let ctx = make_atom_ctx(tmp.path());
987 AtomFeedPlugin.after_compile(&ctx).unwrap();
988
989 let atom_path = tmp.path().join("atom.xml");
990 assert!(atom_path.exists(), "Should create atom.xml from rss.xml");
991 let content = fs::read_to_string(&atom_path).unwrap();
992 assert!(
993 content.contains("From RSS"),
994 "Should contain entry from rss.xml"
995 );
996 Ok(())
997 }
998
999 #[test]
1000 fn test_atom_feed_rss_multiple_items() -> Result<()> {
1001 let tmp = tempdir().unwrap();
1002 let rss_content = r#"<?xml version="1.0"?>
1003<rss version="2.0">
1004<channel>
1005<title>Test</title>
1006<item>
1007<title>Post A</title>
1008<description>Desc A</description>
1009<link>https://example.com/post-a/</link>
1010<pubDate>Thu, 10 Apr 2026 00:00:00 +0000</pubDate>
1011</item>
1012<item>
1013<title>Post B</title>
1014<description>Desc B</description>
1015<link>https://example.com/post-b/</link>
1016<pubDate>Fri, 11 Apr 2026 00:00:00 +0000</pubDate>
1017</item>
1018</channel>
1019</rss>"#;
1020 fs::write(tmp.path().join("rss.xml"), rss_content).unwrap();
1021
1022 let ctx = make_atom_ctx(tmp.path());
1023 AtomFeedPlugin.after_compile(&ctx).unwrap();
1024
1025 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
1026 assert!(content.contains("Post A"));
1027 assert!(content.contains("Post B"));
1028 let entry_count = content.matches("<entry>").count();
1029 assert_eq!(entry_count, 2);
1030 Ok(())
1031 }
1032
1033 #[test]
1034 fn test_atom_feed_rss_with_cdata() -> Result<()> {
1035 let tmp = tempdir().unwrap();
1036 let rss_content = r#"<?xml version="1.0"?>
1037<rss version="2.0">
1038<channel>
1039<title>Test</title>
1040<item>
1041<title><![CDATA[CDATA Title]]></title>
1042<description><![CDATA[CDATA Description]]></description>
1043<link>https://example.com/cdata-post/</link>
1044<pubDate>Thu, 11 Apr 2026 06:06:06 +0000</pubDate>
1045</item>
1046</channel>
1047</rss>"#;
1048 fs::write(tmp.path().join("rss.xml"), rss_content).unwrap();
1049
1050 let ctx = make_atom_ctx(tmp.path());
1051 AtomFeedPlugin.after_compile(&ctx).unwrap();
1052
1053 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
1054 assert!(content.contains("CDATA Title"), "Should unwrap CDATA");
1055 Ok(())
1056 }
1057
1058 #[test]
1063 fn test_extract_xml_tag_simple() {
1064 let xml = "<item><title>Hello</title></item>";
1065 assert_eq!(extract_xml_tag(xml, "title"), Some("Hello".to_string()));
1066 }
1067
1068 #[test]
1069 fn test_extract_xml_tag_with_attributes() {
1070 let xml = r#"<item><link href="http://example.com">text</link></item>"#;
1071 assert_eq!(extract_xml_tag(xml, "link"), Some("text".to_string()));
1072 }
1073
1074 #[test]
1075 fn test_extract_xml_tag_missing() {
1076 let xml = "<item><title>Hello</title></item>";
1077 assert_eq!(extract_xml_tag(xml, "author"), None);
1078 }
1079
1080 #[test]
1081 fn test_extract_xml_tag_empty_content() {
1082 let xml = "<item><title></title></item>";
1083 assert_eq!(extract_xml_tag(xml, "title"), None);
1084 }
1085
1086 #[test]
1087 fn test_extract_xml_tag_cdata() {
1088 let xml = "<item><title><![CDATA[My Title]]></title></item>";
1089 assert_eq!(extract_xml_tag(xml, "title"), Some("My Title".to_string()));
1090 }
1091
1092 #[test]
1093 fn test_extract_xml_tag_decodes_entities() {
1094 let xml = "<item><title>Tom & Jerry</title></item>";
1095 let result = extract_xml_tag(xml, "title").unwrap();
1097 assert!(
1098 result.contains("Tom") && result.contains("Jerry"),
1099 "Should contain decoded text: {result}"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_extract_xml_tag_whitespace() {
1105 let xml = "<item><title> Hello World </title></item>";
1106 assert_eq!(
1107 extract_xml_tag(xml, "title"),
1108 Some("Hello World".to_string())
1109 );
1110 }
1111
1112 #[test]
1117 fn test_extract_entries_from_rss_no_file() {
1118 let tmp = tempdir().unwrap();
1119 let entries = extract_entries_from_rss(tmp.path());
1120 assert!(entries.is_empty(), "{:?}", entries);
1121 }
1122
1123 #[test]
1124 fn test_extract_entries_from_rss_empty_rss() {
1125 let tmp = tempdir().unwrap();
1126 fs::write(
1127 tmp.path().join("rss.xml"),
1128 r#"<?xml version="1.0"?><rss><channel></channel></rss>"#,
1129 )
1130 .unwrap();
1131 let entries = extract_entries_from_rss(tmp.path());
1132 assert!(entries.is_empty(), "{:?}", entries);
1133 }
1134
1135 #[test]
1136 fn test_extract_entries_from_rss_item_without_title() {
1137 let tmp = tempdir().unwrap();
1138 let rss = r#"<?xml version="1.0"?>
1139<rss><channel>
1140<item>
1141<description>No title item</description>
1142<link>https://example.com/no-title/</link>
1143</item>
1144</channel></rss>"#;
1145 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1146 let entries = extract_entries_from_rss(tmp.path());
1147 assert!(entries.is_empty(), "{:?}", entries);
1151 }
1152
1153 #[test]
1154 fn test_extract_entries_from_rss_item_without_link() {
1155 let tmp = tempdir().unwrap();
1156 let rss = r#"<?xml version="1.0"?>
1157<rss><channel>
1158<item>
1159<title>No Link</title>
1160<description>No link item</description>
1161</item>
1162</channel></rss>"#;
1163 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1164 let entries = extract_entries_from_rss(tmp.path());
1165 assert!(entries.is_empty(), "{:?}", entries);
1167 }
1168
1169 #[test]
1174 fn test_build_atom_entry_empty_rel_path() {
1175 let meta = HashMap::new();
1176 assert!(build_atom_entry("", &meta, "https://example.com").is_none());
1177 }
1178
1179 #[test]
1180 fn test_build_atom_entry_empty_title() {
1181 let mut meta = HashMap::new();
1182 let _ = meta.insert("title".to_string(), String::new());
1183 assert!(
1184 build_atom_entry("page", &meta, "https://example.com").is_none()
1185 );
1186 }
1187
1188 #[test]
1189 fn test_build_atom_entry_minimal() {
1190 let mut meta = HashMap::new();
1191 let _ = meta.insert("title".to_string(), "Test".to_string());
1192 let result = build_atom_entry("page", &meta, "https://example.com");
1193 assert!(result.is_some());
1194 let (date_key, entry) = result.unwrap();
1195 assert_eq!(entry.title, "Test");
1196 assert!(entry.link.contains("example.com/page/"));
1197 assert!(entry.author.is_empty(), "{:?}", entry.author);
1198 assert!(date_key.is_empty(), "{:?}", date_key);
1200 }
1201
1202 #[test]
1203 fn test_build_atom_entry_empty_base_url() {
1204 let mut meta = HashMap::new();
1205 let _ = meta.insert("title".to_string(), "Test".to_string());
1206 let result = build_atom_entry("page", &meta, "");
1207 assert!(result.is_some());
1208 let (_, entry) = result.unwrap();
1209 assert_eq!(entry.link, "page/");
1210 }
1211
1212 #[test]
1213 fn test_build_atom_entry_with_all_fields() {
1214 let mut meta = HashMap::new();
1215 let _ = meta.insert("title".to_string(), "Full Entry".to_string());
1216 let _ =
1217 meta.insert("description".to_string(), "A description".to_string());
1218 let _ = meta.insert(
1219 "item_pub_date".to_string(),
1220 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1221 );
1222 let _ = meta.insert("author".to_string(), "Alice".to_string());
1223
1224 let result = build_atom_entry("full", &meta, "https://example.com");
1225 let (date_key, entry) = result.unwrap();
1226 assert_eq!(entry.title, "Full Entry");
1227 assert_eq!(entry.summary, "A description");
1228 assert_eq!(entry.author, "Alice");
1229 assert!(date_key.contains("2026"));
1230 }
1231
1232 #[test]
1233 fn test_build_atom_entry_unparseable_date() {
1234 let mut meta = HashMap::new();
1235 let _ = meta.insert("title".to_string(), "Bad Date".to_string());
1236 let _ =
1237 meta.insert("item_pub_date".to_string(), "not-a-date".to_string());
1238
1239 let result = build_atom_entry("baddate", &meta, "https://example.com");
1240 let (date_key, _) = result.unwrap();
1241 assert_eq!(date_key, "not-a-date");
1243 }
1244
1245 #[test]
1250 fn test_build_atom_entry_iso_date() {
1251 let mut meta = HashMap::new();
1252 let _ = meta.insert("title".to_string(), "ISO Date".to_string());
1253 let _ =
1254 meta.insert("item_pub_date".to_string(), "2026-07-01".to_string());
1255
1256 let result = build_atom_entry("isodate", &meta, "https://example.com");
1257 let (date_key, entry) = result.unwrap();
1258 assert_eq!(date_key, "2026-07-01T00:00:00+00:00");
1259 assert_eq!(entry.updated, "2026-07-01T00:00:00+00:00");
1260 assert_eq!(entry.published, "2026-07-01T00:00:00+00:00");
1261 }
1262
1263 #[test]
1264 fn test_build_atom_entry_long_form_date() {
1265 let mut meta = HashMap::new();
1266 let _ = meta.insert("title".to_string(), "Long Form".to_string());
1267 let _ = meta
1268 .insert("item_pub_date".to_string(), "July 1, 2026".to_string());
1269
1270 let result = build_atom_entry("longform", &meta, "https://example.com");
1271 let (date_key, entry) = result.unwrap();
1272 assert_eq!(date_key, "2026-07-01T00:00:00+00:00");
1273 assert_eq!(entry.updated, "2026-07-01T00:00:00+00:00");
1274 }
1275
1276 #[test]
1277 fn test_build_atom_entry_iso_datetime_normalised() {
1278 let mut meta = HashMap::new();
1279 let _ = meta.insert("title".to_string(), "ISO DT".to_string());
1280 let _ = meta.insert(
1281 "item_pub_date".to_string(),
1282 "2026-07-01T07:07:07Z".to_string(),
1283 );
1284
1285 let result = build_atom_entry("isodt", &meta, "https://example.com");
1286 let (date_key, _) = result.unwrap();
1287 assert_eq!(date_key, "2026-07-01T07:07:07+00:00");
1288 }
1289
1290 #[test]
1295 fn test_collect_atom_entries_empty() {
1296 let entries: Vec<(String, HashMap<String, String>)> = vec![];
1297 let result = collect_atom_entries(&entries, "https://example.com");
1298 assert!(result.is_empty());
1299 }
1300
1301 #[test]
1302 fn test_collect_atom_entries_filters_invalid() {
1303 let mut meta1 = HashMap::new();
1304 let _ = meta1.insert("title".to_string(), "Valid".to_string());
1305 let mut meta2 = HashMap::new();
1306 let _ = meta2.insert("title".to_string(), String::new()); let entries =
1309 vec![("valid".to_string(), meta1), ("invalid".to_string(), meta2)];
1310 let result = collect_atom_entries(&entries, "https://example.com");
1311 assert_eq!(result.len(), 1);
1312 assert_eq!(result[0].1.title, "Valid");
1313 }
1314
1315 #[test]
1320 fn test_build_atom_feed_structure() {
1321 let entry = AtomEntry {
1322 title: "Feed Test".to_string(),
1323 link: "https://example.com/test/".to_string(),
1324 id: "https://example.com/test/".to_string(),
1325 updated: "2026-04-11T00:00:00+00:00".to_string(),
1326 published: "2026-04-11T00:00:00+00:00".to_string(),
1327 summary: "Summary".to_string(),
1328 author: "Bob".to_string(),
1329 };
1330 let articles = vec![("2026-04-11T00:00:00+00:00".to_string(), entry)];
1331 let xml = build_atom_feed("My Feed", "https://example.com", &articles);
1332 assert!(xml.starts_with("<?xml"));
1333 assert!(xml.contains("xmlns=\"http://www.w3.org/2005/Atom\""));
1334 assert!(xml.contains("<title>My Feed</title>"));
1335 assert!(xml.contains("rel=\"self\""));
1336 assert!(xml.contains("https://example.com/atom.xml"));
1337 assert!(xml.contains("<id>https://example.com</id>"));
1338 assert!(xml.contains("Feed Test"));
1339 }
1340
1341 #[test]
1342 fn test_build_atom_feed_empty_base_url() {
1343 let entry = AtomEntry {
1344 title: "Test".to_string(),
1345 link: "test/".to_string(),
1346 id: "test/".to_string(),
1347 updated: "2026-01-01T00:00:00+00:00".to_string(),
1348 published: "2026-01-01T00:00:00+00:00".to_string(),
1349 summary: String::new(),
1350 author: String::new(),
1351 };
1352 let articles = vec![("2026-01-01T00:00:00+00:00".to_string(), entry)];
1353 let xml = build_atom_feed("Untitled", "", &articles);
1354 assert!(xml.contains("<id>/</id>"));
1355 assert!(xml.contains("href=\"atom.xml\""));
1356 }
1357
1358 #[test]
1359 fn test_build_atom_feed_xml_escapes_title() {
1360 let entry = AtomEntry {
1361 title: "A".to_string(),
1362 link: "a/".to_string(),
1363 id: "a/".to_string(),
1364 updated: "2026-01-01T00:00:00+00:00".to_string(),
1365 published: "2026-01-01T00:00:00+00:00".to_string(),
1366 summary: String::new(),
1367 author: String::new(),
1368 };
1369 let articles = vec![("2026-01-01T00:00:00+00:00".to_string(), entry)];
1370 let xml = build_atom_feed(
1371 "Tom & Jerry's <Feed>",
1372 "https://example.com",
1373 &articles,
1374 );
1375 assert!(xml.contains("Tom & Jerry"));
1376 assert!(xml.contains("<Feed>"));
1377 }
1378
1379 #[test]
1384 fn test_atom_entry_to_xml_escapes_all_fields() {
1385 let entry = AtomEntry {
1386 title: "A & B".to_string(),
1387 link: "https://example.com/a&b/".to_string(),
1388 id: "https://example.com/a&b/".to_string(),
1389 updated: "2026-01-01".to_string(),
1390 published: "2026-01-01".to_string(),
1391 summary: "\"quoted\" <summary>".to_string(),
1392 author: "O'Brien".to_string(),
1393 };
1394 let xml = entry.to_xml();
1395 assert!(xml.contains("A & B"), "Title not escaped");
1396 assert!(
1397 xml.contains(""quoted""),
1398 "Summary quotes not escaped"
1399 );
1400 assert!(
1401 xml.contains("<summary>"),
1402 "Summary angles not escaped"
1403 );
1404 assert!(
1405 xml.contains("O'Brien"),
1406 "Author apostrophe not escaped"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_atom_entry_to_xml_empty_summary() {
1412 let entry = AtomEntry {
1413 title: "No Summary".to_string(),
1414 link: "https://example.com/".to_string(),
1415 id: "https://example.com/".to_string(),
1416 updated: "2026-01-01".to_string(),
1417 published: "2026-01-01".to_string(),
1418 summary: String::new(),
1419 author: "Alice".to_string(),
1420 };
1421 let xml = entry.to_xml();
1422 assert!(xml.contains("<summary></summary>"));
1423 }
1424
1425 #[test]
1430 fn test_atom_feed_untitled_site() -> Result<()> {
1431 let tmp = tempdir().unwrap();
1432
1433 let mut meta = HashMap::new();
1434 let _ = meta.insert("title".to_string(), "Post".to_string());
1435 let _ = meta.insert("description".to_string(), "desc".to_string());
1436 let _ = meta.insert(
1437 "item_pub_date".to_string(),
1438 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1439 );
1440 write_meta_sidecar(tmp.path(), "post", &meta);
1441
1442 let config = crate::cmd::SsgConfig {
1444 listings: Vec::new(),
1445 base_url: "https://example.com".to_string(),
1446 site_name: String::new(),
1447 site_title: String::new(),
1448 site_description: String::new(),
1449 language: "en".to_string(),
1450 content_dir: std::path::PathBuf::from("content"),
1451 output_dir: std::path::PathBuf::from("build"),
1452 template_dir: std::path::PathBuf::from("templates"),
1453 theme: None,
1454 serve_dir: None,
1455 #[cfg(feature = "i18n")]
1456 i18n: None,
1457 cdn_prefix: None,
1458 og_image: None,
1459 image: crate::cmd::ImageConfig::default(),
1460 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
1461 agents: None,
1462 transitions: false,
1463 security: crate::cmd::SecurityConfig::default(),
1464 no_taxonomy_pages: false,
1465 };
1466 let ctx = PluginContext::with_config(
1467 Path::new("content"),
1468 Path::new("build"),
1469 tmp.path(),
1470 Path::new("templates"),
1471 config,
1472 );
1473
1474 AtomFeedPlugin.after_compile(&ctx).unwrap();
1475
1476 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
1477 assert!(
1478 content.contains("<title>Untitled</title>"),
1479 "Empty site_name should produce 'Untitled'"
1480 );
1481 Ok(())
1482 }
1483
1484 #[test]
1485 fn test_atom_feed_no_config() -> Result<()> {
1486 let tmp = tempdir().unwrap();
1487
1488 let mut meta = HashMap::new();
1489 let _ = meta.insert("title".to_string(), "Post".to_string());
1490 let _ = meta.insert("description".to_string(), "desc".to_string());
1491 let _ = meta.insert(
1492 "item_pub_date".to_string(),
1493 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1494 );
1495 write_meta_sidecar(tmp.path(), "post", &meta);
1496
1497 let ctx = PluginContext::new(
1499 Path::new("content"),
1500 Path::new("build"),
1501 tmp.path(),
1502 Path::new("templates"),
1503 );
1504
1505 AtomFeedPlugin.after_compile(&ctx).unwrap();
1506
1507 let atom_path = tmp.path().join("atom.xml");
1508 assert!(atom_path.exists(), "atom.xml must be generated");
1509 let content = fs::read_to_string(&atom_path).unwrap();
1510 assert!(
1511 content.contains("<title>Untitled</title>"),
1512 "No config should produce 'Untitled'"
1513 );
1514 Ok(())
1515 }
1516
1517 #[test]
1522 fn test_atom_entry_iso8601_date_passthrough() {
1523 let mut meta = HashMap::new();
1524 let _ = meta.insert("title".to_string(), "ISO Date".to_string());
1525 let _ = meta.insert(
1526 "item_pub_date".to_string(),
1527 "2026-04-11T12:00:00+00:00".to_string(),
1528 );
1529 let result = build_atom_entry("iso", &meta, "https://example.com");
1530 let (date_key, _) = result.unwrap();
1531 assert!(date_key.contains("2026"));
1533 }
1534
1535 #[test]
1536 fn test_atom_entry_empty_date() {
1537 let mut meta = HashMap::new();
1538 let _ = meta.insert("title".to_string(), "No Date".to_string());
1539 let result = build_atom_entry("nodate", &meta, "https://example.com");
1540 let (date_key, entry) = result.unwrap();
1541 assert!(date_key.is_empty(), "{:?}", date_key);
1542 assert!(entry.published.is_empty(), "{:?}", entry.published);
1543 }
1544
1545 #[test]
1550 fn test_atom_feed_truncates_at_50() -> Result<()> {
1551 let tmp = tempdir().unwrap();
1552
1553 for i in 0..60 {
1554 let mut meta = HashMap::new();
1555 let _ = meta.insert("title".to_string(), format!("Post {i}"));
1556 let _ = meta.insert("description".to_string(), format!("Desc {i}"));
1557 let _ = meta.insert(
1558 "item_pub_date".to_string(),
1559 format!(
1560 "Thu, {:02} Apr 2026 {:02}:00:00 +0000",
1561 (i % 28) + 1,
1562 i % 24
1563 ),
1564 );
1565 let _ = meta.insert("author".to_string(), "Bot".to_string());
1566 write_meta_sidecar(tmp.path(), &format!("post-{i:03}"), &meta);
1567 }
1568
1569 let ctx = make_atom_ctx(tmp.path());
1570 AtomFeedPlugin.after_compile(&ctx).unwrap();
1571
1572 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
1573 let entry_count = content.matches("<entry>").count();
1574 assert_eq!(
1575 entry_count, 50,
1576 "Should truncate to 50 entries, got {entry_count}"
1577 );
1578 Ok(())
1579 }
1580
1581 #[test]
1586 fn test_inject_atom_link_multiple_files() -> Result<()> {
1587 let tmp = tempdir().unwrap();
1588 for name in ["index.html", "about.html", "contact.html"] {
1589 fs::write(
1590 tmp.path().join(name),
1591 "<html><head><title>T</title></head><body></body></html>",
1592 )
1593 .unwrap();
1594 }
1595
1596 inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
1597
1598 for name in ["index.html", "about.html", "contact.html"] {
1599 let content = fs::read_to_string(tmp.path().join(name)).unwrap();
1600 assert!(
1601 content.contains("application/atom+xml"),
1602 "{name} should have atom link"
1603 );
1604 }
1605 Ok(())
1606 }
1607
1608 #[test]
1613 fn test_extract_entries_from_rss_malformed_item() {
1614 let tmp = tempdir().unwrap();
1615 let rss = r#"<?xml version="1.0"?>
1617<rss><channel>
1618<item>
1619<title>Unclosed
1620</channel></rss>"#;
1621 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1622 let entries = extract_entries_from_rss(tmp.path());
1623 assert!(entries.is_empty(), "{:?}", entries);
1624 }
1625
1626 #[test]
1627 fn test_extract_entries_from_rss_link_trailing_slash() {
1628 let tmp = tempdir().unwrap();
1629 let rss = r#"<?xml version="1.0"?>
1630<rss><channel>
1631<item>
1632<title>Slash Test</title>
1633<link>https://example.com/my-post/</link>
1634</item>
1635</channel></rss>"#;
1636 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1637 let entries = extract_entries_from_rss(tmp.path());
1638 assert_eq!(entries.len(), 1);
1639 assert_eq!(entries[0].0, "my-post");
1640 }
1641
1642 #[test]
1647 fn test_atom_feed_falls_back_to_build_meta_dir() -> Result<()> {
1648 let tmp = tempdir().unwrap();
1649 let site_dir = tmp.path().join("site");
1650 let build_dir = tmp.path().join("build");
1651 let meta_dir = build_dir.join(".meta");
1652 fs::create_dir_all(&site_dir).unwrap();
1653 fs::create_dir_all(&meta_dir).unwrap();
1654
1655 let page_dir = meta_dir.join("fallback-post");
1657 fs::create_dir_all(&page_dir).unwrap();
1658 let mut meta = HashMap::new();
1659 let _ = meta.insert("title".to_string(), "Fallback".to_string());
1660 let _ = meta
1661 .insert("description".to_string(), "From build dir".to_string());
1662 let _ = meta.insert(
1663 "item_pub_date".to_string(),
1664 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1665 );
1666 let json = serde_json::to_string(&meta).unwrap();
1667 fs::write(page_dir.join("index.meta.json"), json).unwrap();
1668
1669 let config = crate::cmd::SsgConfig {
1670 listings: Vec::new(),
1671 base_url: "https://example.com".to_string(),
1672 site_name: "Test".to_string(),
1673 site_title: "Test".to_string(),
1674 site_description: "Test".to_string(),
1675 language: "en".to_string(),
1676 content_dir: std::path::PathBuf::from("content"),
1677 output_dir: build_dir.clone(),
1678 template_dir: std::path::PathBuf::from("templates"),
1679 theme: None,
1680 serve_dir: None,
1681 #[cfg(feature = "i18n")]
1682 i18n: None,
1683 cdn_prefix: None,
1684 og_image: None,
1685 image: crate::cmd::ImageConfig::default(),
1686 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
1687 agents: None,
1688 transitions: false,
1689 security: crate::cmd::SecurityConfig::default(),
1690 no_taxonomy_pages: false,
1691 };
1692 let ctx = PluginContext::with_config(
1693 Path::new("content"),
1694 &build_dir,
1695 &site_dir,
1696 Path::new("templates"),
1697 config,
1698 );
1699
1700 AtomFeedPlugin.after_compile(&ctx).unwrap();
1701
1702 let atom_path = site_dir.join("atom.xml");
1703 assert!(
1704 atom_path.exists(),
1705 "Should create atom.xml from build/.meta"
1706 );
1707 let content = fs::read_to_string(&atom_path).unwrap();
1708 assert!(content.contains("Fallback"));
1709 Ok(())
1710 }
1711
1712 fn sidecar_meta(title: &str) -> HashMap<String, String> {
1717 let mut meta = HashMap::new();
1718 let _ = meta.insert("title".to_string(), title.to_string());
1719 let _ = meta.insert(
1720 "item_pub_date".to_string(),
1721 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1722 );
1723 meta
1724 }
1725
1726 #[test]
1727 fn test_after_compile_errors_when_atom_xml_is_a_directory() {
1728 let tmp = tempdir().unwrap();
1729 write_meta_sidecar(tmp.path(), "post", &sidecar_meta("Post"));
1730 fs::create_dir_all(tmp.path().join("atom.xml")).unwrap();
1732
1733 let ctx = make_atom_ctx(tmp.path());
1734 let err = AtomFeedPlugin.after_compile(&ctx).unwrap_err();
1735 assert!(
1736 format!("{err}").contains("atom.xml"),
1737 "error names the path"
1738 );
1739 }
1740
1741 #[test]
1742 fn test_after_compile_propagates_unreadable_html_error() {
1743 let tmp = tempdir().unwrap();
1744 write_meta_sidecar(tmp.path(), "post", &sidecar_meta("Post"));
1745 fs::write(tmp.path().join("bad.html"), [0xFF, 0xFE, 0xFD]).unwrap();
1747
1748 let ctx = make_atom_ctx(tmp.path());
1749 let err = AtomFeedPlugin.after_compile(&ctx).unwrap_err();
1750 assert!(
1751 format!("{err}").contains("bad.html"),
1752 "error names the file"
1753 );
1754 }
1755
1756 #[test]
1757 #[cfg(unix)]
1758 fn test_inject_atom_link_write_failure_on_readonly_html() {
1759 use std::os::unix::fs::PermissionsExt;
1760 let tmp = tempdir().unwrap();
1761 let html_path = tmp.path().join("index.html");
1762 fs::write(
1763 &html_path,
1764 "<html><head><title>T</title></head><body></body></html>",
1765 )
1766 .unwrap();
1767 fs::set_permissions(&html_path, fs::Permissions::from_mode(0o444))
1768 .unwrap();
1769
1770 let result = inject_atom_link(tmp.path(), "https://x.example/atom.xml");
1771 let _ =
1772 fs::set_permissions(&html_path, fs::Permissions::from_mode(0o644));
1773 let err = result.unwrap_err();
1774 assert!(format!("{err}").contains("index.html"));
1775 }
1776
1777 #[test]
1778 #[cfg(unix)]
1779 fn test_inject_atom_link_walk_failure_on_unreadable_subdir() {
1780 use std::os::unix::fs::PermissionsExt;
1781 let tmp = tempdir().unwrap();
1782 let locked = tmp.path().join("locked");
1783 fs::create_dir_all(&locked).unwrap();
1784 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1785 .unwrap();
1786
1787 let result = inject_atom_link(tmp.path(), "https://x.example/atom.xml");
1788 let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1789 assert!(result.is_err(), "expected an error from the locked path");
1790 }
1791
1792 #[test]
1797 fn test_extract_xml_tag_attr_form_without_closing_bracket() {
1798 assert_eq!(extract_xml_tag("<title attr=x no-gt", "title"), None);
1801 }
1802
1803 #[test]
1804 fn test_extract_xml_tag_missing_end_tag() {
1805 assert_eq!(extract_xml_tag("<title>abc", "title"), None);
1806 }
1807}