Skip to main content

ssg/plugins/postprocess/
json_feed.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! JSON Feed 1.1 plugin.
5//!
6//! Emits a `feed.json` file at the site root conforming to the
7//! [JSON Feed 1.1 spec](https://jsonfeed.org/version/1.1).
8//!
9//! Runs alongside `RssAggregatePlugin` and `AtomFeedPlugin` in
10//! `after_compile`, reading the same `.meta.json` sidecars (with the
11//! same `build_dir/.meta` and `rss.xml` fallbacks).
12
13use 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
23/// JSON Feed version URL — the required top-level `version` field.
24const JSON_FEED_VERSION: &str = "https://jsonfeed.org/version/1.1";
25
26/// Maximum number of items emitted per feed (matches RSS/Atom).
27const MAX_ITEMS: usize = 50;
28
29/// Generates a JSON Feed 1.1 `feed.json` from `.meta.json` sidecars.
30///
31/// Runs in `after_compile`, alongside `RssAggregatePlugin` and
32/// `AtomFeedPlugin`. Mirrors the sidecar discovery logic of
33/// `AtomFeedPlugin` (`site_dir` → `build_dir/.meta` → `rss.xml` fallback)
34/// so the three feeds stay in sync.
35#[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        // Sort by date descending, then by `id` ascending as a
81        // deterministic tiebreaker. `read_meta_sidecars` walks the
82        // filesystem tree, whose entry order is OS-dependent (ext4 vs
83        // APFS) — without a tiebreaker, items sharing a sort_key
84        // (common in synthetic fixtures) retain that non-deterministic
85        // order through the stable sort, failing the cross-OS
86        // determinism gate.
87        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
130/// Serialize the feed with a fault-injection hook so tests can drive
131/// the compact-encoding fallback branch (pretty-printing a `Value`
132/// built from owned strings cannot fail in practice).
133fn 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
142/// A single JSON Feed item ready for serialisation.
143pub(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    /// Convert to a `serde_json::Value` for the items array.
158    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        // authors[] — JSON Feed 1.1 uses an array.
177        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        // tags[] — always present (may be empty).
188        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
203/// Builds the top-level feed `Value`.
204pub(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
233/// Collects JSON Feed items from metadata sidecars.
234pub(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
247/// Builds a single `JsonFeedItem` from metadata, or `None` if invalid.
248pub(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    // Issue #586 / plan §2 item 1.4 (spec A4): shared flexible date
278    // chain — RFC 2822, long-form, and ISO 8601 all normalise to the
279    // RFC 3339 shape JSON Feed 1.1 requires; unparseable values pass
280    // through verbatim (previous behaviour) with a warning naming the
281    // failing field.
282    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    // Tags: prefer "tags" (comma-separated), fall back to "category".
296    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    // Per-item language: explicit `language`/`locale` meta, else
315    // derived from path prefix matching a known locale (e.g. "fr/...").
316    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
336/// Resolve the top-level feed language: prefer `i18n.default_locale`,
337/// then `config.language`, then `"en"`.
338pub(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
350/// Get the list of configured locales (for per-item language detection).
351pub(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
358/// If the first path segment matches a known locale code, return it.
359fn detect_locale_from_path(
360    rel_path: &str,
361    known_locales: &[String],
362) -> Option<String> {
363    // `split` always yields at least one segment, so this cannot be
364    // empty-handed; `unwrap_or_default` keeps the expression total
365    // without an unreachable `None` branch.
366    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
374/// Inject `<link rel="alternate" type="application/feed+json">` into
375/// every HTML page under `site_dir` that doesn't already have one.
376pub(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        // sorted descending => first sort_key >= last
604        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    // -----------------------------------------------------------------
682    // Flexible date chain (issue #586 / plan §2 item 1.4, spec A4)
683    // -----------------------------------------------------------------
684
685    #[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        // Verbatim fallback preserves the plugin's previous output.
723        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    // -----------------------------------------------------------------
928    // build_dir/.meta fallback (site_dir has no sidecars)
929    // -----------------------------------------------------------------
930
931    #[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    // -----------------------------------------------------------------
984    // JsonFeedItem::to_json: per-item language
985    // -----------------------------------------------------------------
986
987    #[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    // -----------------------------------------------------------------
1006    // build_item: whitespace-only category yields no tags
1007    // -----------------------------------------------------------------
1008
1009    #[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    // -----------------------------------------------------------------
1020    // extract_default_locale fallbacks
1021    // -----------------------------------------------------------------
1022
1023    #[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    // -----------------------------------------------------------------
1081    // Error paths
1082    // -----------------------------------------------------------------
1083
1084    #[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    /// RAII guard that disables a failpoint on drop.
1160    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    /// When `serialize_feed` fails (fault-injected), `after_compile`
1181    /// falls back to `Value::to_string()` (compact encoding) rather
1182    /// than propagating an error — feed.json must still be produced
1183    /// and remain valid JSON, just without pretty-printing.
1184    #[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        // Compact `Value::to_string()` output has no newlines, unlike
1215        // `to_string_pretty`, proving the fallback branch ran.
1216        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}