1use super::helpers::{parse_rfc2822_lenient, read_meta_sidecars, xml_escape};
7use crate::plugin::{Plugin, PluginContext};
8use anyhow::{Context, Result};
9use std::fs;
10use std::path::Path;
11
12#[derive(Debug, Clone, Copy)]
17pub struct AtomFeedPlugin;
18
19impl Plugin for AtomFeedPlugin {
20 fn name(&self) -> &'static str {
21 "atom-feed"
22 }
23
24 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
25 let mut meta_entries =
26 read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
27
28 if meta_entries.is_empty() {
32 let meta_dir = ctx.build_dir.join(".meta");
33 if meta_dir.exists() {
34 meta_entries =
35 read_meta_sidecars(&meta_dir).unwrap_or_default();
36 }
37 }
38
39 if meta_entries.is_empty() {
42 meta_entries = extract_entries_from_rss(&ctx.site_dir);
43 }
44
45 let base_url = ctx
46 .config
47 .as_ref()
48 .map(|c| c.base_url.trim_end_matches('/').to_string())
49 .unwrap_or_default();
50
51 let site_name = ctx
52 .config
53 .as_ref()
54 .map(|c| c.site_name.clone())
55 .unwrap_or_default();
56
57 let feed_title = if site_name.is_empty() {
58 "Untitled".to_string()
59 } else {
60 site_name
61 };
62
63 let mut articles = collect_atom_entries(&meta_entries, &base_url);
64 articles.sort_by(|a, b| b.0.cmp(&a.0));
65 articles.truncate(50);
66
67 if articles.is_empty() {
68 return Ok(());
69 }
70
71 let feed_xml = build_atom_feed(&feed_title, &base_url, &articles);
72
73 let atom_path = ctx.site_dir.join("atom.xml");
74 fs::write(&atom_path, &feed_xml)
75 .with_context(|| format!("cannot write {}", atom_path.display()))?;
76
77 let atom_self_link = if base_url.is_empty() {
78 "atom.xml".to_string()
79 } else {
80 format!("{base_url}/atom.xml")
81 };
82 inject_atom_link(&ctx.site_dir, &atom_self_link)?;
83
84 log::info!(
85 "[atom-feed] Generated atom.xml with {} entries",
86 articles.len()
87 );
88 Ok(())
89 }
90}
91
92fn collect_atom_entries(
94 meta_entries: &[(String, std::collections::HashMap<String, String>)],
95 base_url: &str,
96) -> Vec<(String, AtomEntry)> {
97 let mut articles = Vec::new();
98 for (rel_path, meta) in meta_entries {
99 if let Some(entry) = build_atom_entry(rel_path, meta, base_url) {
100 articles.push(entry);
101 }
102 }
103 articles
104}
105
106fn build_atom_entry(
108 rel_path: &str,
109 meta: &std::collections::HashMap<String, String>,
110 base_url: &str,
111) -> Option<(String, AtomEntry)> {
112 if rel_path.is_empty() {
113 return None;
114 }
115
116 let title = meta.get("title").cloned().unwrap_or_default();
117 if title.is_empty() {
118 return None;
119 }
120
121 let description = meta.get("description").cloned().unwrap_or_default();
122 let pub_date = meta.get("item_pub_date").cloned().unwrap_or_default();
123 let author = meta.get("author").cloned().unwrap_or_default();
124
125 let link = if base_url.is_empty() {
126 format!("{rel_path}/")
127 } else {
128 format!("{base_url}/{rel_path}/")
129 };
130
131 let rfc3339 = parse_rfc2822_lenient(&pub_date)
132 .map_or_else(|| pub_date.clone(), |dt| dt.to_rfc3339());
133
134 Some((
135 rfc3339.clone(),
136 AtomEntry {
137 title,
138 link: link.clone(),
139 id: link,
140 updated: rfc3339.clone(),
141 published: rfc3339,
142 summary: description,
143 author,
144 },
145 ))
146}
147
148fn build_atom_feed(
150 feed_title: &str,
151 base_url: &str,
152 articles: &[(String, AtomEntry)],
153) -> String {
154 let feed_updated = &articles[0].0;
155 let entries_xml: String = articles
156 .iter()
157 .map(|(_, entry)| entry.to_xml())
158 .collect::<Vec<_>>()
159 .join("\n");
160
161 let atom_self_link = if base_url.is_empty() {
162 "atom.xml".to_string()
163 } else {
164 format!("{base_url}/atom.xml")
165 };
166
167 let feed_id = if base_url.is_empty() {
168 "/".to_string()
169 } else {
170 base_url.to_string()
171 };
172
173 format!(
174 r#"<?xml version="1.0" encoding="UTF-8"?>
175<feed xmlns="http://www.w3.org/2005/Atom">
176 <title>{feed_title}</title>
177 <link href="{atom_self_link}" rel="self" type="application/atom+xml"/>
178 <link href="{base_url}"/>
179 <id>{feed_id}</id>
180 <updated>{feed_updated}</updated>
181{entries_xml}
182</feed>
183"#,
184 feed_title = xml_escape(feed_title),
185 )
186}
187
188pub(super) struct AtomEntry {
190 pub title: String,
191 pub link: String,
192 pub id: String,
193 pub updated: String,
194 pub published: String,
195 pub summary: String,
196 pub author: String,
197}
198
199impl AtomEntry {
200 pub(super) fn to_xml(&self) -> String {
201 let author_name = if self.author.is_empty() {
202 "Unknown".to_string()
203 } else {
204 xml_escape(&self.author)
205 };
206 format!(
207 r#" <entry>
208 <title>{title}</title>
209 <link href="{link}"/>
210 <id>{id}</id>
211 <updated>{updated}</updated>
212 <published>{published}</published>
213 <summary>{summary}</summary>
214 <author><name>{author}</name></author>
215 </entry>"#,
216 title = xml_escape(&self.title),
217 link = xml_escape(&self.link),
218 id = xml_escape(&self.id),
219 updated = xml_escape(&self.updated),
220 published = xml_escape(&self.published),
221 summary = xml_escape(&self.summary),
222 author = author_name,
223 )
224 }
225}
226
227fn extract_entries_from_rss(
230 site_dir: &Path,
231) -> Vec<(String, std::collections::HashMap<String, String>)> {
232 let rss_path = site_dir.join("rss.xml");
233 let Ok(rss_content) = fs::read_to_string(&rss_path) else {
234 return Vec::new();
235 };
236
237 let mut entries = Vec::new();
238
239 let mut search_from = 0;
241 while let Some(item_start) = rss_content[search_from..].find("<item>") {
242 let abs_start = search_from + item_start;
243 let Some(item_end) = rss_content[abs_start..].find("</item>") else {
244 break;
245 };
246 let item = &rss_content[abs_start..abs_start + item_end + 7];
247
248 let mut meta = std::collections::HashMap::new();
249 if let Some(title) = extract_xml_tag(item, "title") {
250 let _ = meta.insert("title".to_string(), title);
251 }
252 if let Some(desc) = extract_xml_tag(item, "description") {
253 let _ = meta.insert("description".to_string(), desc);
254 }
255 if let Some(date) = extract_xml_tag(item, "pubDate") {
256 let _ = meta.insert("item_pub_date".to_string(), date);
257 }
258 if let Some(author) = extract_xml_tag(item, "author") {
259 let _ = meta.insert("author".to_string(), author);
260 }
261
262 let rel_path = extract_xml_tag(item, "link")
264 .map(|link| {
265 link.trim_end_matches('/')
266 .rsplit('/')
267 .next()
268 .unwrap_or("")
269 .to_string()
270 })
271 .unwrap_or_default();
272
273 if !rel_path.is_empty() && meta.contains_key("title") {
274 entries.push((rel_path, meta));
275 }
276
277 search_from = abs_start + item_end + 7;
278 }
279
280 entries
281}
282
283fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
288 let open_plain = format!("<{tag}>");
290 let open_attr = format!("<{tag} ");
291 let close = format!("</{tag}>");
292
293 let (start, content_start) = if let Some(pos) = xml.find(&open_plain) {
294 (pos, pos + open_plain.len())
295 } else if let Some(pos) = xml.find(&open_attr) {
296 let gt = xml[pos..].find('>')?;
297 (pos, pos + gt + 1)
298 } else {
299 return None;
300 };
301
302 let _ = start; let end = xml[content_start..].find(&close)? + content_start;
304 let content = xml[content_start..end].trim();
305
306 let content = content
308 .strip_prefix("<![CDATA[")
309 .and_then(|s| s.strip_suffix("]]>"))
310 .unwrap_or(content);
311
312 let decoded = content
314 .replace("&", "&")
315 .replace("<", "<")
316 .replace(">", ">")
317 .replace(""", "\"")
318 .replace("'", "'");
319
320 let decoded = decoded.trim();
321 if decoded.is_empty() {
322 None
323 } else {
324 Some(xml_escape(decoded))
325 }
326}
327
328pub(super) fn inject_atom_link(site_dir: &Path, atom_url: &str) -> Result<()> {
331 let html_files = crate::walk::walk_files(site_dir, "html")?;
332 for path in &html_files {
333 let html = fs::read_to_string(path)
334 .with_context(|| format!("cannot read {}", path.display()))?;
335
336 if html.contains("application/atom+xml") {
337 continue;
338 }
339
340 if let Some(pos) = html.find("</head>") {
342 let link_tag = format!(
343 " <link rel=\"alternate\" type=\"application/atom+xml\" title=\"Atom Feed\" href=\"{atom_url}\"/>\n"
344 );
345 let modified =
346 format!("{}{}{}", &html[..pos], link_tag, &html[pos..]);
347 fs::write(path, &modified)
348 .with_context(|| format!("cannot write {}", path.display()))?;
349 }
350 }
351 Ok(())
352}
353
354#[cfg(test)]
355#[allow(clippy::unwrap_used, clippy::expect_used)]
356mod tests {
357 use super::*;
358 use crate::plugin::PluginContext;
359 use std::collections::HashMap;
360 use std::path::Path;
361 use tempfile::tempdir;
362
363 fn write_meta_sidecar(
364 dir: &Path,
365 slug: &str,
366 meta: &HashMap<String, String>,
367 ) {
368 let page_dir = dir.join(slug);
369 fs::create_dir_all(&page_dir).expect("create page dir");
370 let meta_path = page_dir.join("page.meta.json");
371 let json = serde_json::to_string(meta).expect("serialize meta");
372 fs::write(&meta_path, json).expect("write meta");
373 }
374
375 fn make_atom_ctx(site_dir: &Path) -> PluginContext {
376 crate::test_support::init_logger();
377 let config = crate::cmd::SsgConfig {
378 base_url: "https://example.com".to_string(),
379 site_name: "Test Site".to_string(),
380 site_title: "Test Site".to_string(),
381 site_description: "A test site".to_string(),
382 language: "en".to_string(),
383 content_dir: std::path::PathBuf::from("content"),
384 output_dir: std::path::PathBuf::from("build"),
385 template_dir: std::path::PathBuf::from("templates"),
386 serve_dir: None,
387 i18n: None,
388 };
389 PluginContext::with_config(
390 Path::new("content"),
391 Path::new("build"),
392 site_dir,
393 Path::new("templates"),
394 config,
395 )
396 }
397
398 #[test]
399 fn test_atom_feed_valid_namespace_and_elements() -> Result<()> {
400 let tmp = tempdir()?;
401
402 let mut meta = HashMap::new();
403 let _ = meta.insert("title".to_string(), "Hello World".to_string());
404 let _ =
405 meta.insert("description".to_string(), "A test post".to_string());
406 let _ = meta.insert(
407 "item_pub_date".to_string(),
408 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
409 );
410 let _ = meta.insert("author".to_string(), "Alice".to_string());
411 write_meta_sidecar(tmp.path(), "hello", &meta);
412
413 let ctx = make_atom_ctx(tmp.path());
414 AtomFeedPlugin.after_compile(&ctx)?;
415
416 let atom_path = tmp.path().join("atom.xml");
417 assert!(atom_path.exists(), "atom.xml should be created");
418
419 let content = fs::read_to_string(&atom_path)?;
420 assert!(
421 content.contains("xmlns=\"http://www.w3.org/2005/Atom\""),
422 "Missing Atom namespace"
423 );
424 assert!(content.contains("<feed"), "Missing <feed> element");
425 assert!(content.contains("<title>"), "Missing <title>");
426 assert!(content.contains("rel=\"self\""), "Missing self link");
427 assert!(content.contains("<id>"), "Missing <id>");
428 assert!(content.contains("<updated>"), "Missing <updated>");
429 assert!(content.contains("<entry>"), "Missing <entry>");
430 assert!(content.contains("<author>"), "Missing <author>");
431 assert!(
432 content.contains("<name>Alice</name>"),
433 "Missing author name"
434 );
435 assert!(content.contains("<summary>"), "Missing <summary>");
436 assert!(content.contains("<published>"), "Missing <published>");
437 Ok(())
438 }
439
440 #[test]
441 fn test_atom_feed_entry_count_matches() -> Result<()> {
442 let tmp = tempdir()?;
443
444 for i in 0..5 {
445 let mut meta = HashMap::new();
446 let _ = meta.insert("title".to_string(), format!("Post {i}"));
447 let _ = meta.insert("description".to_string(), format!("Desc {i}"));
448 let _ = meta.insert(
449 "item_pub_date".to_string(),
450 format!("Thu, {:02} Apr 2026 06:06:06 +0000", 10 + i),
451 );
452 let _ = meta.insert("author".to_string(), "Bob".to_string());
453 write_meta_sidecar(tmp.path(), &format!("post-{i}"), &meta);
454 }
455
456 let ctx = make_atom_ctx(tmp.path());
457 AtomFeedPlugin.after_compile(&ctx)?;
458
459 let content = fs::read_to_string(tmp.path().join("atom.xml"))?;
460 let entry_count = content.matches("<entry>").count();
461 assert_eq!(entry_count, 5, "Expected 5 entries, got {entry_count}");
462 Ok(())
463 }
464
465 #[test]
466 fn test_atom_feed_dates_are_rfc3339() -> Result<()> {
467 let tmp = tempdir()?;
468
469 let mut meta = HashMap::new();
470 let _ = meta.insert("title".to_string(), "Date Test".to_string());
471 let _ =
472 meta.insert("description".to_string(), "Testing dates".to_string());
473 let _ = meta.insert(
474 "item_pub_date".to_string(),
475 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
476 );
477 let _ = meta.insert("author".to_string(), "Charlie".to_string());
478 write_meta_sidecar(tmp.path(), "datepost", &meta);
479
480 let ctx = make_atom_ctx(tmp.path());
481 AtomFeedPlugin.after_compile(&ctx)?;
482
483 let content = fs::read_to_string(tmp.path().join("atom.xml"))?;
484 assert!(
485 content.contains("2026-04-11T06:06:06+00:00"),
486 "Expected RFC 3339 date in atom.xml, got:\n{content}"
487 );
488 Ok(())
489 }
490
491 #[test]
492 fn test_atom_feed_idempotent() -> Result<()> {
493 let tmp = tempdir()?;
494
495 let mut meta = HashMap::new();
496 let _ = meta.insert("title".to_string(), "Idempotent".to_string());
497 let _ = meta.insert("description".to_string(), "Test".to_string());
498 let _ = meta.insert(
499 "item_pub_date".to_string(),
500 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
501 );
502 let _ = meta.insert("author".to_string(), "Dave".to_string());
503 write_meta_sidecar(tmp.path(), "idem", &meta);
504
505 let ctx = make_atom_ctx(tmp.path());
506 AtomFeedPlugin.after_compile(&ctx)?;
507 let first = fs::read_to_string(tmp.path().join("atom.xml"))?;
508
509 AtomFeedPlugin.after_compile(&ctx)?;
510 let second = fs::read_to_string(tmp.path().join("atom.xml"))?;
511
512 assert_eq!(first, second, "Atom feed should be idempotent");
513 Ok(())
514 }
515
516 #[test]
517 fn test_atom_feed_injects_link_into_html() -> Result<()> {
518 let tmp = tempdir()?;
519
520 let mut meta = HashMap::new();
521 let _ = meta.insert("title".to_string(), "Link Test".to_string());
522 let _ = meta.insert("description".to_string(), "Test".to_string());
523 let _ = meta.insert(
524 "item_pub_date".to_string(),
525 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
526 );
527 let _ = meta.insert("author".to_string(), "Eve".to_string());
528 write_meta_sidecar(tmp.path(), "linktest", &meta);
529
530 let html_path = tmp.path().join("index.html");
531 fs::write(
532 &html_path,
533 "<html><head><title>Test</title></head><body></body></html>",
534 )?;
535
536 let ctx = make_atom_ctx(tmp.path());
537 AtomFeedPlugin.after_compile(&ctx)?;
538
539 let html = fs::read_to_string(&html_path)?;
540 assert!(
541 html.contains("application/atom+xml"),
542 "HTML should have atom link tag"
543 );
544 Ok(())
545 }
546
547 #[test]
548 fn test_atom_plugin_registers() {
549 use crate::plugin::PluginManager;
550 let mut pm = PluginManager::new();
551 pm.register(AtomFeedPlugin);
552 assert_eq!(pm.len(), 1);
553 assert_eq!(pm.names(), vec!["atom-feed"]);
554 }
555
556 #[test]
557 fn test_atom_feed_sorts_descending() -> Result<()> {
558 let tmp = tempdir()?;
559
560 let mut meta_old = HashMap::new();
561 let _ = meta_old.insert("title".to_string(), "Old Post".to_string());
562 let _ = meta_old.insert("description".to_string(), "old".to_string());
563 let _ = meta_old.insert(
564 "item_pub_date".to_string(),
565 "Mon, 01 Jan 2025 00:00:00 +0000".to_string(),
566 );
567 let _ = meta_old.insert("author".to_string(), "Alice".to_string());
568 write_meta_sidecar(tmp.path(), "old-post", &meta_old);
569
570 let mut meta_new = HashMap::new();
571 let _ = meta_new.insert("title".to_string(), "New Post".to_string());
572 let _ = meta_new.insert("description".to_string(), "new".to_string());
573 let _ = meta_new.insert(
574 "item_pub_date".to_string(),
575 "Fri, 11 Apr 2026 12:00:00 +0000".to_string(),
576 );
577 let _ = meta_new.insert("author".to_string(), "Bob".to_string());
578 write_meta_sidecar(tmp.path(), "new-post", &meta_new);
579
580 let ctx = make_atom_ctx(tmp.path());
581 AtomFeedPlugin.after_compile(&ctx)?;
582
583 let content = fs::read_to_string(tmp.path().join("atom.xml"))?;
584 let first_entry_pos = content.find("<entry>").unwrap();
585 let new_title_pos = content.find("New Post").unwrap();
586 let old_title_pos = content.find("Old Post").unwrap();
587 assert!(
588 new_title_pos < old_title_pos,
589 "Newer post should come first"
590 );
591 assert!(
592 new_title_pos > first_entry_pos,
593 "Title should be inside an entry"
594 );
595 Ok(())
596 }
597
598 #[test]
599 fn test_atom_feed_empty_author_shows_unknown() -> Result<()> {
600 let tmp = tempdir()?;
601
602 let mut meta = HashMap::new();
603 let _ = meta.insert("title".to_string(), "No Author".to_string());
604 let _ = meta.insert("description".to_string(), "test".to_string());
605 let _ = meta.insert(
606 "item_pub_date".to_string(),
607 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
608 );
609 write_meta_sidecar(tmp.path(), "no-author", &meta);
610
611 let ctx = make_atom_ctx(tmp.path());
612 AtomFeedPlugin.after_compile(&ctx)?;
613
614 let content = fs::read_to_string(tmp.path().join("atom.xml"))?;
615 assert!(
616 content.contains("<name>Unknown</name>"),
617 "Empty author should show 'Unknown': {content}"
618 );
619 Ok(())
620 }
621
622 #[test]
623 fn test_atom_feed_skips_empty_title() -> Result<()> {
624 let tmp = tempdir()?;
625
626 let mut meta = HashMap::new();
627 let _ = meta.insert("title".to_string(), String::new());
628 let _ = meta.insert("description".to_string(), "test".to_string());
629 let _ = meta.insert(
630 "item_pub_date".to_string(),
631 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
632 );
633 write_meta_sidecar(tmp.path(), "no-title", &meta);
634
635 let ctx = make_atom_ctx(tmp.path());
636 AtomFeedPlugin.after_compile(&ctx)?;
637
638 let atom_path = tmp.path().join("atom.xml");
639 assert!(
640 !atom_path.exists(),
641 "Should not create atom.xml when all entries have empty titles"
642 );
643 Ok(())
644 }
645
646 #[test]
647 fn test_atom_feed_xml_escapes_content() -> Result<()> {
648 let tmp = tempdir()?;
649
650 let mut meta = HashMap::new();
651 let _ = meta
652 .insert("title".to_string(), "Tom & Jerry <friends>".to_string());
653 let _ = meta
654 .insert("description".to_string(), "A \"great\" show".to_string());
655 let _ = meta.insert(
656 "item_pub_date".to_string(),
657 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
658 );
659 let _ = meta.insert("author".to_string(), "O'Brien".to_string());
660 write_meta_sidecar(tmp.path(), "escape-test", &meta);
661
662 let ctx = make_atom_ctx(tmp.path());
663 AtomFeedPlugin.after_compile(&ctx)?;
664
665 let content = fs::read_to_string(tmp.path().join("atom.xml"))?;
666 assert!(content.contains("Tom & Jerry"), "& should be escaped");
667 assert!(
668 content.contains("<friends>"),
669 "< and > should be escaped"
670 );
671 assert!(
672 content.contains(""great""),
673 "quotes should be escaped"
674 );
675 assert!(
676 content.contains("O'Brien"),
677 "apostrophe should be escaped"
678 );
679 Ok(())
680 }
681
682 #[test]
687 fn test_atom_entry_to_xml() {
688 let entry = AtomEntry {
689 title: "Test Post".to_string(),
690 link: "https://example.com/test/".to_string(),
691 id: "https://example.com/test/".to_string(),
692 updated: "2026-04-11T06:06:06+00:00".to_string(),
693 published: "2026-04-11T06:06:06+00:00".to_string(),
694 summary: "A test summary".to_string(),
695 author: "Alice".to_string(),
696 };
697 let xml = entry.to_xml();
698 assert!(xml.contains("<entry>"));
699 assert!(xml.contains("</entry>"));
700 assert!(xml.contains("<title>Test Post</title>"));
701 assert!(xml.contains("href=\"https://example.com/test/\""));
702 assert!(xml.contains("<name>Alice</name>"));
703 assert!(xml.contains("<summary>A test summary</summary>"));
704 }
705
706 #[test]
707 fn test_atom_entry_empty_author() {
708 let entry = AtomEntry {
709 title: "No Author".to_string(),
710 link: "https://example.com/".to_string(),
711 id: "https://example.com/".to_string(),
712 updated: "2026-01-01T00:00:00+00:00".to_string(),
713 published: "2026-01-01T00:00:00+00:00".to_string(),
714 summary: String::new(),
715 author: String::new(),
716 };
717 let xml = entry.to_xml();
718 assert!(
719 xml.contains("<name>Unknown</name>"),
720 "Empty author should show 'Unknown'"
721 );
722 }
723
724 #[test]
729 fn test_inject_atom_link_adds_tag() -> Result<()> {
730 let tmp = tempdir()?;
731 let html_path = tmp.path().join("page.html");
732 fs::write(
733 &html_path,
734 "<html><head><title>Test</title></head><body></body></html>",
735 )?;
736
737 inject_atom_link(tmp.path(), "https://example.com/atom.xml")?;
738
739 let result = fs::read_to_string(&html_path)?;
740 assert!(
741 result.contains("application/atom+xml"),
742 "Should inject atom link: {result}"
743 );
744 assert!(
745 result.contains("href=\"https://example.com/atom.xml\""),
746 "Should have correct href: {result}"
747 );
748 Ok(())
749 }
750
751 #[test]
752 fn test_inject_atom_link_idempotent() -> Result<()> {
753 let tmp = tempdir()?;
754 let html_path = tmp.path().join("page.html");
755 fs::write(
756 &html_path,
757 "<html><head><title>Test</title></head><body></body></html>",
758 )?;
759
760 inject_atom_link(tmp.path(), "https://example.com/atom.xml")?;
761 let first = fs::read_to_string(&html_path)?;
762
763 inject_atom_link(tmp.path(), "https://example.com/atom.xml")?;
764 let second = fs::read_to_string(&html_path)?;
765
766 assert_eq!(first, second, "inject_atom_link should be idempotent");
767 assert_eq!(
768 second.matches("application/atom+xml").count(),
769 1,
770 "Should have exactly one atom link"
771 );
772 Ok(())
773 }
774
775 #[test]
776 fn test_inject_atom_link_no_head() -> Result<()> {
777 let tmp = tempdir()?;
778 let html_path = tmp.path().join("nohead.html");
779 fs::write(&html_path, "<html><body>No head</body></html>")?;
780
781 inject_atom_link(tmp.path(), "https://example.com/atom.xml")?;
782
783 let result = fs::read_to_string(&html_path)?;
784 assert!(
785 !result.contains("application/atom+xml"),
786 "Should not inject when there is no </head>"
787 );
788 Ok(())
789 }
790
791 #[test]
796 fn test_atom_feed_plugin_name() {
797 let plugin = AtomFeedPlugin;
798 assert_eq!(plugin.name(), "atom-feed");
799 }
800
801 #[test]
802 fn test_atom_feed_plugin_debug() {
803 let plugin = AtomFeedPlugin;
804 let debug = format!("{plugin:?}");
805 assert!(debug.contains("AtomFeedPlugin"));
806 }
807
808 #[test]
809 fn test_atom_feed_plugin_clone_copy() {
810 let plugin = AtomFeedPlugin;
811 let cloned = plugin;
812 assert_eq!(cloned.name(), "atom-feed");
813 }
814
815 #[test]
820 fn test_atom_feed_empty_site_dir() -> Result<()> {
821 let tmp = tempdir()?;
822 let ctx = make_atom_ctx(tmp.path());
824 AtomFeedPlugin.after_compile(&ctx)?;
825
826 let atom_path = tmp.path().join("atom.xml");
827 assert!(
828 !atom_path.exists(),
829 "Should not create atom.xml with no entries"
830 );
831 Ok(())
832 }
833
834 #[test]
839 fn test_atom_feed_falls_back_to_rss_xml() -> Result<()> {
840 let tmp = tempdir()?;
841 let rss_content = r#"<?xml version="1.0"?>
843<rss version="2.0">
844<channel>
845<title>Test</title>
846<item>
847<title>From RSS</title>
848<description>Extracted from RSS</description>
849<link>https://example.com/rss-post/</link>
850<pubDate>Thu, 11 Apr 2026 06:06:06 +0000</pubDate>
851<author>Alice</author>
852</item>
853</channel>
854</rss>"#;
855 fs::write(tmp.path().join("rss.xml"), rss_content)?;
856
857 let ctx = make_atom_ctx(tmp.path());
858 AtomFeedPlugin.after_compile(&ctx)?;
859
860 let atom_path = tmp.path().join("atom.xml");
861 assert!(atom_path.exists(), "Should create atom.xml from rss.xml");
862 let content = fs::read_to_string(&atom_path)?;
863 assert!(
864 content.contains("From RSS"),
865 "Should contain entry from rss.xml"
866 );
867 Ok(())
868 }
869
870 #[test]
871 fn test_atom_feed_rss_multiple_items() -> Result<()> {
872 let tmp = tempdir()?;
873 let rss_content = r#"<?xml version="1.0"?>
874<rss version="2.0">
875<channel>
876<title>Test</title>
877<item>
878<title>Post A</title>
879<description>Desc A</description>
880<link>https://example.com/post-a/</link>
881<pubDate>Thu, 10 Apr 2026 00:00:00 +0000</pubDate>
882</item>
883<item>
884<title>Post B</title>
885<description>Desc B</description>
886<link>https://example.com/post-b/</link>
887<pubDate>Fri, 11 Apr 2026 00:00:00 +0000</pubDate>
888</item>
889</channel>
890</rss>"#;
891 fs::write(tmp.path().join("rss.xml"), rss_content)?;
892
893 let ctx = make_atom_ctx(tmp.path());
894 AtomFeedPlugin.after_compile(&ctx)?;
895
896 let content = fs::read_to_string(tmp.path().join("atom.xml"))?;
897 assert!(content.contains("Post A"));
898 assert!(content.contains("Post B"));
899 let entry_count = content.matches("<entry>").count();
900 assert_eq!(entry_count, 2);
901 Ok(())
902 }
903
904 #[test]
905 fn test_atom_feed_rss_with_cdata() -> Result<()> {
906 let tmp = tempdir()?;
907 let rss_content = r#"<?xml version="1.0"?>
908<rss version="2.0">
909<channel>
910<title>Test</title>
911<item>
912<title><![CDATA[CDATA Title]]></title>
913<description><![CDATA[CDATA Description]]></description>
914<link>https://example.com/cdata-post/</link>
915<pubDate>Thu, 11 Apr 2026 06:06:06 +0000</pubDate>
916</item>
917</channel>
918</rss>"#;
919 fs::write(tmp.path().join("rss.xml"), rss_content)?;
920
921 let ctx = make_atom_ctx(tmp.path());
922 AtomFeedPlugin.after_compile(&ctx)?;
923
924 let content = fs::read_to_string(tmp.path().join("atom.xml"))?;
925 assert!(content.contains("CDATA Title"), "Should unwrap CDATA");
926 Ok(())
927 }
928
929 #[test]
934 fn test_extract_xml_tag_simple() {
935 let xml = "<item><title>Hello</title></item>";
936 assert_eq!(extract_xml_tag(xml, "title"), Some("Hello".to_string()));
937 }
938
939 #[test]
940 fn test_extract_xml_tag_with_attributes() {
941 let xml = r#"<item><link href="http://example.com">text</link></item>"#;
942 assert_eq!(extract_xml_tag(xml, "link"), Some("text".to_string()));
943 }
944
945 #[test]
946 fn test_extract_xml_tag_missing() {
947 let xml = "<item><title>Hello</title></item>";
948 assert_eq!(extract_xml_tag(xml, "author"), None);
949 }
950
951 #[test]
952 fn test_extract_xml_tag_empty_content() {
953 let xml = "<item><title></title></item>";
954 assert_eq!(extract_xml_tag(xml, "title"), None);
955 }
956
957 #[test]
958 fn test_extract_xml_tag_cdata() {
959 let xml = "<item><title><![CDATA[My Title]]></title></item>";
960 assert_eq!(extract_xml_tag(xml, "title"), Some("My Title".to_string()));
961 }
962
963 #[test]
964 fn test_extract_xml_tag_decodes_entities() {
965 let xml = "<item><title>Tom & Jerry</title></item>";
966 let result = extract_xml_tag(xml, "title").unwrap();
968 assert!(
969 result.contains("Tom") && result.contains("Jerry"),
970 "Should contain decoded text: {result}"
971 );
972 }
973
974 #[test]
975 fn test_extract_xml_tag_whitespace() {
976 let xml = "<item><title> Hello World </title></item>";
977 assert_eq!(
978 extract_xml_tag(xml, "title"),
979 Some("Hello World".to_string())
980 );
981 }
982
983 #[test]
988 fn test_extract_entries_from_rss_no_file() {
989 let tmp = tempdir().unwrap();
990 let entries = extract_entries_from_rss(tmp.path());
991 assert!(entries.is_empty());
992 }
993
994 #[test]
995 fn test_extract_entries_from_rss_empty_rss() {
996 let tmp = tempdir().unwrap();
997 fs::write(
998 tmp.path().join("rss.xml"),
999 r#"<?xml version="1.0"?><rss><channel></channel></rss>"#,
1000 )
1001 .unwrap();
1002 let entries = extract_entries_from_rss(tmp.path());
1003 assert!(entries.is_empty());
1004 }
1005
1006 #[test]
1007 fn test_extract_entries_from_rss_item_without_title() {
1008 let tmp = tempdir().unwrap();
1009 let rss = r#"<?xml version="1.0"?>
1010<rss><channel>
1011<item>
1012<description>No title item</description>
1013<link>https://example.com/no-title/</link>
1014</item>
1015</channel></rss>"#;
1016 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1017 let entries = extract_entries_from_rss(tmp.path());
1018 assert!(entries.is_empty());
1022 }
1023
1024 #[test]
1025 fn test_extract_entries_from_rss_item_without_link() {
1026 let tmp = tempdir().unwrap();
1027 let rss = r#"<?xml version="1.0"?>
1028<rss><channel>
1029<item>
1030<title>No Link</title>
1031<description>No link item</description>
1032</item>
1033</channel></rss>"#;
1034 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1035 let entries = extract_entries_from_rss(tmp.path());
1036 assert!(entries.is_empty());
1038 }
1039
1040 #[test]
1045 fn test_build_atom_entry_empty_rel_path() {
1046 let meta = HashMap::new();
1047 assert!(build_atom_entry("", &meta, "https://example.com").is_none());
1048 }
1049
1050 #[test]
1051 fn test_build_atom_entry_empty_title() {
1052 let mut meta = HashMap::new();
1053 let _ = meta.insert("title".to_string(), String::new());
1054 assert!(
1055 build_atom_entry("page", &meta, "https://example.com").is_none()
1056 );
1057 }
1058
1059 #[test]
1060 fn test_build_atom_entry_minimal() {
1061 let mut meta = HashMap::new();
1062 let _ = meta.insert("title".to_string(), "Test".to_string());
1063 let result = build_atom_entry("page", &meta, "https://example.com");
1064 assert!(result.is_some());
1065 let (date_key, entry) = result.unwrap();
1066 assert_eq!(entry.title, "Test");
1067 assert!(entry.link.contains("example.com/page/"));
1068 assert!(entry.author.is_empty());
1069 assert!(date_key.is_empty());
1071 }
1072
1073 #[test]
1074 fn test_build_atom_entry_empty_base_url() {
1075 let mut meta = HashMap::new();
1076 let _ = meta.insert("title".to_string(), "Test".to_string());
1077 let result = build_atom_entry("page", &meta, "");
1078 assert!(result.is_some());
1079 let (_, entry) = result.unwrap();
1080 assert_eq!(entry.link, "page/");
1081 }
1082
1083 #[test]
1084 fn test_build_atom_entry_with_all_fields() {
1085 let mut meta = HashMap::new();
1086 let _ = meta.insert("title".to_string(), "Full Entry".to_string());
1087 let _ =
1088 meta.insert("description".to_string(), "A description".to_string());
1089 let _ = meta.insert(
1090 "item_pub_date".to_string(),
1091 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1092 );
1093 let _ = meta.insert("author".to_string(), "Alice".to_string());
1094
1095 let result = build_atom_entry("full", &meta, "https://example.com");
1096 let (date_key, entry) = result.unwrap();
1097 assert_eq!(entry.title, "Full Entry");
1098 assert_eq!(entry.summary, "A description");
1099 assert_eq!(entry.author, "Alice");
1100 assert!(date_key.contains("2026"));
1101 }
1102
1103 #[test]
1104 fn test_build_atom_entry_unparseable_date() {
1105 let mut meta = HashMap::new();
1106 let _ = meta.insert("title".to_string(), "Bad Date".to_string());
1107 let _ =
1108 meta.insert("item_pub_date".to_string(), "not-a-date".to_string());
1109
1110 let result = build_atom_entry("baddate", &meta, "https://example.com");
1111 let (date_key, _) = result.unwrap();
1112 assert_eq!(date_key, "not-a-date");
1114 }
1115
1116 #[test]
1121 fn test_collect_atom_entries_empty() {
1122 let entries: Vec<(String, HashMap<String, String>)> = vec![];
1123 let result = collect_atom_entries(&entries, "https://example.com");
1124 assert!(result.is_empty());
1125 }
1126
1127 #[test]
1128 fn test_collect_atom_entries_filters_invalid() {
1129 let mut meta1 = HashMap::new();
1130 let _ = meta1.insert("title".to_string(), "Valid".to_string());
1131 let mut meta2 = HashMap::new();
1132 let _ = meta2.insert("title".to_string(), String::new()); let entries =
1135 vec![("valid".to_string(), meta1), ("invalid".to_string(), meta2)];
1136 let result = collect_atom_entries(&entries, "https://example.com");
1137 assert_eq!(result.len(), 1);
1138 assert_eq!(result[0].1.title, "Valid");
1139 }
1140
1141 #[test]
1146 fn test_build_atom_feed_structure() {
1147 let entry = AtomEntry {
1148 title: "Feed Test".to_string(),
1149 link: "https://example.com/test/".to_string(),
1150 id: "https://example.com/test/".to_string(),
1151 updated: "2026-04-11T00:00:00+00:00".to_string(),
1152 published: "2026-04-11T00:00:00+00:00".to_string(),
1153 summary: "Summary".to_string(),
1154 author: "Bob".to_string(),
1155 };
1156 let articles = vec![("2026-04-11T00:00:00+00:00".to_string(), entry)];
1157 let xml = build_atom_feed("My Feed", "https://example.com", &articles);
1158 assert!(xml.starts_with("<?xml"));
1159 assert!(xml.contains("xmlns=\"http://www.w3.org/2005/Atom\""));
1160 assert!(xml.contains("<title>My Feed</title>"));
1161 assert!(xml.contains("rel=\"self\""));
1162 assert!(xml.contains("https://example.com/atom.xml"));
1163 assert!(xml.contains("<id>https://example.com</id>"));
1164 assert!(xml.contains("Feed Test"));
1165 }
1166
1167 #[test]
1168 fn test_build_atom_feed_empty_base_url() {
1169 let entry = AtomEntry {
1170 title: "Test".to_string(),
1171 link: "test/".to_string(),
1172 id: "test/".to_string(),
1173 updated: "2026-01-01T00:00:00+00:00".to_string(),
1174 published: "2026-01-01T00:00:00+00:00".to_string(),
1175 summary: String::new(),
1176 author: String::new(),
1177 };
1178 let articles = vec![("2026-01-01T00:00:00+00:00".to_string(), entry)];
1179 let xml = build_atom_feed("Untitled", "", &articles);
1180 assert!(xml.contains("<id>/</id>"));
1181 assert!(xml.contains("href=\"atom.xml\""));
1182 }
1183
1184 #[test]
1185 fn test_build_atom_feed_xml_escapes_title() {
1186 let entry = AtomEntry {
1187 title: "A".to_string(),
1188 link: "a/".to_string(),
1189 id: "a/".to_string(),
1190 updated: "2026-01-01T00:00:00+00:00".to_string(),
1191 published: "2026-01-01T00:00:00+00:00".to_string(),
1192 summary: String::new(),
1193 author: String::new(),
1194 };
1195 let articles = vec![("2026-01-01T00:00:00+00:00".to_string(), entry)];
1196 let xml = build_atom_feed(
1197 "Tom & Jerry's <Feed>",
1198 "https://example.com",
1199 &articles,
1200 );
1201 assert!(xml.contains("Tom & Jerry"));
1202 assert!(xml.contains("<Feed>"));
1203 }
1204
1205 #[test]
1210 fn test_atom_entry_to_xml_escapes_all_fields() {
1211 let entry = AtomEntry {
1212 title: "A & B".to_string(),
1213 link: "https://example.com/a&b/".to_string(),
1214 id: "https://example.com/a&b/".to_string(),
1215 updated: "2026-01-01".to_string(),
1216 published: "2026-01-01".to_string(),
1217 summary: "\"quoted\" <summary>".to_string(),
1218 author: "O'Brien".to_string(),
1219 };
1220 let xml = entry.to_xml();
1221 assert!(xml.contains("A & B"), "Title not escaped");
1222 assert!(
1223 xml.contains(""quoted""),
1224 "Summary quotes not escaped"
1225 );
1226 assert!(
1227 xml.contains("<summary>"),
1228 "Summary angles not escaped"
1229 );
1230 assert!(
1231 xml.contains("O'Brien"),
1232 "Author apostrophe not escaped"
1233 );
1234 }
1235
1236 #[test]
1237 fn test_atom_entry_to_xml_empty_summary() {
1238 let entry = AtomEntry {
1239 title: "No Summary".to_string(),
1240 link: "https://example.com/".to_string(),
1241 id: "https://example.com/".to_string(),
1242 updated: "2026-01-01".to_string(),
1243 published: "2026-01-01".to_string(),
1244 summary: String::new(),
1245 author: "Alice".to_string(),
1246 };
1247 let xml = entry.to_xml();
1248 assert!(xml.contains("<summary></summary>"));
1249 }
1250
1251 #[test]
1256 fn test_atom_feed_untitled_site() -> Result<()> {
1257 let tmp = tempdir()?;
1258
1259 let mut meta = HashMap::new();
1260 let _ = meta.insert("title".to_string(), "Post".to_string());
1261 let _ = meta.insert("description".to_string(), "desc".to_string());
1262 let _ = meta.insert(
1263 "item_pub_date".to_string(),
1264 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1265 );
1266 write_meta_sidecar(tmp.path(), "post", &meta);
1267
1268 let config = crate::cmd::SsgConfig {
1270 base_url: "https://example.com".to_string(),
1271 site_name: String::new(),
1272 site_title: String::new(),
1273 site_description: String::new(),
1274 language: "en".to_string(),
1275 content_dir: std::path::PathBuf::from("content"),
1276 output_dir: std::path::PathBuf::from("build"),
1277 template_dir: std::path::PathBuf::from("templates"),
1278 serve_dir: None,
1279 i18n: None,
1280 };
1281 let ctx = PluginContext::with_config(
1282 Path::new("content"),
1283 Path::new("build"),
1284 tmp.path(),
1285 Path::new("templates"),
1286 config,
1287 );
1288
1289 AtomFeedPlugin.after_compile(&ctx)?;
1290
1291 let content = fs::read_to_string(tmp.path().join("atom.xml"))?;
1292 assert!(
1293 content.contains("<title>Untitled</title>"),
1294 "Empty site_name should produce 'Untitled'"
1295 );
1296 Ok(())
1297 }
1298
1299 #[test]
1300 fn test_atom_feed_no_config() -> Result<()> {
1301 let tmp = tempdir()?;
1302
1303 let mut meta = HashMap::new();
1304 let _ = meta.insert("title".to_string(), "Post".to_string());
1305 let _ = meta.insert("description".to_string(), "desc".to_string());
1306 let _ = meta.insert(
1307 "item_pub_date".to_string(),
1308 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1309 );
1310 write_meta_sidecar(tmp.path(), "post", &meta);
1311
1312 let ctx = PluginContext::new(
1314 Path::new("content"),
1315 Path::new("build"),
1316 tmp.path(),
1317 Path::new("templates"),
1318 );
1319
1320 AtomFeedPlugin.after_compile(&ctx)?;
1321
1322 let atom_path = tmp.path().join("atom.xml");
1323 if atom_path.exists() {
1324 let content = fs::read_to_string(&atom_path)?;
1325 assert!(
1326 content.contains("<title>Untitled</title>"),
1327 "No config should produce 'Untitled'"
1328 );
1329 }
1330 Ok(())
1331 }
1332
1333 #[test]
1338 fn test_atom_entry_iso8601_date_passthrough() {
1339 let mut meta = HashMap::new();
1340 let _ = meta.insert("title".to_string(), "ISO Date".to_string());
1341 let _ = meta.insert(
1342 "item_pub_date".to_string(),
1343 "2026-04-11T12:00:00+00:00".to_string(),
1344 );
1345 let result = build_atom_entry("iso", &meta, "https://example.com");
1346 let (date_key, _) = result.unwrap();
1347 assert!(date_key.contains("2026"));
1349 }
1350
1351 #[test]
1352 fn test_atom_entry_empty_date() {
1353 let mut meta = HashMap::new();
1354 let _ = meta.insert("title".to_string(), "No Date".to_string());
1355 let result = build_atom_entry("nodate", &meta, "https://example.com");
1356 let (date_key, entry) = result.unwrap();
1357 assert!(date_key.is_empty());
1358 assert!(entry.published.is_empty());
1359 }
1360
1361 #[test]
1366 fn test_atom_feed_truncates_at_50() -> Result<()> {
1367 let tmp = tempdir()?;
1368
1369 for i in 0..60 {
1370 let mut meta = HashMap::new();
1371 let _ = meta.insert("title".to_string(), format!("Post {i}"));
1372 let _ = meta.insert("description".to_string(), format!("Desc {i}"));
1373 let _ = meta.insert(
1374 "item_pub_date".to_string(),
1375 format!(
1376 "Thu, {:02} Apr 2026 {:02}:00:00 +0000",
1377 (i % 28) + 1,
1378 i % 24
1379 ),
1380 );
1381 let _ = meta.insert("author".to_string(), "Bot".to_string());
1382 write_meta_sidecar(tmp.path(), &format!("post-{i:03}"), &meta);
1383 }
1384
1385 let ctx = make_atom_ctx(tmp.path());
1386 AtomFeedPlugin.after_compile(&ctx)?;
1387
1388 let content = fs::read_to_string(tmp.path().join("atom.xml"))?;
1389 let entry_count = content.matches("<entry>").count();
1390 assert_eq!(
1391 entry_count, 50,
1392 "Should truncate to 50 entries, got {entry_count}"
1393 );
1394 Ok(())
1395 }
1396
1397 #[test]
1402 fn test_inject_atom_link_multiple_files() -> Result<()> {
1403 let tmp = tempdir()?;
1404 for name in ["index.html", "about.html", "contact.html"] {
1405 fs::write(
1406 tmp.path().join(name),
1407 "<html><head><title>T</title></head><body></body></html>",
1408 )?;
1409 }
1410
1411 inject_atom_link(tmp.path(), "https://example.com/atom.xml")?;
1412
1413 for name in ["index.html", "about.html", "contact.html"] {
1414 let content = fs::read_to_string(tmp.path().join(name))?;
1415 assert!(
1416 content.contains("application/atom+xml"),
1417 "{name} should have atom link"
1418 );
1419 }
1420 Ok(())
1421 }
1422
1423 #[test]
1428 fn test_extract_entries_from_rss_malformed_item() {
1429 let tmp = tempdir().unwrap();
1430 let rss = r#"<?xml version="1.0"?>
1432<rss><channel>
1433<item>
1434<title>Unclosed
1435</channel></rss>"#;
1436 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1437 let entries = extract_entries_from_rss(tmp.path());
1438 assert!(entries.is_empty());
1439 }
1440
1441 #[test]
1442 fn test_extract_entries_from_rss_link_trailing_slash() {
1443 let tmp = tempdir().unwrap();
1444 let rss = r#"<?xml version="1.0"?>
1445<rss><channel>
1446<item>
1447<title>Slash Test</title>
1448<link>https://example.com/my-post/</link>
1449</item>
1450</channel></rss>"#;
1451 fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1452 let entries = extract_entries_from_rss(tmp.path());
1453 assert_eq!(entries.len(), 1);
1454 assert_eq!(entries[0].0, "my-post");
1455 }
1456
1457 #[test]
1462 fn test_atom_feed_falls_back_to_build_meta_dir() -> Result<()> {
1463 let tmp = tempdir()?;
1464 let site_dir = tmp.path().join("site");
1465 let build_dir = tmp.path().join("build");
1466 let meta_dir = build_dir.join(".meta");
1467 fs::create_dir_all(&site_dir)?;
1468 fs::create_dir_all(&meta_dir)?;
1469
1470 let page_dir = meta_dir.join("fallback-post");
1472 fs::create_dir_all(&page_dir)?;
1473 let mut meta = HashMap::new();
1474 let _ = meta.insert("title".to_string(), "Fallback".to_string());
1475 let _ = meta
1476 .insert("description".to_string(), "From build dir".to_string());
1477 let _ = meta.insert(
1478 "item_pub_date".to_string(),
1479 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1480 );
1481 let json = serde_json::to_string(&meta).unwrap();
1482 fs::write(page_dir.join("page.meta.json"), json)?;
1483
1484 let config = crate::cmd::SsgConfig {
1485 base_url: "https://example.com".to_string(),
1486 site_name: "Test".to_string(),
1487 site_title: "Test".to_string(),
1488 site_description: "Test".to_string(),
1489 language: "en".to_string(),
1490 content_dir: std::path::PathBuf::from("content"),
1491 output_dir: build_dir.clone(),
1492 template_dir: std::path::PathBuf::from("templates"),
1493 serve_dir: None,
1494 i18n: None,
1495 };
1496 let ctx = PluginContext::with_config(
1497 Path::new("content"),
1498 &build_dir,
1499 &site_dir,
1500 Path::new("templates"),
1501 config,
1502 );
1503
1504 AtomFeedPlugin.after_compile(&ctx)?;
1505
1506 let atom_path = site_dir.join("atom.xml");
1507 assert!(
1508 atom_path.exists(),
1509 "Should create atom.xml from build/.meta"
1510 );
1511 let content = fs::read_to_string(&atom_path)?;
1512 assert!(content.contains("Fallback"));
1513 Ok(())
1514 }
1515}