1use super::helpers::read_meta_sidecars;
14use crate::dates::parse_flexible_date;
15use crate::error::{PathErrorExt, SsgError};
16use crate::plugin::{Plugin, PluginContext};
17use crate::util::head_dom::inject_before_head_close;
18use serde_json::{json, Map, Value};
19use std::collections::HashMap;
20use std::fs;
21use std::path::Path;
22
23const JSON_FEED_VERSION: &str = "https://jsonfeed.org/version/1.1";
25
26const MAX_ITEMS: usize = 50;
28
29#[derive(Debug, Clone, Copy)]
36pub struct JsonFeedPlugin;
37
38impl Plugin for JsonFeedPlugin {
39 fn name(&self) -> &'static str {
40 "json-feed"
41 }
42
43 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
44 let mut meta_entries =
45 read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
46
47 if meta_entries.is_empty() {
48 let meta_dir = ctx.build_dir.join(".meta");
49 if meta_dir.exists() {
50 meta_entries =
51 read_meta_sidecars(&meta_dir).unwrap_or_default();
52 }
53 }
54
55 if meta_entries.is_empty() {
56 meta_entries = super::atom::extract_entries_from_rss(&ctx.site_dir);
57 }
58
59 let base_url = ctx
60 .config
61 .as_ref()
62 .map(|c| c.base_url.trim_end_matches('/').to_string())
63 .unwrap_or_default();
64
65 let site_name = ctx
66 .config
67 .as_ref()
68 .map(|c| c.site_name.clone())
69 .unwrap_or_default();
70 let feed_title = if site_name.is_empty() {
71 "Untitled".to_string()
72 } else {
73 site_name
74 };
75
76 let default_locale = extract_default_locale(ctx);
77 let known_locales = extract_known_locales(ctx);
78
79 let mut items = collect_items(&meta_entries, &base_url, &known_locales);
80 items.sort_by(|a, b| {
88 b.sort_key.cmp(&a.sort_key).then_with(|| a.id.cmp(&b.id))
89 });
90 items.truncate(MAX_ITEMS);
91
92 if items.is_empty() {
93 return Ok(());
94 }
95
96 let feed_url = if base_url.is_empty() {
97 "feed.json".to_string()
98 } else {
99 format!("{base_url}/feed.json")
100 };
101 let home_page_url = if base_url.is_empty() {
102 "/".to_string()
103 } else {
104 format!("{base_url}/")
105 };
106
107 let feed_json = build_feed_json(
108 &feed_title,
109 &home_page_url,
110 &feed_url,
111 &default_locale,
112 &items,
113 );
114
115 let feed_path = ctx.site_dir.join("feed.json");
116 let serialized = serialize_feed(&feed_json)
117 .unwrap_or_else(|_| feed_json.to_string());
118 fs::write(&feed_path, serialized).with_path(&feed_path)?;
119
120 inject_json_feed_link(&ctx.site_dir, &feed_url)?;
121
122 log::info!(
123 "[json-feed] Generated feed.json with {} items",
124 items.len()
125 );
126 Ok(())
127 }
128}
129
130fn serialize_feed(feed_json: &Value) -> serde_json::Result<String> {
134 fail_point!("postprocess::json-feed-serialize", |_| Err(
135 <serde_json::Error as serde::ser::Error>::custom(
136 "injected: postprocess::json-feed-serialize"
137 )
138 ));
139 serde_json::to_string_pretty(feed_json)
140}
141
142pub(super) struct JsonFeedItem {
144 pub sort_key: String,
145 pub id: String,
146 pub url: String,
147 pub title: String,
148 pub content_html: String,
149 pub date_published: String,
150 pub date_modified: String,
151 pub author: String,
152 pub tags: Vec<String>,
153 pub language: Option<String>,
154}
155
156impl JsonFeedItem {
157 pub(super) fn to_json(&self) -> Value {
159 let mut obj = Map::new();
160 let _ = obj.insert("id".into(), Value::String(self.id.clone()));
161 let _ = obj.insert("url".into(), Value::String(self.url.clone()));
162 let _ = obj.insert("title".into(), Value::String(self.title.clone()));
163 let _ = obj.insert(
164 "content_html".into(),
165 Value::String(self.content_html.clone()),
166 );
167 let _ = obj.insert(
168 "date_published".into(),
169 Value::String(self.date_published.clone()),
170 );
171 let _ = obj.insert(
172 "date_modified".into(),
173 Value::String(self.date_modified.clone()),
174 );
175
176 let author_name = if self.author.is_empty() {
178 "Unknown".to_string()
179 } else {
180 self.author.clone()
181 };
182 let _ = obj.insert(
183 "authors".into(),
184 Value::Array(vec![json!({ "name": author_name })]),
185 );
186
187 let _ = obj.insert(
189 "tags".into(),
190 Value::Array(
191 self.tags.iter().map(|t| Value::String(t.clone())).collect(),
192 ),
193 );
194
195 if let Some(ref lang) = self.language {
196 let _ = obj.insert("language".into(), Value::String(lang.clone()));
197 }
198
199 Value::Object(obj)
200 }
201}
202
203pub(super) fn build_feed_json(
205 title: &str,
206 home_page_url: &str,
207 feed_url: &str,
208 language: &str,
209 items: &[JsonFeedItem],
210) -> Value {
211 let items_json: Vec<Value> =
212 items.iter().map(JsonFeedItem::to_json).collect();
213
214 let mut feed = Map::new();
215 let _ = feed.insert(
216 "version".into(),
217 Value::String(JSON_FEED_VERSION.to_string()),
218 );
219 let _ = feed.insert("title".into(), Value::String(title.to_string()));
220 let _ = feed.insert(
221 "home_page_url".into(),
222 Value::String(home_page_url.to_string()),
223 );
224 let _ = feed.insert("feed_url".into(), Value::String(feed_url.to_string()));
225 if !language.is_empty() {
226 let _ =
227 feed.insert("language".into(), Value::String(language.to_string()));
228 }
229 let _ = feed.insert("items".into(), Value::Array(items_json));
230 Value::Object(feed)
231}
232
233pub(super) fn collect_items(
235 meta_entries: &[(String, HashMap<String, String>)],
236 base_url: &str,
237 known_locales: &[String],
238) -> Vec<JsonFeedItem> {
239 meta_entries
240 .iter()
241 .filter_map(|(rel_path, meta)| {
242 build_item(rel_path, meta, base_url, known_locales)
243 })
244 .collect()
245}
246
247pub(super) fn build_item(
249 rel_path: &str,
250 meta: &HashMap<String, String>,
251 base_url: &str,
252 known_locales: &[String],
253) -> Option<JsonFeedItem> {
254 if rel_path.is_empty() {
255 return None;
256 }
257 let title = meta.get("title").cloned().unwrap_or_default();
258 if title.is_empty() {
259 return None;
260 }
261
262 let description = meta.get("description").cloned().unwrap_or_default();
263 let pub_date = meta.get("item_pub_date").cloned().unwrap_or_default();
264 let modified_date = meta
265 .get("last_build_date")
266 .or_else(|| meta.get("date_modified"))
267 .cloned()
268 .unwrap_or_else(|| pub_date.clone());
269 let author = meta.get("author").cloned().unwrap_or_default();
270
271 let url = if base_url.is_empty() {
272 format!("{rel_path}/")
273 } else {
274 format!("{base_url}/{rel_path}/")
275 };
276
277 let flex_rfc3339 = |field: &str, raw: &str| match parse_flexible_date(raw) {
283 Ok(dt) => dt.to_rfc3339(),
284 Err(err) => {
285 if !raw.is_empty() {
286 log::warn!("[json-feed] '{field}' for '{rel_path}': {err}");
287 }
288 raw.to_string()
289 }
290 };
291 let date_published = flex_rfc3339("item_pub_date", &pub_date);
292 let date_modified =
293 flex_rfc3339("last_build_date/date_modified", &modified_date);
294
295 let mut tags: Vec<String> = meta
297 .get("tags")
298 .map(|t| {
299 t.split(',')
300 .map(|s| s.trim().to_string())
301 .filter(|s| !s.is_empty())
302 .collect()
303 })
304 .unwrap_or_default();
305 if tags.is_empty() {
306 if let Some(cat) = meta.get("category") {
307 let trimmed = cat.trim();
308 if !trimmed.is_empty() {
309 tags.push(trimmed.to_string());
310 }
311 }
312 }
313
314 let language = meta
317 .get("language")
318 .or_else(|| meta.get("locale"))
319 .cloned()
320 .or_else(|| detect_locale_from_path(rel_path, known_locales));
321
322 Some(JsonFeedItem {
323 sort_key: date_published.clone(),
324 id: url.clone(),
325 url,
326 title,
327 content_html: description,
328 date_published,
329 date_modified,
330 author,
331 tags,
332 language,
333 })
334}
335
336pub(super) fn extract_default_locale(ctx: &PluginContext) -> String {
339 if let Some(cfg) = ctx.config.as_ref() {
340 if let Some(locale) = cfg.i18n_default_locale() {
341 return locale;
342 }
343 if !cfg.language.is_empty() {
344 return cfg.language.clone();
345 }
346 }
347 "en".to_string()
348}
349
350pub(super) fn extract_known_locales(ctx: &PluginContext) -> Vec<String> {
352 ctx.config
353 .as_ref()
354 .map(crate::cmd::SsgConfig::i18n_locales)
355 .unwrap_or_default()
356}
357
358fn detect_locale_from_path(
360 rel_path: &str,
361 known_locales: &[String],
362) -> Option<String> {
363 let first = rel_path.split('/').next().unwrap_or_default();
367 if known_locales.iter().any(|l| l == first) {
368 Some(first.to_string())
369 } else {
370 None
371 }
372}
373
374pub(super) fn inject_json_feed_link(
377 site_dir: &Path,
378 feed_url: &str,
379) -> Result<(), SsgError> {
380 let html_files = crate::walk::walk_files(site_dir, "html")
381 .map_err(|e| SsgError::io(e, site_dir))?;
382 for path in &html_files {
383 let html = fs::read_to_string(path).with_path(path)?;
384
385 if html.contains("application/feed+json") {
386 continue;
387 }
388 let link_tag = format!(
389 " <link rel=\"alternate\" type=\"application/feed+json\" title=\"JSON Feed\" href=\"{feed_url}\"/>\n"
390 );
391 let modified = inject_before_head_close(&html, &link_tag);
392 if modified != html {
393 fs::write(path, &modified).with_path(path)?;
394 }
395 }
396 Ok(())
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::plugin::PluginContext;
403 use anyhow::Result;
404 use std::path::Path;
405 use tempfile::tempdir;
406
407 fn write_meta_sidecar(
408 dir: &Path,
409 slug: &str,
410 meta: &HashMap<String, String>,
411 ) {
412 let page_dir = dir.join(slug);
413 fs::create_dir_all(&page_dir).expect("create page dir");
414 let meta_path = page_dir.join("index.meta.json");
415 let json = serde_json::to_string(meta).expect("serialize meta");
416 fs::write(&meta_path, json).expect("write meta");
417 }
418
419 fn make_ctx(site_dir: &Path) -> PluginContext {
420 crate::test_support::init_logger();
421 let config = crate::cmd::SsgConfig {
422 listings: Vec::new(),
423 base_url: "https://example.com".to_string(),
424 site_name: "Test Site".to_string(),
425 site_title: "Test Site".to_string(),
426 site_description: "A test site".to_string(),
427 language: "en".to_string(),
428 content_dir: std::path::PathBuf::from("content"),
429 output_dir: std::path::PathBuf::from("build"),
430 template_dir: std::path::PathBuf::from("templates"),
431 theme: None,
432 serve_dir: None,
433 #[cfg(feature = "i18n")]
434 i18n: None,
435 cdn_prefix: None,
436 og_image: None,
437 image: crate::cmd::ImageConfig::default(),
438 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
439 agents: None,
440 transitions: false,
441 security: crate::cmd::SecurityConfig::default(),
442 no_taxonomy_pages: false,
443 };
444 PluginContext::with_config(
445 Path::new("content"),
446 Path::new("build"),
447 site_dir,
448 Path::new("templates"),
449 config,
450 )
451 }
452
453 #[test]
454 #[serial_test::parallel]
455 fn test_json_feed_top_level_fields() -> Result<()> {
456 let tmp = tempdir().unwrap();
457
458 let mut meta = HashMap::new();
459 let _ = meta.insert("title".to_string(), "Hello World".to_string());
460 let _ = meta.insert(
461 "description".to_string(),
462 "<p>A test post</p>".to_string(),
463 );
464 let _ = meta.insert(
465 "item_pub_date".to_string(),
466 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
467 );
468 let _ = meta.insert("author".to_string(), "Alice".to_string());
469 let _ = meta.insert("tags".to_string(), "rust, web".to_string());
470 write_meta_sidecar(tmp.path(), "hello", &meta);
471
472 let ctx = make_ctx(tmp.path());
473 JsonFeedPlugin.after_compile(&ctx).unwrap();
474
475 let feed_path = tmp.path().join("feed.json");
476 assert!(feed_path.exists(), "feed.json should be created");
477
478 let raw = fs::read_to_string(&feed_path).unwrap();
479 let value: Value = serde_json::from_str(&raw).unwrap();
480 assert_eq!(value["version"], JSON_FEED_VERSION);
481 assert_eq!(value["title"], "Test Site");
482 assert_eq!(value["home_page_url"], "https://example.com/");
483 assert_eq!(value["feed_url"], "https://example.com/feed.json");
484 assert_eq!(value["language"], "en");
485 assert!(value["items"].is_array());
486 assert_eq!(value["items"].as_array().unwrap().len(), 1);
487 Ok(())
488 }
489
490 #[test]
491 #[serial_test::parallel]
492 fn test_json_feed_item_required_fields() -> Result<()> {
493 let tmp = tempdir().unwrap();
494
495 let mut meta = HashMap::new();
496 let _ = meta.insert("title".to_string(), "Item Test".to_string());
497 let _ =
498 meta.insert("description".to_string(), "<p>body</p>".to_string());
499 let _ = meta.insert(
500 "item_pub_date".to_string(),
501 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
502 );
503 let _ = meta.insert("author".to_string(), "Bob".to_string());
504 let _ = meta.insert("tags".to_string(), "alpha".to_string());
505 write_meta_sidecar(tmp.path(), "item-test", &meta);
506
507 let ctx = make_ctx(tmp.path());
508 JsonFeedPlugin.after_compile(&ctx).unwrap();
509
510 let value: Value = serde_json::from_str(
511 &fs::read_to_string(tmp.path().join("feed.json")).unwrap(),
512 )
513 .unwrap();
514 let item = &value["items"][0];
515
516 assert!(item["id"].is_string());
517 assert!(item["url"].is_string());
518 assert_eq!(item["title"], "Item Test");
519 assert_eq!(item["content_html"], "<p>body</p>");
520 assert_eq!(item["date_published"], "2026-04-11T06:06:06+00:00");
521 assert_eq!(item["date_modified"], "2026-04-11T06:06:06+00:00");
522
523 let authors = item["authors"].as_array().unwrap();
524 assert_eq!(authors.len(), 1);
525 assert_eq!(authors[0]["name"], "Bob");
526
527 let tags = item["tags"].as_array().unwrap();
528 assert_eq!(tags.len(), 1);
529 assert_eq!(tags[0], "alpha");
530 Ok(())
531 }
532
533 #[test]
534 #[serial_test::parallel]
535 fn test_json_feed_injects_link_into_html() -> Result<()> {
536 let tmp = tempdir().unwrap();
537
538 let mut meta = HashMap::new();
539 let _ = meta.insert("title".to_string(), "Link Test".to_string());
540 let _ = meta.insert("description".to_string(), "x".to_string());
541 let _ = meta.insert(
542 "item_pub_date".to_string(),
543 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
544 );
545 write_meta_sidecar(tmp.path(), "linktest", &meta);
546
547 let html_path = tmp.path().join("index.html");
548 fs::write(
549 &html_path,
550 "<html><head><title>T</title></head><body></body></html>",
551 )
552 .unwrap();
553
554 let ctx = make_ctx(tmp.path());
555 JsonFeedPlugin.after_compile(&ctx).unwrap();
556
557 let html = fs::read_to_string(&html_path).unwrap();
558 assert!(
559 html.contains("application/feed+json"),
560 "missing feed+json link tag in: {html}"
561 );
562 assert!(html.contains("href=\"https://example.com/feed.json\""));
563 Ok(())
564 }
565
566 #[test]
567 #[serial_test::parallel]
568 fn test_json_feed_empty_site_dir() -> Result<()> {
569 let tmp = tempdir().unwrap();
570 let ctx = make_ctx(tmp.path());
571 JsonFeedPlugin.after_compile(&ctx).unwrap();
572 assert!(!tmp.path().join("feed.json").exists());
573 Ok(())
574 }
575
576 #[test]
577 #[serial_test::parallel]
578 fn test_json_feed_sorts_descending_and_truncates() -> Result<()> {
579 let tmp = tempdir().unwrap();
580 for i in 0..60 {
581 let mut meta = HashMap::new();
582 let _ = meta.insert("title".to_string(), format!("P{i}"));
583 let _ = meta.insert("description".to_string(), format!("body {i}"));
584 let _ = meta.insert(
585 "item_pub_date".to_string(),
586 format!(
587 "Thu, {:02} Apr 2026 {:02}:00:00 +0000",
588 (i % 28) + 1,
589 i % 24
590 ),
591 );
592 write_meta_sidecar(tmp.path(), &format!("post-{i:03}"), &meta);
593 }
594
595 let ctx = make_ctx(tmp.path());
596 JsonFeedPlugin.after_compile(&ctx).unwrap();
597 let value: Value = serde_json::from_str(
598 &fs::read_to_string(tmp.path().join("feed.json")).unwrap(),
599 )
600 .unwrap();
601 let items = value["items"].as_array().unwrap();
602 assert_eq!(items.len(), MAX_ITEMS);
603 let first = items[0]["date_published"].as_str().unwrap();
605 let last = items[items.len() - 1]["date_published"].as_str().unwrap();
606 assert!(first >= last);
607 Ok(())
608 }
609
610 #[test]
611 #[serial_test::parallel]
612 fn test_json_feed_empty_author_shows_unknown() -> Result<()> {
613 let tmp = tempdir().unwrap();
614 let mut meta = HashMap::new();
615 let _ = meta.insert("title".to_string(), "T".to_string());
616 let _ = meta.insert("description".to_string(), "b".to_string());
617 let _ = meta.insert(
618 "item_pub_date".to_string(),
619 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
620 );
621 write_meta_sidecar(tmp.path(), "noauth", &meta);
622
623 let ctx = make_ctx(tmp.path());
624 JsonFeedPlugin.after_compile(&ctx).unwrap();
625 let value: Value = serde_json::from_str(
626 &fs::read_to_string(tmp.path().join("feed.json")).unwrap(),
627 )
628 .unwrap();
629 assert_eq!(value["items"][0]["authors"][0]["name"], "Unknown");
630 Ok(())
631 }
632
633 #[test]
634 fn test_json_feed_locale_detection_from_path() {
635 let known = vec!["en".to_string(), "fr".to_string()];
636 let mut meta = HashMap::new();
637 let _ = meta.insert("title".to_string(), "Bonjour".to_string());
638 let _ = meta.insert("description".to_string(), "x".to_string());
639 let item =
640 build_item("fr/bonjour", &meta, "https://example.com", &known)
641 .unwrap();
642 assert_eq!(item.language, Some("fr".to_string()));
643 }
644
645 #[test]
646 fn test_json_feed_explicit_locale_overrides_path() {
647 let known = vec!["en".to_string(), "fr".to_string()];
648 let mut meta = HashMap::new();
649 let _ = meta.insert("title".to_string(), "T".to_string());
650 let _ = meta.insert("description".to_string(), "x".to_string());
651 let _ = meta.insert("language".to_string(), "de".to_string());
652 let item = build_item("fr/post", &meta, "https://example.com", &known)
653 .unwrap();
654 assert_eq!(item.language, Some("de".to_string()));
655 }
656
657 #[test]
658 fn test_json_feed_skips_empty_title() {
659 let mut meta = HashMap::new();
660 let _ = meta.insert("title".to_string(), String::new());
661 assert!(build_item("post", &meta, "https://example.com", &[]).is_none());
662 }
663
664 #[test]
665 fn test_json_feed_skips_empty_path() {
666 let mut meta = HashMap::new();
667 let _ = meta.insert("title".to_string(), "T".to_string());
668 assert!(build_item("", &meta, "https://example.com", &[]).is_none());
669 }
670
671 #[test]
672 fn test_json_feed_id_matches_url() {
673 let mut meta = HashMap::new();
674 let _ = meta.insert("title".to_string(), "T".to_string());
675 let _ = meta.insert("description".to_string(), "x".to_string());
676 let item = build_item("p", &meta, "https://example.com", &[]).unwrap();
677 assert_eq!(item.id, item.url);
678 assert_eq!(item.url, "https://example.com/p/");
679 }
680
681 #[test]
686 fn test_build_item_iso_date_normalised_to_rfc3339() {
687 let mut meta = HashMap::new();
688 let _ = meta.insert("title".to_string(), "ISO".to_string());
689 let _ =
690 meta.insert("item_pub_date".to_string(), "2026-07-01".to_string());
691 let item = build_item("iso", &meta, "https://example.com", &[])
692 .expect("valid item");
693 assert_eq!(item.date_published, "2026-07-01T00:00:00+00:00");
694 assert_eq!(item.date_modified, "2026-07-01T00:00:00+00:00");
695 }
696
697 #[test]
698 fn test_build_item_long_form_date_normalised_to_rfc3339() {
699 let mut meta = HashMap::new();
700 let _ = meta.insert("title".to_string(), "Long".to_string());
701 let _ = meta
702 .insert("item_pub_date".to_string(), "July 1, 2026".to_string());
703 let _ = meta.insert(
704 "date_modified".to_string(),
705 "2026-07-02T07:07:07Z".to_string(),
706 );
707 let item = build_item("long", &meta, "https://example.com", &[])
708 .expect("valid item");
709 assert_eq!(item.date_published, "2026-07-01T00:00:00+00:00");
710 assert_eq!(item.date_modified, "2026-07-02T07:07:07+00:00");
711 }
712
713 #[test]
714 fn test_build_item_unparseable_date_passes_through() {
715 crate::test_support::init_logger();
716 let mut meta = HashMap::new();
717 let _ = meta.insert("title".to_string(), "Bad".to_string());
718 let _ =
719 meta.insert("item_pub_date".to_string(), "not-a-date".to_string());
720 let item = build_item("bad", &meta, "https://example.com", &[])
721 .expect("valid item");
722 assert_eq!(item.date_published, "not-a-date");
724 }
725
726 #[test]
727 fn test_json_feed_plugin_name() {
728 assert_eq!(JsonFeedPlugin.name(), "json-feed");
729 }
730
731 #[test]
732 fn test_json_feed_plugin_registers() {
733 use crate::plugin::PluginManager;
734 let mut pm = PluginManager::new();
735 pm.register(JsonFeedPlugin);
736 assert!(pm.names().contains(&"json-feed"));
737 }
738
739 #[test]
740 fn test_json_feed_idempotent_link_injection() -> Result<()> {
741 let tmp = tempdir().unwrap();
742 let html_path = tmp.path().join("page.html");
743 fs::write(
744 &html_path,
745 "<html><head><title>T</title></head><body></body></html>",
746 )
747 .unwrap();
748 inject_json_feed_link(tmp.path(), "https://example.com/feed.json")
749 .unwrap();
750 let first = fs::read_to_string(&html_path).unwrap();
751 inject_json_feed_link(tmp.path(), "https://example.com/feed.json")
752 .unwrap();
753 let second = fs::read_to_string(&html_path).unwrap();
754 assert_eq!(first, second);
755 assert_eq!(
756 second.matches("application/feed+json").count(),
757 1,
758 "should inject exactly one link tag"
759 );
760 Ok(())
761 }
762
763 #[test]
764 fn test_json_feed_link_skips_files_without_head() -> Result<()> {
765 let tmp = tempdir().unwrap();
766 let html_path = tmp.path().join("frag.html");
767 fs::write(&html_path, "<div>no head</div>").unwrap();
768 inject_json_feed_link(tmp.path(), "https://example.com/feed.json")
769 .unwrap();
770 let result = fs::read_to_string(&html_path).unwrap();
771 assert!(!result.contains("application/feed+json"));
772 Ok(())
773 }
774
775 #[cfg(feature = "i18n")]
776 #[test]
777 fn test_extract_default_locale_prefers_i18n() {
778 #[cfg(feature = "i18n")]
779 use crate::i18n::I18nConfig;
780 let tmp = tempdir().unwrap();
781 let config = crate::cmd::SsgConfig {
782 listings: Vec::new(),
783 base_url: "https://example.com".to_string(),
784 site_name: "S".to_string(),
785 site_title: "S".to_string(),
786 site_description: String::new(),
787 language: "en".to_string(),
788 content_dir: std::path::PathBuf::from("c"),
789 output_dir: std::path::PathBuf::from("b"),
790 template_dir: std::path::PathBuf::from("t"),
791 theme: None,
792 serve_dir: None,
793 i18n: Some(I18nConfig {
794 default_locale: "fr".to_string(),
795 locales: vec!["en".into(), "fr".into()],
796 url_prefix: crate::i18n::UrlPrefixStrategy::SubPath,
797 }),
798 cdn_prefix: None,
799 og_image: None,
800 image: crate::cmd::ImageConfig::default(),
801 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
802 agents: None,
803 transitions: false,
804 security: crate::cmd::SecurityConfig::default(),
805 no_taxonomy_pages: false,
806 };
807 let ctx = PluginContext::with_config(
808 Path::new("c"),
809 Path::new("b"),
810 tmp.path(),
811 Path::new("t"),
812 config,
813 );
814 assert_eq!(extract_default_locale(&ctx), "fr");
815 assert_eq!(extract_known_locales(&ctx), vec!["en", "fr"]);
816 }
817
818 #[test]
819 fn test_extract_default_locale_falls_back_to_language() {
820 let tmp = tempdir().unwrap();
821 let ctx = make_ctx(tmp.path());
822 assert_eq!(extract_default_locale(&ctx), "en");
823 assert!(extract_known_locales(&ctx).is_empty());
824 }
825
826 #[test]
827 fn test_extract_default_locale_no_config_defaults_en() {
828 let ctx = PluginContext::new(
829 Path::new("c"),
830 Path::new("b"),
831 Path::new("s"),
832 Path::new("t"),
833 );
834 assert_eq!(extract_default_locale(&ctx), "en");
835 }
836
837 #[test]
838 fn test_build_feed_json_omits_empty_language() {
839 let items: Vec<JsonFeedItem> = vec![JsonFeedItem {
840 sort_key: "2026".into(),
841 id: "https://x/".into(),
842 url: "https://x/".into(),
843 title: "T".into(),
844 content_html: "h".into(),
845 date_published: "2026".into(),
846 date_modified: "2026".into(),
847 author: "A".into(),
848 tags: vec![],
849 language: None,
850 }];
851 let v = build_feed_json(
852 "Title",
853 "https://x/",
854 "https://x/feed.json",
855 "",
856 &items,
857 );
858 assert!(v.get("language").is_none());
859 assert_eq!(v["version"], JSON_FEED_VERSION);
860 assert_eq!(v["items"].as_array().unwrap().len(), 1);
861 }
862
863 #[test]
864 fn test_tags_fallback_to_category() {
865 let mut meta = HashMap::new();
866 let _ = meta.insert("title".to_string(), "T".to_string());
867 let _ = meta.insert("category".to_string(), "Tech".to_string());
868 let item = build_item("p", &meta, "https://example.com", &[]).unwrap();
869 assert_eq!(item.tags, vec!["Tech"]);
870 }
871
872 #[test]
873 #[serial_test::parallel]
874 fn test_json_feed_no_base_url() -> Result<()> {
875 let tmp = tempdir().unwrap();
876 let mut meta = HashMap::new();
877 let _ = meta.insert("title".to_string(), "T".to_string());
878 let _ = meta.insert("description".to_string(), "x".to_string());
879 let _ = meta.insert(
880 "item_pub_date".to_string(),
881 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
882 );
883 write_meta_sidecar(tmp.path(), "p", &meta);
884
885 let config = crate::cmd::SsgConfig {
886 listings: Vec::new(),
887 base_url: String::new(),
888 site_name: "S".to_string(),
889 site_title: "S".to_string(),
890 site_description: String::new(),
891 language: "en".to_string(),
892 content_dir: std::path::PathBuf::from("c"),
893 output_dir: std::path::PathBuf::from("b"),
894 template_dir: std::path::PathBuf::from("t"),
895 theme: None,
896 serve_dir: None,
897 #[cfg(feature = "i18n")]
898 i18n: None,
899 cdn_prefix: None,
900 og_image: None,
901 image: crate::cmd::ImageConfig::default(),
902 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
903 agents: None,
904 transitions: false,
905 security: crate::cmd::SecurityConfig::default(),
906 no_taxonomy_pages: false,
907 };
908 let ctx = PluginContext::with_config(
909 Path::new("c"),
910 Path::new("b"),
911 tmp.path(),
912 Path::new("t"),
913 config,
914 );
915 JsonFeedPlugin.after_compile(&ctx).unwrap();
916
917 let value: Value = serde_json::from_str(
918 &fs::read_to_string(tmp.path().join("feed.json")).unwrap(),
919 )
920 .unwrap();
921 assert_eq!(value["feed_url"], "feed.json");
922 assert_eq!(value["home_page_url"], "/");
923 assert_eq!(value["items"][0]["url"], "p/");
924 Ok(())
925 }
926
927 #[test]
932 #[serial_test::parallel]
933 fn test_json_feed_falls_back_to_build_meta_dir() {
934 let tmp = tempdir().unwrap();
935 let build = tmp.path().join("build");
936 let site = tmp.path().join("site");
937 fs::create_dir_all(&site).unwrap();
938 let page_dir = build.join(".meta").join("post");
939 fs::create_dir_all(&page_dir).unwrap();
940 fs::write(
941 page_dir.join("index.meta.json"),
942 r#"{"title":"From Build Meta","item_pub_date":"Thu, 11 Apr 2026 06:06:06 +0000"}"#,
943 )
944 .unwrap();
945
946 crate::test_support::init_logger();
947 let config = crate::cmd::SsgConfig {
948 listings: Vec::new(),
949 base_url: "https://example.com".to_string(),
950 site_name: "Test Site".to_string(),
951 site_title: "Test Site".to_string(),
952 site_description: "A test site".to_string(),
953 language: "en".to_string(),
954 content_dir: std::path::PathBuf::from("content"),
955 output_dir: std::path::PathBuf::from("build"),
956 template_dir: std::path::PathBuf::from("templates"),
957 theme: None,
958 serve_dir: None,
959 #[cfg(feature = "i18n")]
960 i18n: None,
961 cdn_prefix: None,
962 og_image: None,
963 image: crate::cmd::ImageConfig::default(),
964 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
965 agents: None,
966 transitions: false,
967 security: crate::cmd::SecurityConfig::default(),
968 no_taxonomy_pages: false,
969 };
970 let ctx = PluginContext::with_config(
971 Path::new("content"),
972 &build,
973 &site,
974 Path::new("templates"),
975 config,
976 );
977 JsonFeedPlugin.after_compile(&ctx).unwrap();
978
979 let raw = fs::read_to_string(site.join("feed.json")).unwrap();
980 assert!(raw.contains("From Build Meta"));
981 }
982
983 #[test]
988 fn test_to_json_includes_language_when_present() {
989 let item = JsonFeedItem {
990 sort_key: "2026".to_string(),
991 id: "id".to_string(),
992 url: "u/".to_string(),
993 title: "T".to_string(),
994 content_html: "C".to_string(),
995 date_published: "2026-01-01T00:00:00+00:00".to_string(),
996 date_modified: "2026-01-01T00:00:00+00:00".to_string(),
997 author: "A".to_string(),
998 tags: Vec::new(),
999 language: Some("fr".to_string()),
1000 };
1001 let v = item.to_json();
1002 assert_eq!(v["language"], "fr");
1003 }
1004
1005 #[cfg(feature = "i18n")]
1010 #[test]
1011 fn test_build_item_ignores_whitespace_only_category() {
1012 let mut meta = HashMap::new();
1013 let _ = meta.insert("title".to_string(), "T".to_string());
1014 let _ = meta.insert("category".to_string(), " ".to_string());
1015 let item = build_item("p", &meta, "", &[]).unwrap();
1016 assert!(item.tags.is_empty(), "blank category must not become a tag");
1017 }
1018
1019 #[cfg(feature = "i18n")]
1024 fn ctx_with_locale(
1025 site_dir: &Path,
1026 default_locale: &str,
1027 language: &str,
1028 ) -> PluginContext {
1029 let config = crate::cmd::SsgConfig {
1030 listings: Vec::new(),
1031 base_url: String::new(),
1032 site_name: "S".to_string(),
1033 site_title: String::new(),
1034 site_description: String::new(),
1035 language: language.to_string(),
1036 content_dir: std::path::PathBuf::from("c"),
1037 output_dir: std::path::PathBuf::from("b"),
1038 template_dir: std::path::PathBuf::from("t"),
1039 theme: None,
1040 serve_dir: None,
1041 i18n: Some(crate::i18n::I18nConfig {
1042 default_locale: default_locale.to_string(),
1043 locales: vec!["en".to_string(), "fr".to_string()],
1044 url_prefix: Default::default(),
1045 }),
1046 cdn_prefix: None,
1047 og_image: None,
1048 image: crate::cmd::ImageConfig::default(),
1049 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
1050 agents: None,
1051 transitions: false,
1052 security: crate::cmd::SecurityConfig::default(),
1053 no_taxonomy_pages: false,
1054 };
1055 PluginContext::with_config(
1056 Path::new("c"),
1057 Path::new("b"),
1058 site_dir,
1059 Path::new("t"),
1060 config,
1061 )
1062 }
1063
1064 #[cfg(feature = "i18n")]
1065 #[test]
1066 fn test_extract_default_locale_empty_i18n_uses_language() {
1067 let tmp = tempdir().unwrap();
1068 let ctx = ctx_with_locale(tmp.path(), "", "de");
1069 assert_eq!(extract_default_locale(&ctx), "de");
1070 }
1071
1072 #[cfg(feature = "i18n")]
1073 #[test]
1074 fn test_extract_default_locale_all_empty_falls_back_to_en() {
1075 let tmp = tempdir().unwrap();
1076 let ctx = ctx_with_locale(tmp.path(), "", "");
1077 assert_eq!(extract_default_locale(&ctx), "en");
1078 }
1079
1080 #[test]
1085 #[serial_test::parallel]
1086 fn test_after_compile_errors_when_feed_json_is_a_directory() {
1087 let tmp = tempdir().unwrap();
1088 let mut meta = HashMap::new();
1089 let _ = meta.insert("title".to_string(), "Post".to_string());
1090 write_meta_sidecar(tmp.path(), "post", &meta);
1091 fs::create_dir_all(tmp.path().join("feed.json")).unwrap();
1092
1093 let ctx = make_ctx(tmp.path());
1094 let err = JsonFeedPlugin.after_compile(&ctx).unwrap_err();
1095 assert!(format!("{err}").contains("feed.json"));
1096 }
1097
1098 #[test]
1099 #[serial_test::parallel]
1100 fn test_after_compile_propagates_unreadable_html_error() {
1101 let tmp = tempdir().unwrap();
1102 let mut meta = HashMap::new();
1103 let _ = meta.insert("title".to_string(), "Post".to_string());
1104 write_meta_sidecar(tmp.path(), "post", &meta);
1105 fs::write(tmp.path().join("bad.html"), [0xFF, 0xFE, 0xFD]).unwrap();
1106
1107 let ctx = make_ctx(tmp.path());
1108 let err = JsonFeedPlugin.after_compile(&ctx).unwrap_err();
1109 assert!(format!("{err}").contains("bad.html"));
1110 }
1111
1112 #[test]
1113 #[cfg(unix)]
1114 fn test_inject_json_feed_link_write_failure_on_readonly_html() {
1115 use std::os::unix::fs::PermissionsExt;
1116 let tmp = tempdir().unwrap();
1117 let html_path = tmp.path().join("index.html");
1118 fs::write(
1119 &html_path,
1120 "<html><head><title>T</title></head><body></body></html>",
1121 )
1122 .unwrap();
1123 fs::set_permissions(&html_path, fs::Permissions::from_mode(0o444))
1124 .unwrap();
1125
1126 let result =
1127 inject_json_feed_link(tmp.path(), "https://x.example/feed.json");
1128 let _ =
1129 fs::set_permissions(&html_path, fs::Permissions::from_mode(0o644));
1130 let err = result.unwrap_err();
1131 assert!(format!("{err}").contains("index.html"));
1132 }
1133
1134 #[test]
1135 #[cfg(unix)]
1136 fn test_inject_json_feed_link_walk_failure_on_unreadable_subdir() {
1137 use std::os::unix::fs::PermissionsExt;
1138 let tmp = tempdir().unwrap();
1139 let locked = tmp.path().join("locked");
1140 fs::create_dir_all(&locked).unwrap();
1141 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1142 .unwrap();
1143
1144 let result =
1145 inject_json_feed_link(tmp.path(), "https://x.example/feed.json");
1146 let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1147 assert!(result.is_err(), "expected an error from the locked path");
1148 }
1149}
1150
1151#[cfg(all(test, feature = "test-fault-injection"))]
1152mod fault_tests {
1153 use super::*;
1154 use crate::plugin::PluginContext;
1155 use serial_test::serial;
1156 use std::path::Path;
1157 use tempfile::tempdir;
1158
1159 struct FailGuard(&'static str);
1161
1162 impl Drop for FailGuard {
1163 fn drop(&mut self) {
1164 let _ = fail::cfg(self.0, "off");
1165 }
1166 }
1167
1168 fn write_meta_sidecar(
1169 dir: &Path,
1170 slug: &str,
1171 meta: &HashMap<String, String>,
1172 ) {
1173 let page_dir = dir.join(slug);
1174 fs::create_dir_all(&page_dir).expect("create page dir");
1175 let meta_path = page_dir.join("index.meta.json");
1176 let json = serde_json::to_string(meta).expect("serialize meta");
1177 fs::write(&meta_path, json).expect("write meta");
1178 }
1179
1180 #[test]
1185 #[serial]
1186 fn after_compile_falls_back_to_compact_encoding_on_serialize_failure() {
1187 let _guard = FailGuard("postprocess::json-feed-serialize");
1188 fail::cfg("postprocess::json-feed-serialize", "return")
1189 .expect("activate failpoint");
1190
1191 let tmp = tempdir().unwrap();
1192 let mut meta = HashMap::new();
1193 let _ = meta.insert("title".to_string(), "Fallback".to_string());
1194 let _ = meta.insert("description".to_string(), "x".to_string());
1195 let _ = meta.insert(
1196 "item_pub_date".to_string(),
1197 "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1198 );
1199 write_meta_sidecar(tmp.path(), "fallback-post", &meta);
1200
1201 crate::test_support::init_logger();
1202 let ctx = PluginContext::new(
1203 Path::new("content"),
1204 Path::new("build"),
1205 tmp.path(),
1206 Path::new("templates"),
1207 );
1208 JsonFeedPlugin
1209 .after_compile(&ctx)
1210 .expect("fallback path must not surface an error");
1211
1212 let feed_path = tmp.path().join("feed.json");
1213 let raw = fs::read_to_string(&feed_path).unwrap();
1214 assert!(
1217 !raw.contains('\n'),
1218 "expected compact fallback encoding, got: {raw}"
1219 );
1220 let value: Value = serde_json::from_str(&raw).unwrap();
1221 assert_eq!(value["items"][0]["title"], "Fallback");
1222 }
1223}