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 let mut entries = Vec::new();
261
262 let mut search_from = 0;
264 while let Some(item_start) = rss_content[search_from..].find("<item>") {
265 let abs_start = search_from + item_start;
266 let Some(item_end) = rss_content[abs_start..].find("</item>") else {
267 break;
268 };
269 let item = &rss_content[abs_start..abs_start + item_end + 7];
270
271 let mut meta = std::collections::HashMap::new();
272 if let Some(title) = extract_xml_tag(item, "title") {
273 let _ = meta.insert("title".to_string(), title);
274 }
275 if let Some(desc) = extract_xml_tag(item, "description") {
276 let _ = meta.insert("description".to_string(), desc);
277 }
278 if let Some(date) = extract_xml_tag(item, "pubDate") {
279 let _ = meta.insert("item_pub_date".to_string(), date);
280 }
281 if let Some(author) = extract_xml_tag(item, "author") {
282 let _ = meta.insert("author".to_string(), author);
283 }
284
285 let rel_path = extract_xml_tag(item, "link")
287 .map(|link| {
288 link.trim_end_matches('/')
289 .rsplit('/')
290 .next()
291 .unwrap_or("")
292 .to_string()
293 })
294 .unwrap_or_default();
295
296 if !rel_path.is_empty() && meta.contains_key("title") {
297 entries.push((rel_path, meta));
298 }
299
300 search_from = abs_start + item_end + 7;
301 }
302
303 entries
304}
305
306fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
311 let open_plain = format!("<{tag}>");
313 let open_attr = format!("<{tag} ");
314 let close = format!("</{tag}>");
315
316 let (start, content_start) = if let Some(pos) = xml.find(&open_plain) {
317 (pos, pos + open_plain.len())
318 } else {
319 let pos = xml.find(&open_attr)?;
320 let gt = xml[pos..].find('>')?;
321 (pos, pos + gt + 1)
322 };
323
324 let _ = start; let end = xml[content_start..].find(&close)? + content_start;
326 let content = xml[content_start..end].trim();
327
328 let content = content
330 .strip_prefix("<![CDATA[")
331 .and_then(|s| s.strip_suffix("]]>"))
332 .unwrap_or(content);
333
334 let decoded = content
336 .replace("&", "&")
337 .replace("<", "<")
338 .replace(">", ">")
339 .replace(""", "\"")
340 .replace("'", "'");
341
342 let decoded = decoded.trim();
343 if decoded.is_empty() {
344 None
345 } else {
346 Some(xml_escape(decoded))
347 }
348}
349
350pub(super) fn inject_atom_link(
353 site_dir: &Path,
354 atom_url: &str,
355) -> Result<(), SsgError> {
356 let html_files = crate::walk::walk_files(site_dir, "html")
357 .map_err(|e| SsgError::io(e, site_dir))?;
358 for path in &html_files {
359 let html = fs::read_to_string(path).with_path(path)?;
360
361 if html.contains("application/atom+xml") {
362 continue;
363 }
364
365 let link_tag = format!(
366 " <link rel=\"alternate\" type=\"application/atom+xml\" title=\"Atom Feed\" href=\"{atom_url}\"/>\n"
367 );
368 let modified = inject_before_head_close(&html, &link_tag);
369 if modified != html {
370 fs::write(path, &modified).with_path(path)?;
371 }
372 }
373 Ok(())
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::plugin::PluginContext;
380 use anyhow::Result;
381 use std::collections::HashMap;
382 use std::path::Path;
383 use tempfile::tempdir;
384
385 fn write_meta_sidecar(
386 dir: &Path,
387 slug: &str,
388 meta: &HashMap<String, String>,
389 ) {
390 let page_dir = dir.join(slug);
391 fs::create_dir_all(&page_dir).expect("create page dir");
392 let meta_path = page_dir.join("index.meta.json");
393 let json = serde_json::to_string(meta).expect("serialize meta");
394 fs::write(&meta_path, json).expect("write meta");
395 }
396
397 fn make_atom_ctx(site_dir: &Path) -> PluginContext {
398 crate::test_support::init_logger();
399 let config = crate::cmd::SsgConfig {
400 listings: Vec::new(),
401 base_url: "https://example.com".to_string(),
402 site_name: "Test Site".to_string(),
403 site_title: "Test Site".to_string(),
404 site_description: "A test site".to_string(),
405 language: "en".to_string(),
406 content_dir: std::path::PathBuf::from("content"),
407 output_dir: std::path::PathBuf::from("build"),
408 template_dir: std::path::PathBuf::from("templates"),
409 theme: None,
410 serve_dir: None,
411 #[cfg(feature = "i18n")]
412 i18n: None,
413 cdn_prefix: None,
414 og_image: None,
415 image: crate::cmd::ImageConfig::default(),
416 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
417 agents: None,
418 transitions: false,
419 security: crate::cmd::SecurityConfig::default(),
420 no_taxonomy_pages: false,
421 };
422 PluginContext::with_config(
423 Path::new("content"),
424 Path::new("build"),
425 site_dir,
426 Path::new("templates"),
427 config,
428 )
429 }
430
431 #[test]
432 fn test_atom_feed_valid_namespace_and_elements() -> Result<()> {
433 let tmp = tempdir().unwrap();
434
435 let mut meta = HashMap::new();
436 let _ = meta.insert("title".to_string(), "Hello World".to_string());
437 let _ =
438 meta.insert("description".to_string(), "A test post".to_string());
439 let _ = meta.insert(
440 "item_pub_date".to_string(),
441 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
442 );
443 let _ = meta.insert("author".to_string(), "Alice".to_string());
444 write_meta_sidecar(tmp.path(), "hello", &meta);
445
446 let ctx = make_atom_ctx(tmp.path());
447 AtomFeedPlugin.after_compile(&ctx).unwrap();
448
449 let atom_path = tmp.path().join("atom.xml");
450 assert!(atom_path.exists(), "atom.xml should be created");
451
452 let content = fs::read_to_string(&atom_path).unwrap();
453 assert!(
454 content.contains("xmlns=\"http://www.w3.org/2005/Atom\""),
455 "Missing Atom namespace"
456 );
457 assert!(content.contains("<feed"), "Missing <feed> element");
458 assert!(content.contains("<title>"), "Missing <title>");
459 assert!(content.contains("rel=\"self\""), "Missing self link");
460 assert!(content.contains("<id>"), "Missing <id>");
461 assert!(content.contains("<updated>"), "Missing <updated>");
462 assert!(content.contains("<entry>"), "Missing <entry>");
463 assert!(content.contains("<author>"), "Missing <author>");
464 assert!(
465 content.contains("<name>Alice</name>"),
466 "Missing author name"
467 );
468 assert!(content.contains("<summary>"), "Missing <summary>");
469 assert!(content.contains("<published>"), "Missing <published>");
470 Ok(())
471 }
472
473 #[test]
474 fn test_atom_feed_entry_count_matches() -> Result<()> {
475 let tmp = tempdir().unwrap();
476
477 for i in 0..5 {
478 let mut meta = HashMap::new();
479 let _ = meta.insert("title".to_string(), format!("Post {i}"));
480 let _ = meta.insert("description".to_string(), format!("Desc {i}"));
481 let _ = meta.insert(
482 "item_pub_date".to_string(),
483 format!("Thu, {:02} Apr 2026 06:06:06 +0000", 10 + i),
484 );
485 let _ = meta.insert("author".to_string(), "Bob".to_string());
486 write_meta_sidecar(tmp.path(), &format!("post-{i}"), &meta);
487 }
488
489 let ctx = make_atom_ctx(tmp.path());
490 AtomFeedPlugin.after_compile(&ctx).unwrap();
491
492 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
493 let entry_count = content.matches("<entry>").count();
494 assert_eq!(entry_count, 5, "Expected 5 entries, got {entry_count}");
495 Ok(())
496 }
497
498 #[test]
499 fn test_atom_feed_dates_are_rfc3339() -> Result<()> {
500 let tmp = tempdir().unwrap();
501
502 let mut meta = HashMap::new();
503 let _ = meta.insert("title".to_string(), "Date Test".to_string());
504 let _ =
505 meta.insert("description".to_string(), "Testing dates".to_string());
506 let _ = meta.insert(
507 "item_pub_date".to_string(),
508 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
509 );
510 let _ = meta.insert("author".to_string(), "Charlie".to_string());
511 write_meta_sidecar(tmp.path(), "datepost", &meta);
512
513 let ctx = make_atom_ctx(tmp.path());
514 AtomFeedPlugin.after_compile(&ctx).unwrap();
515
516 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
517 assert!(
518 content.contains("2026-04-11T06:06:06+00:00"),
519 "Expected RFC 3339 date in atom.xml, got:\n{content}"
520 );
521 Ok(())
522 }
523
524 #[test]
525 fn test_atom_feed_idempotent() -> Result<()> {
526 let tmp = tempdir().unwrap();
527
528 let mut meta = HashMap::new();
529 let _ = meta.insert("title".to_string(), "Idempotent".to_string());
530 let _ = meta.insert("description".to_string(), "Test".to_string());
531 let _ = meta.insert(
532 "item_pub_date".to_string(),
533 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
534 );
535 let _ = meta.insert("author".to_string(), "Dave".to_string());
536 write_meta_sidecar(tmp.path(), "idem", &meta);
537
538 let ctx = make_atom_ctx(tmp.path());
539 AtomFeedPlugin.after_compile(&ctx).unwrap();
540 let first = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
541
542 AtomFeedPlugin.after_compile(&ctx).unwrap();
543 let second = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
544
545 assert_eq!(first, second, "Atom feed should be idempotent");
546 Ok(())
547 }
548
549 #[test]
550 fn test_atom_feed_injects_link_into_html() -> Result<()> {
551 let tmp = tempdir().unwrap();
552
553 let mut meta = HashMap::new();
554 let _ = meta.insert("title".to_string(), "Link Test".to_string());
555 let _ = meta.insert("description".to_string(), "Test".to_string());
556 let _ = meta.insert(
557 "item_pub_date".to_string(),
558 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
559 );
560 let _ = meta.insert("author".to_string(), "Eve".to_string());
561 write_meta_sidecar(tmp.path(), "linktest", &meta);
562
563 let html_path = tmp.path().join("index.html");
564 fs::write(
565 &html_path,
566 "<html><head><title>Test</title></head><body></body></html>",
567 )
568 .unwrap();
569
570 let ctx = make_atom_ctx(tmp.path());
571 AtomFeedPlugin.after_compile(&ctx).unwrap();
572
573 let html = fs::read_to_string(&html_path).unwrap();
574 assert!(
575 html.contains("application/atom+xml"),
576 "HTML should have atom link tag"
577 );
578 Ok(())
579 }
580
581 #[test]
582 fn test_atom_plugin_registers() {
583 use crate::plugin::PluginManager;
584 let mut pm = PluginManager::new();
585 pm.register(AtomFeedPlugin);
586 assert_eq!(pm.len(), 1);
587 assert_eq!(pm.names(), vec!["atom-feed"]);
588 }
589
590 #[test]
591 fn test_atom_feed_sorts_descending() -> Result<()> {
592 let tmp = tempdir().unwrap();
593
594 let mut meta_old = HashMap::new();
595 let _ = meta_old.insert("title".to_string(), "Old Post".to_string());
596 let _ = meta_old.insert("description".to_string(), "old".to_string());
597 let _ = meta_old.insert(
598 "item_pub_date".to_string(),
599 "Mon, 01 Jan 2025 00:00:00 +0000".to_string(),
600 );
601 let _ = meta_old.insert("author".to_string(), "Alice".to_string());
602 write_meta_sidecar(tmp.path(), "old-post", &meta_old);
603
604 let mut meta_new = HashMap::new();
605 let _ = meta_new.insert("title".to_string(), "New Post".to_string());
606 let _ = meta_new.insert("description".to_string(), "new".to_string());
607 let _ = meta_new.insert(
608 "item_pub_date".to_string(),
609 "Fri, 11 Apr 2026 12:00:00 +0000".to_string(),
610 );
611 let _ = meta_new.insert("author".to_string(), "Bob".to_string());
612 write_meta_sidecar(tmp.path(), "new-post", &meta_new);
613
614 let ctx = make_atom_ctx(tmp.path());
615 AtomFeedPlugin.after_compile(&ctx).unwrap();
616
617 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
618 let first_entry_pos = content.find("<entry>").unwrap();
619 let new_title_pos = content.find("New Post").unwrap();
620 let old_title_pos = content.find("Old Post").unwrap();
621 assert!(
622 new_title_pos < old_title_pos,
623 "Newer post should come first"
624 );
625 assert!(
626 new_title_pos > first_entry_pos,
627 "Title should be inside an entry"
628 );
629 Ok(())
630 }
631
632 #[test]
633 fn test_atom_feed_empty_author_shows_unknown() -> Result<()> {
634 let tmp = tempdir().unwrap();
635
636 let mut meta = HashMap::new();
637 let _ = meta.insert("title".to_string(), "No Author".to_string());
638 let _ = meta.insert("description".to_string(), "test".to_string());
639 let _ = meta.insert(
640 "item_pub_date".to_string(),
641 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
642 );
643 write_meta_sidecar(tmp.path(), "no-author", &meta);
644
645 let ctx = make_atom_ctx(tmp.path());
646 AtomFeedPlugin.after_compile(&ctx).unwrap();
647
648 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
649 assert!(
650 content.contains("<name>Unknown</name>"),
651 "Empty author should show 'Unknown': {content}"
652 );
653 Ok(())
654 }
655
656 #[test]
657 fn test_atom_feed_skips_empty_title() -> Result<()> {
658 let tmp = tempdir().unwrap();
659
660 let mut meta = HashMap::new();
661 let _ = meta.insert("title".to_string(), String::new());
662 let _ = meta.insert("description".to_string(), "test".to_string());
663 let _ = meta.insert(
664 "item_pub_date".to_string(),
665 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
666 );
667 write_meta_sidecar(tmp.path(), "no-title", &meta);
668
669 let ctx = make_atom_ctx(tmp.path());
670 AtomFeedPlugin.after_compile(&ctx).unwrap();
671
672 let atom_path = tmp.path().join("atom.xml");
673 assert!(
674 !atom_path.exists(),
675 "Should not create atom.xml when all entries have empty titles"
676 );
677 Ok(())
678 }
679
680 #[test]
681 fn test_atom_feed_xml_escapes_content() -> Result<()> {
682 let tmp = tempdir().unwrap();
683
684 let mut meta = HashMap::new();
685 let _ = meta
686 .insert("title".to_string(), "Tom & Jerry <friends>".to_string());
687 let _ = meta
688 .insert("description".to_string(), "A \"great\" show".to_string());
689 let _ = meta.insert(
690 "item_pub_date".to_string(),
691 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
692 );
693 let _ = meta.insert("author".to_string(), "O'Brien".to_string());
694 write_meta_sidecar(tmp.path(), "escape-test", &meta);
695
696 let ctx = make_atom_ctx(tmp.path());
697 AtomFeedPlugin.after_compile(&ctx).unwrap();
698
699 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
700 assert!(content.contains("Tom & Jerry"), "& should be escaped");
701 assert!(
702 content.contains("<friends>"),
703 "< and > should be escaped"
704 );
705 assert!(
706 content.contains(""great""),
707 "quotes should be escaped"
708 );
709 assert!(
710 content.contains("O'Brien"),
711 "apostrophe should be escaped"
712 );
713 Ok(())
714 }
715
716 #[test]
721 fn test_atom_entry_to_xml() {
722 let entry = AtomEntry {
723 title: "Test Post".to_string(),
724 link: "https://example.com/test/".to_string(),
725 id: "https://example.com/test/".to_string(),
726 updated: "2026-04-11T06:06:06+00:00".to_string(),
727 published: "2026-04-11T06:06:06+00:00".to_string(),
728 summary: "A test summary".to_string(),
729 author: "Alice".to_string(),
730 };
731 let xml = entry.to_xml();
732 assert!(xml.contains("<entry>"));
733 assert!(xml.contains("</entry>"));
734 assert!(xml.contains("<title>Test Post</title>"));
735 assert!(xml.contains("href=\"https://example.com/test/\""));
736 assert!(xml.contains("<name>Alice</name>"));
737 assert!(xml.contains("<summary>A test summary</summary>"));
738 }
739
740 #[test]
741 fn test_atom_entry_empty_author() {
742 let entry = AtomEntry {
743 title: "No Author".to_string(),
744 link: "https://example.com/".to_string(),
745 id: "https://example.com/".to_string(),
746 updated: "2026-01-01T00:00:00+00:00".to_string(),
747 published: "2026-01-01T00:00:00+00:00".to_string(),
748 summary: String::new(),
749 author: String::new(),
750 };
751 let xml = entry.to_xml();
752 assert!(
753 xml.contains("<name>Unknown</name>"),
754 "Empty author should show 'Unknown'"
755 );
756 }
757
758 #[test]
763 fn test_inject_atom_link_adds_tag() -> Result<()> {
764 let tmp = tempdir().unwrap();
765 let html_path = tmp.path().join("page.html");
766 fs::write(
767 &html_path,
768 "<html><head><title>Test</title></head><body></body></html>",
769 )
770 .unwrap();
771
772 inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
773
774 let result = fs::read_to_string(&html_path).unwrap();
775 assert!(
776 result.contains("application/atom+xml"),
777 "Should inject atom link: {result}"
778 );
779 assert!(
780 result.contains("href=\"https://example.com/atom.xml\""),
781 "Should have correct href: {result}"
782 );
783 Ok(())
784 }
785
786 #[test]
787 fn test_inject_atom_link_idempotent() -> Result<()> {
788 let tmp = tempdir().unwrap();
789 let html_path = tmp.path().join("page.html");
790 fs::write(
791 &html_path,
792 "<html><head><title>Test</title></head><body></body></html>",
793 )
794 .unwrap();
795
796 inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
797 let first = fs::read_to_string(&html_path).unwrap();
798
799 inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
800 let second = fs::read_to_string(&html_path).unwrap();
801
802 assert_eq!(first, second, "inject_atom_link should be idempotent");
803 assert_eq!(
804 second.matches("application/atom+xml").count(),
805 1,
806 "Should have exactly one atom link"
807 );
808 Ok(())
809 }
810
811 #[test]
812 fn test_inject_atom_link_no_head() -> Result<()> {
813 let tmp = tempdir().unwrap();
814 let html_path = tmp.path().join("nohead.html");
815 fs::write(&html_path, "<html><body>No head</body></html>").unwrap();
816
817 inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
818
819 let result = fs::read_to_string(&html_path).unwrap();
820 assert!(
821 !result.contains("application/atom+xml"),
822 "Should not inject when there is no </head>"
823 );
824 Ok(())
825 }
826
827 #[test]
832 fn test_atom_feed_plugin_name() {
833 let plugin = AtomFeedPlugin;
834 assert_eq!(plugin.name(), "atom-feed");
835 }
836
837 #[test]
838 fn test_atom_feed_plugin_debug() {
839 let plugin = AtomFeedPlugin;
840 let debug = format!("{plugin:?}");
841 assert!(debug.contains("AtomFeedPlugin"));
842 }
843
844 #[test]
845 fn test_atom_feed_plugin_clone_copy() {
846 let plugin = AtomFeedPlugin;
847 let cloned = plugin;
848 assert_eq!(cloned.name(), "atom-feed");
849 }
850
851 #[test]
856 fn test_atom_feed_empty_site_dir() -> Result<()> {
857 let tmp = tempdir().unwrap();
858 let ctx = make_atom_ctx(tmp.path());
860 AtomFeedPlugin.after_compile(&ctx).unwrap();
861
862 let atom_path = tmp.path().join("atom.xml");
863 assert!(
864 !atom_path.exists(),
865 "Should not create atom.xml with no entries"
866 );
867 Ok(())
868 }
869
870 #[test]
875 fn test_atom_feed_falls_back_to_rss_xml() -> Result<()> {
876 let tmp = tempdir().unwrap();
877 let rss_content = r#"<?xml version="1.0"?>
879<rss version="2.0">
880<channel>
881<title>Test</title>
882<item>
883<title>From RSS</title>
884<description>Extracted from RSS</description>
885<link>https://example.com/rss-post/</link>
886<pubDate>Thu, 11 Apr 2026 06:06:06 +0000</pubDate>
887<author>Alice</author>
888</item>
889</channel>
890</rss>"#;
891 fs::write(tmp.path().join("rss.xml"), rss_content).unwrap();
892
893 let ctx = make_atom_ctx(tmp.path());
894 AtomFeedPlugin.after_compile(&ctx).unwrap();
895
896 let atom_path = tmp.path().join("atom.xml");
897 assert!(atom_path.exists(), "Should create atom.xml from rss.xml");
898 let content = fs::read_to_string(&atom_path).unwrap();
899 assert!(
900 content.contains("From RSS"),
901 "Should contain entry from rss.xml"
902 );
903 Ok(())
904 }
905
906 #[test]
907 fn test_atom_feed_rss_multiple_items() -> Result<()> {
908 let tmp = tempdir().unwrap();
909 let rss_content = r#"<?xml version="1.0"?>
910<rss version="2.0">
911<channel>
912<title>Test</title>
913<item>
914<title>Post A</title>
915<description>Desc A</description>
916<link>https://example.com/post-a/</link>
917<pubDate>Thu, 10 Apr 2026 00:00:00 +0000</pubDate>
918</item>
919<item>
920<title>Post B</title>
921<description>Desc B</description>
922<link>https://example.com/post-b/</link>
923<pubDate>Fri, 11 Apr 2026 00:00:00 +0000</pubDate>
924</item>
925</channel>
926</rss>"#;
927 fs::write(tmp.path().join("rss.xml"), rss_content).unwrap();
928
929 let ctx = make_atom_ctx(tmp.path());
930 AtomFeedPlugin.after_compile(&ctx).unwrap();
931
932 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
933 assert!(content.contains("Post A"));
934 assert!(content.contains("Post B"));
935 let entry_count = content.matches("<entry>").count();
936 assert_eq!(entry_count, 2);
937 Ok(())
938 }
939
940 #[test]
941 fn test_atom_feed_rss_with_cdata() -> Result<()> {
942 let tmp = tempdir().unwrap();
943 let rss_content = r#"<?xml version="1.0"?>
944<rss version="2.0">
945<channel>
946<title>Test</title>
947<item>
948<title><![CDATA[CDATA Title]]></title>
949<description><![CDATA[CDATA Description]]></description>
950<link>https://example.com/cdata-post/</link>
951<pubDate>Thu, 11 Apr 2026 06:06:06 +0000</pubDate>
952</item>
953</channel>
954</rss>"#;
955 fs::write(tmp.path().join("rss.xml"), rss_content).unwrap();
956
957 let ctx = make_atom_ctx(tmp.path());
958 AtomFeedPlugin.after_compile(&ctx).unwrap();
959
960 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
961 assert!(content.contains("CDATA Title"), "Should unwrap CDATA");
962 Ok(())
963 }
964
965 #[test]
970 fn test_extract_xml_tag_simple() {
971 let xml = "<item><title>Hello</title></item>";
972 assert_eq!(extract_xml_tag(xml, "title"), Some("Hello".to_string()));
973 }
974
975 #[test]
976 fn test_extract_xml_tag_with_attributes() {
977 let xml = r#"<item><link href="http://example.com">text</link></item>"#;
978 assert_eq!(extract_xml_tag(xml, "link"), Some("text".to_string()));
979 }
980
981 #[test]
982 fn test_extract_xml_tag_missing() {
983 let xml = "<item><title>Hello</title></item>";
984 assert_eq!(extract_xml_tag(xml, "author"), None);
985 }
986
987 #[test]
988 fn test_extract_xml_tag_empty_content() {
989 let xml = "<item><title></title></item>";
990 assert_eq!(extract_xml_tag(xml, "title"), None);
991 }
992
993 #[test]
994 fn test_extract_xml_tag_cdata() {
995 let xml = "<item><title><![CDATA[My Title]]></title></item>";
996 assert_eq!(extract_xml_tag(xml, "title"), Some("My Title".to_string()));
997 }
998
999 #[test]
1000 fn test_extract_xml_tag_decodes_entities() {
1001 let xml = "<item><title>Tom & Jerry</title></item>";
1002 let result = extract_xml_tag(xml, "title").unwrap();
1004 assert!(
1005 result.contains("Tom") && result.contains("Jerry"),
1006 "Should contain decoded text: {result}"
1007 );
1008 }
1009
1010 #[test]
1011 fn test_extract_xml_tag_whitespace() {
1012 let xml = "<item><title> Hello World </title></item>";
1013 assert_eq!(
1014 extract_xml_tag(xml, "title"),
1015 Some("Hello World".to_string())
1016 );
1017 }
1018
1019 #[test]
1024 fn test_extract_entries_from_rss_no_file() {
1025 let tmp = tempdir().unwrap();
1026 let entries = extract_entries_from_rss(tmp.path());
1027 assert!(entries.is_empty());
1028 }
1029
1030 #[test]
1031 fn test_extract_entries_from_rss_empty_rss() {
1032 let tmp = tempdir().unwrap();
1033 fs::write(
1034 tmp.path().join("rss.xml"),
1035 r#"<?xml version="1.0"?><rss><channel></channel></rss>"#,
1036 )
1037 .unwrap();
1038 let entries = extract_entries_from_rss(tmp.path());
1039 assert!(entries.is_empty());
1040 }
1041
1042 #[test]
1043 fn test_extract_entries_from_rss_item_without_title() {
1044 let tmp = tempdir().unwrap();
1045 let rss = r#"<?xml version="1.0"?>
1046<rss><channel>
1047<item>
1048<description>No title item</description>
1049<link>https://example.com/no-title/</link>
1050</item>
1051</channel></rss>"#;
1052 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1053 let entries = extract_entries_from_rss(tmp.path());
1054 assert!(entries.is_empty());
1058 }
1059
1060 #[test]
1061 fn test_extract_entries_from_rss_item_without_link() {
1062 let tmp = tempdir().unwrap();
1063 let rss = r#"<?xml version="1.0"?>
1064<rss><channel>
1065<item>
1066<title>No Link</title>
1067<description>No link item</description>
1068</item>
1069</channel></rss>"#;
1070 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1071 let entries = extract_entries_from_rss(tmp.path());
1072 assert!(entries.is_empty());
1074 }
1075
1076 #[test]
1081 fn test_build_atom_entry_empty_rel_path() {
1082 let meta = HashMap::new();
1083 assert!(build_atom_entry("", &meta, "https://example.com").is_none());
1084 }
1085
1086 #[test]
1087 fn test_build_atom_entry_empty_title() {
1088 let mut meta = HashMap::new();
1089 let _ = meta.insert("title".to_string(), String::new());
1090 assert!(
1091 build_atom_entry("page", &meta, "https://example.com").is_none()
1092 );
1093 }
1094
1095 #[test]
1096 fn test_build_atom_entry_minimal() {
1097 let mut meta = HashMap::new();
1098 let _ = meta.insert("title".to_string(), "Test".to_string());
1099 let result = build_atom_entry("page", &meta, "https://example.com");
1100 assert!(result.is_some());
1101 let (date_key, entry) = result.unwrap();
1102 assert_eq!(entry.title, "Test");
1103 assert!(entry.link.contains("example.com/page/"));
1104 assert!(entry.author.is_empty());
1105 assert!(date_key.is_empty());
1107 }
1108
1109 #[test]
1110 fn test_build_atom_entry_empty_base_url() {
1111 let mut meta = HashMap::new();
1112 let _ = meta.insert("title".to_string(), "Test".to_string());
1113 let result = build_atom_entry("page", &meta, "");
1114 assert!(result.is_some());
1115 let (_, entry) = result.unwrap();
1116 assert_eq!(entry.link, "page/");
1117 }
1118
1119 #[test]
1120 fn test_build_atom_entry_with_all_fields() {
1121 let mut meta = HashMap::new();
1122 let _ = meta.insert("title".to_string(), "Full Entry".to_string());
1123 let _ =
1124 meta.insert("description".to_string(), "A description".to_string());
1125 let _ = meta.insert(
1126 "item_pub_date".to_string(),
1127 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1128 );
1129 let _ = meta.insert("author".to_string(), "Alice".to_string());
1130
1131 let result = build_atom_entry("full", &meta, "https://example.com");
1132 let (date_key, entry) = result.unwrap();
1133 assert_eq!(entry.title, "Full Entry");
1134 assert_eq!(entry.summary, "A description");
1135 assert_eq!(entry.author, "Alice");
1136 assert!(date_key.contains("2026"));
1137 }
1138
1139 #[test]
1140 fn test_build_atom_entry_unparseable_date() {
1141 let mut meta = HashMap::new();
1142 let _ = meta.insert("title".to_string(), "Bad Date".to_string());
1143 let _ =
1144 meta.insert("item_pub_date".to_string(), "not-a-date".to_string());
1145
1146 let result = build_atom_entry("baddate", &meta, "https://example.com");
1147 let (date_key, _) = result.unwrap();
1148 assert_eq!(date_key, "not-a-date");
1150 }
1151
1152 #[test]
1157 fn test_build_atom_entry_iso_date() {
1158 let mut meta = HashMap::new();
1159 let _ = meta.insert("title".to_string(), "ISO Date".to_string());
1160 let _ =
1161 meta.insert("item_pub_date".to_string(), "2026-07-01".to_string());
1162
1163 let result = build_atom_entry("isodate", &meta, "https://example.com");
1164 let (date_key, entry) = result.unwrap();
1165 assert_eq!(date_key, "2026-07-01T00:00:00+00:00");
1166 assert_eq!(entry.updated, "2026-07-01T00:00:00+00:00");
1167 assert_eq!(entry.published, "2026-07-01T00:00:00+00:00");
1168 }
1169
1170 #[test]
1171 fn test_build_atom_entry_long_form_date() {
1172 let mut meta = HashMap::new();
1173 let _ = meta.insert("title".to_string(), "Long Form".to_string());
1174 let _ = meta
1175 .insert("item_pub_date".to_string(), "July 1, 2026".to_string());
1176
1177 let result = build_atom_entry("longform", &meta, "https://example.com");
1178 let (date_key, entry) = result.unwrap();
1179 assert_eq!(date_key, "2026-07-01T00:00:00+00:00");
1180 assert_eq!(entry.updated, "2026-07-01T00:00:00+00:00");
1181 }
1182
1183 #[test]
1184 fn test_build_atom_entry_iso_datetime_normalised() {
1185 let mut meta = HashMap::new();
1186 let _ = meta.insert("title".to_string(), "ISO DT".to_string());
1187 let _ = meta.insert(
1188 "item_pub_date".to_string(),
1189 "2026-07-01T07:07:07Z".to_string(),
1190 );
1191
1192 let result = build_atom_entry("isodt", &meta, "https://example.com");
1193 let (date_key, _) = result.unwrap();
1194 assert_eq!(date_key, "2026-07-01T07:07:07+00:00");
1195 }
1196
1197 #[test]
1202 fn test_collect_atom_entries_empty() {
1203 let entries: Vec<(String, HashMap<String, String>)> = vec![];
1204 let result = collect_atom_entries(&entries, "https://example.com");
1205 assert!(result.is_empty());
1206 }
1207
1208 #[test]
1209 fn test_collect_atom_entries_filters_invalid() {
1210 let mut meta1 = HashMap::new();
1211 let _ = meta1.insert("title".to_string(), "Valid".to_string());
1212 let mut meta2 = HashMap::new();
1213 let _ = meta2.insert("title".to_string(), String::new()); let entries =
1216 vec![("valid".to_string(), meta1), ("invalid".to_string(), meta2)];
1217 let result = collect_atom_entries(&entries, "https://example.com");
1218 assert_eq!(result.len(), 1);
1219 assert_eq!(result[0].1.title, "Valid");
1220 }
1221
1222 #[test]
1227 fn test_build_atom_feed_structure() {
1228 let entry = AtomEntry {
1229 title: "Feed Test".to_string(),
1230 link: "https://example.com/test/".to_string(),
1231 id: "https://example.com/test/".to_string(),
1232 updated: "2026-04-11T00:00:00+00:00".to_string(),
1233 published: "2026-04-11T00:00:00+00:00".to_string(),
1234 summary: "Summary".to_string(),
1235 author: "Bob".to_string(),
1236 };
1237 let articles = vec![("2026-04-11T00:00:00+00:00".to_string(), entry)];
1238 let xml = build_atom_feed("My Feed", "https://example.com", &articles);
1239 assert!(xml.starts_with("<?xml"));
1240 assert!(xml.contains("xmlns=\"http://www.w3.org/2005/Atom\""));
1241 assert!(xml.contains("<title>My Feed</title>"));
1242 assert!(xml.contains("rel=\"self\""));
1243 assert!(xml.contains("https://example.com/atom.xml"));
1244 assert!(xml.contains("<id>https://example.com</id>"));
1245 assert!(xml.contains("Feed Test"));
1246 }
1247
1248 #[test]
1249 fn test_build_atom_feed_empty_base_url() {
1250 let entry = AtomEntry {
1251 title: "Test".to_string(),
1252 link: "test/".to_string(),
1253 id: "test/".to_string(),
1254 updated: "2026-01-01T00:00:00+00:00".to_string(),
1255 published: "2026-01-01T00:00:00+00:00".to_string(),
1256 summary: String::new(),
1257 author: String::new(),
1258 };
1259 let articles = vec![("2026-01-01T00:00:00+00:00".to_string(), entry)];
1260 let xml = build_atom_feed("Untitled", "", &articles);
1261 assert!(xml.contains("<id>/</id>"));
1262 assert!(xml.contains("href=\"atom.xml\""));
1263 }
1264
1265 #[test]
1266 fn test_build_atom_feed_xml_escapes_title() {
1267 let entry = AtomEntry {
1268 title: "A".to_string(),
1269 link: "a/".to_string(),
1270 id: "a/".to_string(),
1271 updated: "2026-01-01T00:00:00+00:00".to_string(),
1272 published: "2026-01-01T00:00:00+00:00".to_string(),
1273 summary: String::new(),
1274 author: String::new(),
1275 };
1276 let articles = vec![("2026-01-01T00:00:00+00:00".to_string(), entry)];
1277 let xml = build_atom_feed(
1278 "Tom & Jerry's <Feed>",
1279 "https://example.com",
1280 &articles,
1281 );
1282 assert!(xml.contains("Tom & Jerry"));
1283 assert!(xml.contains("<Feed>"));
1284 }
1285
1286 #[test]
1291 fn test_atom_entry_to_xml_escapes_all_fields() {
1292 let entry = AtomEntry {
1293 title: "A & B".to_string(),
1294 link: "https://example.com/a&b/".to_string(),
1295 id: "https://example.com/a&b/".to_string(),
1296 updated: "2026-01-01".to_string(),
1297 published: "2026-01-01".to_string(),
1298 summary: "\"quoted\" <summary>".to_string(),
1299 author: "O'Brien".to_string(),
1300 };
1301 let xml = entry.to_xml();
1302 assert!(xml.contains("A & B"), "Title not escaped");
1303 assert!(
1304 xml.contains(""quoted""),
1305 "Summary quotes not escaped"
1306 );
1307 assert!(
1308 xml.contains("<summary>"),
1309 "Summary angles not escaped"
1310 );
1311 assert!(
1312 xml.contains("O'Brien"),
1313 "Author apostrophe not escaped"
1314 );
1315 }
1316
1317 #[test]
1318 fn test_atom_entry_to_xml_empty_summary() {
1319 let entry = AtomEntry {
1320 title: "No Summary".to_string(),
1321 link: "https://example.com/".to_string(),
1322 id: "https://example.com/".to_string(),
1323 updated: "2026-01-01".to_string(),
1324 published: "2026-01-01".to_string(),
1325 summary: String::new(),
1326 author: "Alice".to_string(),
1327 };
1328 let xml = entry.to_xml();
1329 assert!(xml.contains("<summary></summary>"));
1330 }
1331
1332 #[test]
1337 fn test_atom_feed_untitled_site() -> Result<()> {
1338 let tmp = tempdir().unwrap();
1339
1340 let mut meta = HashMap::new();
1341 let _ = meta.insert("title".to_string(), "Post".to_string());
1342 let _ = meta.insert("description".to_string(), "desc".to_string());
1343 let _ = meta.insert(
1344 "item_pub_date".to_string(),
1345 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1346 );
1347 write_meta_sidecar(tmp.path(), "post", &meta);
1348
1349 let config = crate::cmd::SsgConfig {
1351 listings: Vec::new(),
1352 base_url: "https://example.com".to_string(),
1353 site_name: String::new(),
1354 site_title: String::new(),
1355 site_description: String::new(),
1356 language: "en".to_string(),
1357 content_dir: std::path::PathBuf::from("content"),
1358 output_dir: std::path::PathBuf::from("build"),
1359 template_dir: std::path::PathBuf::from("templates"),
1360 theme: None,
1361 serve_dir: None,
1362 #[cfg(feature = "i18n")]
1363 i18n: None,
1364 cdn_prefix: None,
1365 og_image: None,
1366 image: crate::cmd::ImageConfig::default(),
1367 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
1368 agents: None,
1369 transitions: false,
1370 security: crate::cmd::SecurityConfig::default(),
1371 no_taxonomy_pages: false,
1372 };
1373 let ctx = PluginContext::with_config(
1374 Path::new("content"),
1375 Path::new("build"),
1376 tmp.path(),
1377 Path::new("templates"),
1378 config,
1379 );
1380
1381 AtomFeedPlugin.after_compile(&ctx).unwrap();
1382
1383 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
1384 assert!(
1385 content.contains("<title>Untitled</title>"),
1386 "Empty site_name should produce 'Untitled'"
1387 );
1388 Ok(())
1389 }
1390
1391 #[test]
1392 fn test_atom_feed_no_config() -> Result<()> {
1393 let tmp = tempdir().unwrap();
1394
1395 let mut meta = HashMap::new();
1396 let _ = meta.insert("title".to_string(), "Post".to_string());
1397 let _ = meta.insert("description".to_string(), "desc".to_string());
1398 let _ = meta.insert(
1399 "item_pub_date".to_string(),
1400 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1401 );
1402 write_meta_sidecar(tmp.path(), "post", &meta);
1403
1404 let ctx = PluginContext::new(
1406 Path::new("content"),
1407 Path::new("build"),
1408 tmp.path(),
1409 Path::new("templates"),
1410 );
1411
1412 AtomFeedPlugin.after_compile(&ctx).unwrap();
1413
1414 let atom_path = tmp.path().join("atom.xml");
1415 assert!(atom_path.exists(), "atom.xml must be generated");
1416 let content = fs::read_to_string(&atom_path).unwrap();
1417 assert!(
1418 content.contains("<title>Untitled</title>"),
1419 "No config should produce 'Untitled'"
1420 );
1421 Ok(())
1422 }
1423
1424 #[test]
1429 fn test_atom_entry_iso8601_date_passthrough() {
1430 let mut meta = HashMap::new();
1431 let _ = meta.insert("title".to_string(), "ISO Date".to_string());
1432 let _ = meta.insert(
1433 "item_pub_date".to_string(),
1434 "2026-04-11T12:00:00+00:00".to_string(),
1435 );
1436 let result = build_atom_entry("iso", &meta, "https://example.com");
1437 let (date_key, _) = result.unwrap();
1438 assert!(date_key.contains("2026"));
1440 }
1441
1442 #[test]
1443 fn test_atom_entry_empty_date() {
1444 let mut meta = HashMap::new();
1445 let _ = meta.insert("title".to_string(), "No Date".to_string());
1446 let result = build_atom_entry("nodate", &meta, "https://example.com");
1447 let (date_key, entry) = result.unwrap();
1448 assert!(date_key.is_empty());
1449 assert!(entry.published.is_empty());
1450 }
1451
1452 #[test]
1457 fn test_atom_feed_truncates_at_50() -> Result<()> {
1458 let tmp = tempdir().unwrap();
1459
1460 for i in 0..60 {
1461 let mut meta = HashMap::new();
1462 let _ = meta.insert("title".to_string(), format!("Post {i}"));
1463 let _ = meta.insert("description".to_string(), format!("Desc {i}"));
1464 let _ = meta.insert(
1465 "item_pub_date".to_string(),
1466 format!(
1467 "Thu, {:02} Apr 2026 {:02}:00:00 +0000",
1468 (i % 28) + 1,
1469 i % 24
1470 ),
1471 );
1472 let _ = meta.insert("author".to_string(), "Bot".to_string());
1473 write_meta_sidecar(tmp.path(), &format!("post-{i:03}"), &meta);
1474 }
1475
1476 let ctx = make_atom_ctx(tmp.path());
1477 AtomFeedPlugin.after_compile(&ctx).unwrap();
1478
1479 let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
1480 let entry_count = content.matches("<entry>").count();
1481 assert_eq!(
1482 entry_count, 50,
1483 "Should truncate to 50 entries, got {entry_count}"
1484 );
1485 Ok(())
1486 }
1487
1488 #[test]
1493 fn test_inject_atom_link_multiple_files() -> Result<()> {
1494 let tmp = tempdir().unwrap();
1495 for name in ["index.html", "about.html", "contact.html"] {
1496 fs::write(
1497 tmp.path().join(name),
1498 "<html><head><title>T</title></head><body></body></html>",
1499 )
1500 .unwrap();
1501 }
1502
1503 inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
1504
1505 for name in ["index.html", "about.html", "contact.html"] {
1506 let content = fs::read_to_string(tmp.path().join(name)).unwrap();
1507 assert!(
1508 content.contains("application/atom+xml"),
1509 "{name} should have atom link"
1510 );
1511 }
1512 Ok(())
1513 }
1514
1515 #[test]
1520 fn test_extract_entries_from_rss_malformed_item() {
1521 let tmp = tempdir().unwrap();
1522 let rss = r#"<?xml version="1.0"?>
1524<rss><channel>
1525<item>
1526<title>Unclosed
1527</channel></rss>"#;
1528 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1529 let entries = extract_entries_from_rss(tmp.path());
1530 assert!(entries.is_empty());
1531 }
1532
1533 #[test]
1534 fn test_extract_entries_from_rss_link_trailing_slash() {
1535 let tmp = tempdir().unwrap();
1536 let rss = r#"<?xml version="1.0"?>
1537<rss><channel>
1538<item>
1539<title>Slash Test</title>
1540<link>https://example.com/my-post/</link>
1541</item>
1542</channel></rss>"#;
1543 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1544 let entries = extract_entries_from_rss(tmp.path());
1545 assert_eq!(entries.len(), 1);
1546 assert_eq!(entries[0].0, "my-post");
1547 }
1548
1549 #[test]
1554 fn test_atom_feed_falls_back_to_build_meta_dir() -> Result<()> {
1555 let tmp = tempdir().unwrap();
1556 let site_dir = tmp.path().join("site");
1557 let build_dir = tmp.path().join("build");
1558 let meta_dir = build_dir.join(".meta");
1559 fs::create_dir_all(&site_dir).unwrap();
1560 fs::create_dir_all(&meta_dir).unwrap();
1561
1562 let page_dir = meta_dir.join("fallback-post");
1564 fs::create_dir_all(&page_dir).unwrap();
1565 let mut meta = HashMap::new();
1566 let _ = meta.insert("title".to_string(), "Fallback".to_string());
1567 let _ = meta
1568 .insert("description".to_string(), "From build dir".to_string());
1569 let _ = meta.insert(
1570 "item_pub_date".to_string(),
1571 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1572 );
1573 let json = serde_json::to_string(&meta).unwrap();
1574 fs::write(page_dir.join("index.meta.json"), json).unwrap();
1575
1576 let config = crate::cmd::SsgConfig {
1577 listings: Vec::new(),
1578 base_url: "https://example.com".to_string(),
1579 site_name: "Test".to_string(),
1580 site_title: "Test".to_string(),
1581 site_description: "Test".to_string(),
1582 language: "en".to_string(),
1583 content_dir: std::path::PathBuf::from("content"),
1584 output_dir: build_dir.clone(),
1585 template_dir: std::path::PathBuf::from("templates"),
1586 theme: None,
1587 serve_dir: None,
1588 #[cfg(feature = "i18n")]
1589 i18n: None,
1590 cdn_prefix: None,
1591 og_image: None,
1592 image: crate::cmd::ImageConfig::default(),
1593 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
1594 agents: None,
1595 transitions: false,
1596 security: crate::cmd::SecurityConfig::default(),
1597 no_taxonomy_pages: false,
1598 };
1599 let ctx = PluginContext::with_config(
1600 Path::new("content"),
1601 &build_dir,
1602 &site_dir,
1603 Path::new("templates"),
1604 config,
1605 );
1606
1607 AtomFeedPlugin.after_compile(&ctx).unwrap();
1608
1609 let atom_path = site_dir.join("atom.xml");
1610 assert!(
1611 atom_path.exists(),
1612 "Should create atom.xml from build/.meta"
1613 );
1614 let content = fs::read_to_string(&atom_path).unwrap();
1615 assert!(content.contains("Fallback"));
1616 Ok(())
1617 }
1618
1619 fn sidecar_meta(title: &str) -> HashMap<String, String> {
1624 let mut meta = HashMap::new();
1625 let _ = meta.insert("title".to_string(), title.to_string());
1626 let _ = meta.insert(
1627 "item_pub_date".to_string(),
1628 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1629 );
1630 meta
1631 }
1632
1633 #[test]
1634 fn test_after_compile_errors_when_atom_xml_is_a_directory() {
1635 let tmp = tempdir().unwrap();
1636 write_meta_sidecar(tmp.path(), "post", &sidecar_meta("Post"));
1637 fs::create_dir_all(tmp.path().join("atom.xml")).unwrap();
1639
1640 let ctx = make_atom_ctx(tmp.path());
1641 let err = AtomFeedPlugin.after_compile(&ctx).unwrap_err();
1642 assert!(
1643 format!("{err}").contains("atom.xml"),
1644 "error names the path"
1645 );
1646 }
1647
1648 #[test]
1649 fn test_after_compile_propagates_unreadable_html_error() {
1650 let tmp = tempdir().unwrap();
1651 write_meta_sidecar(tmp.path(), "post", &sidecar_meta("Post"));
1652 fs::write(tmp.path().join("bad.html"), [0xFF, 0xFE, 0xFD]).unwrap();
1654
1655 let ctx = make_atom_ctx(tmp.path());
1656 let err = AtomFeedPlugin.after_compile(&ctx).unwrap_err();
1657 assert!(
1658 format!("{err}").contains("bad.html"),
1659 "error names the file"
1660 );
1661 }
1662
1663 #[test]
1664 #[cfg(unix)]
1665 fn test_inject_atom_link_write_failure_on_readonly_html() {
1666 use std::os::unix::fs::PermissionsExt;
1667 let tmp = tempdir().unwrap();
1668 let html_path = tmp.path().join("index.html");
1669 fs::write(
1670 &html_path,
1671 "<html><head><title>T</title></head><body></body></html>",
1672 )
1673 .unwrap();
1674 fs::set_permissions(&html_path, fs::Permissions::from_mode(0o444))
1675 .unwrap();
1676
1677 let result = inject_atom_link(tmp.path(), "https://x.example/atom.xml");
1678 let _ =
1679 fs::set_permissions(&html_path, fs::Permissions::from_mode(0o644));
1680 let err = result.unwrap_err();
1681 assert!(format!("{err}").contains("index.html"));
1682 }
1683
1684 #[test]
1685 #[cfg(unix)]
1686 fn test_inject_atom_link_walk_failure_on_unreadable_subdir() {
1687 use std::os::unix::fs::PermissionsExt;
1688 let tmp = tempdir().unwrap();
1689 let locked = tmp.path().join("locked");
1690 fs::create_dir_all(&locked).unwrap();
1691 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1692 .unwrap();
1693
1694 let result = inject_atom_link(tmp.path(), "https://x.example/atom.xml");
1695 let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1696 assert!(result.is_err(), "expected an error from the locked path");
1697 }
1698
1699 #[test]
1704 fn test_extract_xml_tag_attr_form_without_closing_bracket() {
1705 assert_eq!(extract_xml_tag("<title attr=x no-gt", "title"), None);
1708 }
1709
1710 #[test]
1711 fn test_extract_xml_tag_missing_end_tag() {
1712 assert_eq!(extract_xml_tag("<title>abc", "title"), None);
1713 }
1714}