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!(
824            extract_known_locales(&ctx).is_empty(),
825            "{:?}",
826            extract_known_locales(&ctx)
827        );
828    }
829
830    #[test]
831    fn test_extract_default_locale_no_config_defaults_en() {
832        let ctx = PluginContext::new(
833            Path::new("c"),
834            Path::new("b"),
835            Path::new("s"),
836            Path::new("t"),
837        );
838        assert_eq!(extract_default_locale(&ctx), "en");
839    }
840
841    #[test]
842    fn test_build_feed_json_omits_empty_language() {
843        let items: Vec<JsonFeedItem> = vec![JsonFeedItem {
844            sort_key: "2026".into(),
845            id: "https://x/".into(),
846            url: "https://x/".into(),
847            title: "T".into(),
848            content_html: "h".into(),
849            date_published: "2026".into(),
850            date_modified: "2026".into(),
851            author: "A".into(),
852            tags: vec![],
853            language: None,
854        }];
855        let v = build_feed_json(
856            "Title",
857            "https://x/",
858            "https://x/feed.json",
859            "",
860            &items,
861        );
862        assert!(v.get("language").is_none());
863        assert_eq!(v["version"], JSON_FEED_VERSION);
864        assert_eq!(v["items"].as_array().unwrap().len(), 1);
865    }
866
867    #[test]
868    fn test_tags_fallback_to_category() {
869        let mut meta = HashMap::new();
870        let _ = meta.insert("title".to_string(), "T".to_string());
871        let _ = meta.insert("category".to_string(), "Tech".to_string());
872        let item = build_item("p", &meta, "https://example.com", &[]).unwrap();
873        assert_eq!(item.tags, vec!["Tech"]);
874    }
875
876    #[test]
877    #[serial_test::parallel]
878    fn test_json_feed_no_base_url() -> Result<()> {
879        let tmp = tempdir().unwrap();
880        let mut meta = HashMap::new();
881        let _ = meta.insert("title".to_string(), "T".to_string());
882        let _ = meta.insert("description".to_string(), "x".to_string());
883        let _ = meta.insert(
884            "item_pub_date".to_string(),
885            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
886        );
887        write_meta_sidecar(tmp.path(), "p", &meta);
888
889        let config = crate::cmd::SsgConfig {
890            listings: Vec::new(),
891            base_url: String::new(),
892            site_name: "S".to_string(),
893            site_title: "S".to_string(),
894            site_description: String::new(),
895            language: "en".to_string(),
896            content_dir: std::path::PathBuf::from("c"),
897            output_dir: std::path::PathBuf::from("b"),
898            template_dir: std::path::PathBuf::from("t"),
899            theme: None,
900            serve_dir: None,
901            #[cfg(feature = "i18n")]
902            i18n: None,
903            cdn_prefix: None,
904            og_image: None,
905            image: crate::cmd::ImageConfig::default(),
906            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
907            agents: None,
908            transitions: false,
909            security: crate::cmd::SecurityConfig::default(),
910            no_taxonomy_pages: false,
911        };
912        let ctx = PluginContext::with_config(
913            Path::new("c"),
914            Path::new("b"),
915            tmp.path(),
916            Path::new("t"),
917            config,
918        );
919        JsonFeedPlugin.after_compile(&ctx).unwrap();
920
921        let value: Value = serde_json::from_str(
922            &fs::read_to_string(tmp.path().join("feed.json")).unwrap(),
923        )
924        .unwrap();
925        assert_eq!(value["feed_url"], "feed.json");
926        assert_eq!(value["home_page_url"], "/");
927        assert_eq!(value["items"][0]["url"], "p/");
928        Ok(())
929    }
930
931    // -----------------------------------------------------------------
932    // build_dir/.meta fallback (site_dir has no sidecars)
933    // -----------------------------------------------------------------
934
935    #[test]
936    #[serial_test::parallel]
937    fn test_json_feed_falls_back_to_build_meta_dir() {
938        let tmp = tempdir().unwrap();
939        let build = tmp.path().join("build");
940        let site = tmp.path().join("site");
941        fs::create_dir_all(&site).unwrap();
942        let page_dir = build.join(".meta").join("post");
943        fs::create_dir_all(&page_dir).unwrap();
944        fs::write(
945            page_dir.join("index.meta.json"),
946            r#"{"title":"From Build Meta","item_pub_date":"Thu, 11 Apr 2026 06:06:06 +0000"}"#,
947        )
948        .unwrap();
949
950        crate::test_support::init_logger();
951        let config = crate::cmd::SsgConfig {
952            listings: Vec::new(),
953            base_url: "https://example.com".to_string(),
954            site_name: "Test Site".to_string(),
955            site_title: "Test Site".to_string(),
956            site_description: "A test site".to_string(),
957            language: "en".to_string(),
958            content_dir: std::path::PathBuf::from("content"),
959            output_dir: std::path::PathBuf::from("build"),
960            template_dir: std::path::PathBuf::from("templates"),
961            theme: None,
962            serve_dir: None,
963            #[cfg(feature = "i18n")]
964            i18n: None,
965            cdn_prefix: None,
966            og_image: None,
967            image: crate::cmd::ImageConfig::default(),
968            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
969            agents: None,
970            transitions: false,
971            security: crate::cmd::SecurityConfig::default(),
972            no_taxonomy_pages: false,
973        };
974        let ctx = PluginContext::with_config(
975            Path::new("content"),
976            &build,
977            &site,
978            Path::new("templates"),
979            config,
980        );
981        JsonFeedPlugin.after_compile(&ctx).unwrap();
982
983        let raw = fs::read_to_string(site.join("feed.json")).unwrap();
984        assert!(raw.contains("From Build Meta"));
985    }
986
987    // -----------------------------------------------------------------
988    // JsonFeedItem::to_json: per-item language
989    // -----------------------------------------------------------------
990
991    #[test]
992    fn test_to_json_includes_language_when_present() {
993        let item = JsonFeedItem {
994            sort_key: "2026".to_string(),
995            id: "id".to_string(),
996            url: "u/".to_string(),
997            title: "T".to_string(),
998            content_html: "C".to_string(),
999            date_published: "2026-01-01T00:00:00+00:00".to_string(),
1000            date_modified: "2026-01-01T00:00:00+00:00".to_string(),
1001            author: "A".to_string(),
1002            tags: Vec::new(),
1003            language: Some("fr".to_string()),
1004        };
1005        let v = item.to_json();
1006        assert_eq!(v["language"], "fr");
1007    }
1008
1009    // -----------------------------------------------------------------
1010    // build_item: whitespace-only category yields no tags
1011    // -----------------------------------------------------------------
1012
1013    #[cfg(feature = "i18n")]
1014    #[test]
1015    fn test_build_item_ignores_whitespace_only_category() {
1016        let mut meta = HashMap::new();
1017        let _ = meta.insert("title".to_string(), "T".to_string());
1018        let _ = meta.insert("category".to_string(), "   ".to_string());
1019        let item = build_item("p", &meta, "", &[]).unwrap();
1020        assert!(item.tags.is_empty(), "blank category must not become a tag");
1021    }
1022
1023    // -----------------------------------------------------------------
1024    // extract_default_locale fallbacks
1025    // -----------------------------------------------------------------
1026
1027    #[cfg(feature = "i18n")]
1028    fn ctx_with_locale(
1029        site_dir: &Path,
1030        default_locale: &str,
1031        language: &str,
1032    ) -> PluginContext {
1033        let config = crate::cmd::SsgConfig {
1034            listings: Vec::new(),
1035            base_url: String::new(),
1036            site_name: "S".to_string(),
1037            site_title: String::new(),
1038            site_description: String::new(),
1039            language: language.to_string(),
1040            content_dir: std::path::PathBuf::from("c"),
1041            output_dir: std::path::PathBuf::from("b"),
1042            template_dir: std::path::PathBuf::from("t"),
1043            theme: None,
1044            serve_dir: None,
1045            i18n: Some(crate::i18n::I18nConfig {
1046                default_locale: default_locale.to_string(),
1047                locales: vec!["en".to_string(), "fr".to_string()],
1048                url_prefix: Default::default(),
1049            }),
1050            cdn_prefix: None,
1051            og_image: None,
1052            image: crate::cmd::ImageConfig::default(),
1053            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
1054            agents: None,
1055            transitions: false,
1056            security: crate::cmd::SecurityConfig::default(),
1057            no_taxonomy_pages: false,
1058        };
1059        PluginContext::with_config(
1060            Path::new("c"),
1061            Path::new("b"),
1062            site_dir,
1063            Path::new("t"),
1064            config,
1065        )
1066    }
1067
1068    #[cfg(feature = "i18n")]
1069    #[test]
1070    fn test_extract_default_locale_empty_i18n_uses_language() {
1071        let tmp = tempdir().unwrap();
1072        let ctx = ctx_with_locale(tmp.path(), "", "de");
1073        assert_eq!(extract_default_locale(&ctx), "de");
1074    }
1075
1076    #[cfg(feature = "i18n")]
1077    #[test]
1078    fn test_extract_default_locale_all_empty_falls_back_to_en() {
1079        let tmp = tempdir().unwrap();
1080        let ctx = ctx_with_locale(tmp.path(), "", "");
1081        assert_eq!(extract_default_locale(&ctx), "en");
1082    }
1083
1084    // -----------------------------------------------------------------
1085    // Error paths
1086    // -----------------------------------------------------------------
1087
1088    #[test]
1089    #[serial_test::parallel]
1090    fn test_after_compile_errors_when_feed_json_is_a_directory() {
1091        let tmp = tempdir().unwrap();
1092        let mut meta = HashMap::new();
1093        let _ = meta.insert("title".to_string(), "Post".to_string());
1094        write_meta_sidecar(tmp.path(), "post", &meta);
1095        fs::create_dir_all(tmp.path().join("feed.json")).unwrap();
1096
1097        let ctx = make_ctx(tmp.path());
1098        let err = JsonFeedPlugin.after_compile(&ctx).unwrap_err();
1099        assert!(format!("{err}").contains("feed.json"));
1100    }
1101
1102    #[test]
1103    #[serial_test::parallel]
1104    fn test_after_compile_propagates_unreadable_html_error() {
1105        let tmp = tempdir().unwrap();
1106        let mut meta = HashMap::new();
1107        let _ = meta.insert("title".to_string(), "Post".to_string());
1108        write_meta_sidecar(tmp.path(), "post", &meta);
1109        fs::write(tmp.path().join("bad.html"), [0xFF, 0xFE, 0xFD]).unwrap();
1110
1111        let ctx = make_ctx(tmp.path());
1112        let err = JsonFeedPlugin.after_compile(&ctx).unwrap_err();
1113        assert!(format!("{err}").contains("bad.html"));
1114    }
1115
1116    #[test]
1117    #[cfg(unix)]
1118    fn test_inject_json_feed_link_write_failure_on_readonly_html() {
1119        use std::os::unix::fs::PermissionsExt;
1120        let tmp = tempdir().unwrap();
1121        let html_path = tmp.path().join("index.html");
1122        fs::write(
1123            &html_path,
1124            "<html><head><title>T</title></head><body></body></html>",
1125        )
1126        .unwrap();
1127        fs::set_permissions(&html_path, fs::Permissions::from_mode(0o444))
1128            .unwrap();
1129
1130        let result =
1131            inject_json_feed_link(tmp.path(), "https://x.example/feed.json");
1132        let _ =
1133            fs::set_permissions(&html_path, fs::Permissions::from_mode(0o644));
1134        let err = result.unwrap_err();
1135        assert!(format!("{err}").contains("index.html"));
1136    }
1137
1138    #[test]
1139    #[cfg(unix)]
1140    fn test_inject_json_feed_link_walk_failure_on_unreadable_subdir() {
1141        use std::os::unix::fs::PermissionsExt;
1142        let tmp = tempdir().unwrap();
1143        let locked = tmp.path().join("locked");
1144        fs::create_dir_all(&locked).unwrap();
1145        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1146            .unwrap();
1147
1148        let result =
1149            inject_json_feed_link(tmp.path(), "https://x.example/feed.json");
1150        let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1151        assert!(result.is_err(), "expected an error from the locked path");
1152    }
1153}
1154
1155#[cfg(all(test, feature = "test-fault-injection"))]
1156mod fault_tests {
1157    use super::*;
1158    use crate::plugin::PluginContext;
1159    use serial_test::serial;
1160    use std::path::Path;
1161    use tempfile::tempdir;
1162
1163    /// RAII guard that disables a failpoint on drop.
1164    struct FailGuard(&'static str);
1165
1166    impl Drop for FailGuard {
1167        fn drop(&mut self) {
1168            let _ = fail::cfg(self.0, "off");
1169        }
1170    }
1171
1172    fn write_meta_sidecar(
1173        dir: &Path,
1174        slug: &str,
1175        meta: &HashMap<String, String>,
1176    ) {
1177        let page_dir = dir.join(slug);
1178        fs::create_dir_all(&page_dir).expect("create page dir");
1179        let meta_path = page_dir.join("index.meta.json");
1180        let json = serde_json::to_string(meta).expect("serialize meta");
1181        fs::write(&meta_path, json).expect("write meta");
1182    }
1183
1184    /// When `serialize_feed` fails (fault-injected), `after_compile`
1185    /// falls back to `Value::to_string()` (compact encoding) rather
1186    /// than propagating an error — feed.json must still be produced
1187    /// and remain valid JSON, just without pretty-printing.
1188    #[test]
1189    #[serial]
1190    fn after_compile_falls_back_to_compact_encoding_on_serialize_failure() {
1191        let _guard = FailGuard("postprocess::json-feed-serialize");
1192        fail::cfg("postprocess::json-feed-serialize", "return")
1193            .expect("activate failpoint");
1194
1195        let tmp = tempdir().unwrap();
1196        let mut meta = HashMap::new();
1197        let _ = meta.insert("title".to_string(), "Fallback".to_string());
1198        let _ = meta.insert("description".to_string(), "x".to_string());
1199        let _ = meta.insert(
1200            "item_pub_date".to_string(),
1201            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1202        );
1203        write_meta_sidecar(tmp.path(), "fallback-post", &meta);
1204
1205        crate::test_support::init_logger();
1206        let ctx = PluginContext::new(
1207            Path::new("content"),
1208            Path::new("build"),
1209            tmp.path(),
1210            Path::new("templates"),
1211        );
1212        JsonFeedPlugin
1213            .after_compile(&ctx)
1214            .expect("fallback path must not surface an error");
1215
1216        let feed_path = tmp.path().join("feed.json");
1217        let raw = fs::read_to_string(&feed_path).unwrap();
1218        // Compact `Value::to_string()` output has no newlines, unlike
1219        // `to_string_pretty`, proving the fallback branch ran.
1220        assert!(
1221            !raw.contains('\n'),
1222            "expected compact fallback encoding, got: {raw}"
1223        );
1224        let value: Value = serde_json::from_str(&raw).unwrap();
1225        assert_eq!(value["items"][0]["title"], "Fallback");
1226    }
1227}