use super::helpers::read_meta_sidecars;
use crate::dates::parse_flexible_date;
use crate::error::{PathErrorExt, SsgError};
use crate::plugin::{Plugin, PluginContext};
use crate::util::head_dom::inject_before_head_close;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
const JSON_FEED_VERSION: &str = "https://jsonfeed.org/version/1.1";
const MAX_ITEMS: usize = 50;
#[derive(Debug, Clone, Copy)]
pub struct JsonFeedPlugin;
impl Plugin for JsonFeedPlugin {
fn name(&self) -> &'static str {
"json-feed"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
let mut meta_entries =
read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
if meta_entries.is_empty() {
let meta_dir = ctx.build_dir.join(".meta");
if meta_dir.exists() {
meta_entries =
read_meta_sidecars(&meta_dir).unwrap_or_default();
}
}
if meta_entries.is_empty() {
meta_entries = super::atom::extract_entries_from_rss(&ctx.site_dir);
}
let base_url = ctx
.config
.as_ref()
.map(|c| c.base_url.trim_end_matches('/').to_string())
.unwrap_or_default();
let site_name = ctx
.config
.as_ref()
.map(|c| c.site_name.clone())
.unwrap_or_default();
let feed_title = if site_name.is_empty() {
"Untitled".to_string()
} else {
site_name
};
let default_locale = extract_default_locale(ctx);
let known_locales = extract_known_locales(ctx);
let mut items = collect_items(&meta_entries, &base_url, &known_locales);
items.sort_by(|a, b| {
b.sort_key.cmp(&a.sort_key).then_with(|| a.id.cmp(&b.id))
});
items.truncate(MAX_ITEMS);
if items.is_empty() {
return Ok(());
}
let feed_url = if base_url.is_empty() {
"feed.json".to_string()
} else {
format!("{base_url}/feed.json")
};
let home_page_url = if base_url.is_empty() {
"/".to_string()
} else {
format!("{base_url}/")
};
let feed_json = build_feed_json(
&feed_title,
&home_page_url,
&feed_url,
&default_locale,
&items,
);
let feed_path = ctx.site_dir.join("feed.json");
let serialized = serialize_feed(&feed_json)
.unwrap_or_else(|_| feed_json.to_string());
fs::write(&feed_path, serialized).with_path(&feed_path)?;
inject_json_feed_link(&ctx.site_dir, &feed_url)?;
log::info!(
"[json-feed] Generated feed.json with {} items",
items.len()
);
Ok(())
}
}
fn serialize_feed(feed_json: &Value) -> serde_json::Result<String> {
fail_point!("postprocess::json-feed-serialize", |_| Err(
<serde_json::Error as serde::ser::Error>::custom(
"injected: postprocess::json-feed-serialize"
)
));
serde_json::to_string_pretty(feed_json)
}
pub(super) struct JsonFeedItem {
pub sort_key: String,
pub id: String,
pub url: String,
pub title: String,
pub content_html: String,
pub date_published: String,
pub date_modified: String,
pub author: String,
pub tags: Vec<String>,
pub language: Option<String>,
}
impl JsonFeedItem {
pub(super) fn to_json(&self) -> Value {
let mut obj = Map::new();
let _ = obj.insert("id".into(), Value::String(self.id.clone()));
let _ = obj.insert("url".into(), Value::String(self.url.clone()));
let _ = obj.insert("title".into(), Value::String(self.title.clone()));
let _ = obj.insert(
"content_html".into(),
Value::String(self.content_html.clone()),
);
let _ = obj.insert(
"date_published".into(),
Value::String(self.date_published.clone()),
);
let _ = obj.insert(
"date_modified".into(),
Value::String(self.date_modified.clone()),
);
let author_name = if self.author.is_empty() {
"Unknown".to_string()
} else {
self.author.clone()
};
let _ = obj.insert(
"authors".into(),
Value::Array(vec![json!({ "name": author_name })]),
);
let _ = obj.insert(
"tags".into(),
Value::Array(
self.tags.iter().map(|t| Value::String(t.clone())).collect(),
),
);
if let Some(ref lang) = self.language {
let _ = obj.insert("language".into(), Value::String(lang.clone()));
}
Value::Object(obj)
}
}
pub(super) fn build_feed_json(
title: &str,
home_page_url: &str,
feed_url: &str,
language: &str,
items: &[JsonFeedItem],
) -> Value {
let items_json: Vec<Value> =
items.iter().map(JsonFeedItem::to_json).collect();
let mut feed = Map::new();
let _ = feed.insert(
"version".into(),
Value::String(JSON_FEED_VERSION.to_string()),
);
let _ = feed.insert("title".into(), Value::String(title.to_string()));
let _ = feed.insert(
"home_page_url".into(),
Value::String(home_page_url.to_string()),
);
let _ = feed.insert("feed_url".into(), Value::String(feed_url.to_string()));
if !language.is_empty() {
let _ =
feed.insert("language".into(), Value::String(language.to_string()));
}
let _ = feed.insert("items".into(), Value::Array(items_json));
Value::Object(feed)
}
pub(super) fn collect_items(
meta_entries: &[(String, HashMap<String, String>)],
base_url: &str,
known_locales: &[String],
) -> Vec<JsonFeedItem> {
meta_entries
.iter()
.filter_map(|(rel_path, meta)| {
build_item(rel_path, meta, base_url, known_locales)
})
.collect()
}
pub(super) fn build_item(
rel_path: &str,
meta: &HashMap<String, String>,
base_url: &str,
known_locales: &[String],
) -> Option<JsonFeedItem> {
if rel_path.is_empty() {
return None;
}
let title = meta.get("title").cloned().unwrap_or_default();
if title.is_empty() {
return None;
}
let description = meta.get("description").cloned().unwrap_or_default();
let pub_date = meta.get("item_pub_date").cloned().unwrap_or_default();
let modified_date = meta
.get("last_build_date")
.or_else(|| meta.get("date_modified"))
.cloned()
.unwrap_or_else(|| pub_date.clone());
let author = meta.get("author").cloned().unwrap_or_default();
let url = if base_url.is_empty() {
format!("{rel_path}/")
} else {
format!("{base_url}/{rel_path}/")
};
let flex_rfc3339 = |field: &str, raw: &str| match parse_flexible_date(raw) {
Ok(dt) => dt.to_rfc3339(),
Err(err) => {
if !raw.is_empty() {
log::warn!("[json-feed] '{field}' for '{rel_path}': {err}");
}
raw.to_string()
}
};
let date_published = flex_rfc3339("item_pub_date", &pub_date);
let date_modified =
flex_rfc3339("last_build_date/date_modified", &modified_date);
let mut tags: Vec<String> = meta
.get("tags")
.map(|t| {
t.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
if tags.is_empty() {
if let Some(cat) = meta.get("category") {
let trimmed = cat.trim();
if !trimmed.is_empty() {
tags.push(trimmed.to_string());
}
}
}
let language = meta
.get("language")
.or_else(|| meta.get("locale"))
.cloned()
.or_else(|| detect_locale_from_path(rel_path, known_locales));
Some(JsonFeedItem {
sort_key: date_published.clone(),
id: url.clone(),
url,
title,
content_html: description,
date_published,
date_modified,
author,
tags,
language,
})
}
pub(super) fn extract_default_locale(ctx: &PluginContext) -> String {
if let Some(cfg) = ctx.config.as_ref() {
if let Some(ref i18n) = cfg.i18n {
if !i18n.default_locale.is_empty() {
return i18n.default_locale.clone();
}
}
if !cfg.language.is_empty() {
return cfg.language.clone();
}
}
"en".to_string()
}
pub(super) fn extract_known_locales(ctx: &PluginContext) -> Vec<String> {
ctx.config
.as_ref()
.and_then(|c| c.i18n.as_ref())
.map(|i| i.locales.clone())
.unwrap_or_default()
}
fn detect_locale_from_path(
rel_path: &str,
known_locales: &[String],
) -> Option<String> {
let first = rel_path.split('/').next().unwrap_or_default();
if known_locales.iter().any(|l| l == first) {
Some(first.to_string())
} else {
None
}
}
pub(super) fn inject_json_feed_link(
site_dir: &Path,
feed_url: &str,
) -> Result<(), SsgError> {
let html_files = crate::walk::walk_files(site_dir, "html")
.map_err(|e| SsgError::io(e, site_dir))?;
for path in &html_files {
let html = fs::read_to_string(path).with_path(path)?;
if html.contains("application/feed+json") {
continue;
}
let link_tag = format!(
" <link rel=\"alternate\" type=\"application/feed+json\" title=\"JSON Feed\" href=\"{feed_url}\"/>\n"
);
let modified = inject_before_head_close(&html, &link_tag);
if modified != html {
fs::write(path, &modified).with_path(path)?;
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::plugin::PluginContext;
use anyhow::Result;
use std::path::Path;
use tempfile::tempdir;
fn write_meta_sidecar(
dir: &Path,
slug: &str,
meta: &HashMap<String, String>,
) {
let page_dir = dir.join(slug);
fs::create_dir_all(&page_dir).expect("create page dir");
let meta_path = page_dir.join("index.meta.json");
let json = serde_json::to_string(meta).expect("serialize meta");
fs::write(&meta_path, json).expect("write meta");
}
fn make_ctx(site_dir: &Path) -> PluginContext {
crate::test_support::init_logger();
let config = crate::cmd::SsgConfig {
base_url: "https://example.com".to_string(),
site_name: "Test Site".to_string(),
site_title: "Test Site".to_string(),
site_description: "A test site".to_string(),
language: "en".to_string(),
content_dir: std::path::PathBuf::from("content"),
output_dir: std::path::PathBuf::from("build"),
template_dir: std::path::PathBuf::from("templates"),
serve_dir: None,
i18n: None,
cdn_prefix: None,
image: crate::cmd::ImageConfig::default(),
edge_headers: crate::cmd::EdgeHeadersConfig::default(),
agents: None,
transitions: false,
security: crate::cmd::SecurityConfig::default(),
};
PluginContext::with_config(
Path::new("content"),
Path::new("build"),
site_dir,
Path::new("templates"),
config,
)
}
#[test]
#[serial_test::parallel]
fn test_json_feed_top_level_fields() -> Result<()> {
let tmp = tempdir().unwrap();
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "Hello World".to_string());
let _ = meta.insert(
"description".to_string(),
"<p>A test post</p>".to_string(),
);
let _ = meta.insert(
"item_pub_date".to_string(),
"Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
);
let _ = meta.insert("author".to_string(), "Alice".to_string());
let _ = meta.insert("tags".to_string(), "rust, web".to_string());
write_meta_sidecar(tmp.path(), "hello", &meta);
let ctx = make_ctx(tmp.path());
JsonFeedPlugin.after_compile(&ctx).unwrap();
let feed_path = tmp.path().join("feed.json");
assert!(feed_path.exists(), "feed.json should be created");
let raw = fs::read_to_string(&feed_path).unwrap();
let value: Value = serde_json::from_str(&raw).unwrap();
assert_eq!(value["version"], JSON_FEED_VERSION);
assert_eq!(value["title"], "Test Site");
assert_eq!(value["home_page_url"], "https://example.com/");
assert_eq!(value["feed_url"], "https://example.com/feed.json");
assert_eq!(value["language"], "en");
assert!(value["items"].is_array());
assert_eq!(value["items"].as_array().unwrap().len(), 1);
Ok(())
}
#[test]
#[serial_test::parallel]
fn test_json_feed_item_required_fields() -> Result<()> {
let tmp = tempdir().unwrap();
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "Item Test".to_string());
let _ =
meta.insert("description".to_string(), "<p>body</p>".to_string());
let _ = meta.insert(
"item_pub_date".to_string(),
"Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
);
let _ = meta.insert("author".to_string(), "Bob".to_string());
let _ = meta.insert("tags".to_string(), "alpha".to_string());
write_meta_sidecar(tmp.path(), "item-test", &meta);
let ctx = make_ctx(tmp.path());
JsonFeedPlugin.after_compile(&ctx).unwrap();
let value: Value = serde_json::from_str(
&fs::read_to_string(tmp.path().join("feed.json")).unwrap(),
)
.unwrap();
let item = &value["items"][0];
assert!(item["id"].is_string());
assert!(item["url"].is_string());
assert_eq!(item["title"], "Item Test");
assert_eq!(item["content_html"], "<p>body</p>");
assert_eq!(item["date_published"], "2026-04-11T06:06:06+00:00");
assert_eq!(item["date_modified"], "2026-04-11T06:06:06+00:00");
let authors = item["authors"].as_array().unwrap();
assert_eq!(authors.len(), 1);
assert_eq!(authors[0]["name"], "Bob");
let tags = item["tags"].as_array().unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0], "alpha");
Ok(())
}
#[test]
#[serial_test::parallel]
fn test_json_feed_injects_link_into_html() -> Result<()> {
let tmp = tempdir().unwrap();
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "Link Test".to_string());
let _ = meta.insert("description".to_string(), "x".to_string());
let _ = meta.insert(
"item_pub_date".to_string(),
"Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
);
write_meta_sidecar(tmp.path(), "linktest", &meta);
let html_path = tmp.path().join("index.html");
fs::write(
&html_path,
"<html><head><title>T</title></head><body></body></html>",
)
.unwrap();
let ctx = make_ctx(tmp.path());
JsonFeedPlugin.after_compile(&ctx).unwrap();
let html = fs::read_to_string(&html_path).unwrap();
assert!(
html.contains("application/feed+json"),
"missing feed+json link tag in: {html}"
);
assert!(html.contains("href=\"https://example.com/feed.json\""));
Ok(())
}
#[test]
#[serial_test::parallel]
fn test_json_feed_empty_site_dir() -> Result<()> {
let tmp = tempdir().unwrap();
let ctx = make_ctx(tmp.path());
JsonFeedPlugin.after_compile(&ctx).unwrap();
assert!(!tmp.path().join("feed.json").exists());
Ok(())
}
#[test]
#[serial_test::parallel]
fn test_json_feed_sorts_descending_and_truncates() -> Result<()> {
let tmp = tempdir().unwrap();
for i in 0..60 {
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), format!("P{i}"));
let _ = meta.insert("description".to_string(), format!("body {i}"));
let _ = meta.insert(
"item_pub_date".to_string(),
format!(
"Thu, {:02} Apr 2026 {:02}:00:00 +0000",
(i % 28) + 1,
i % 24
),
);
write_meta_sidecar(tmp.path(), &format!("post-{i:03}"), &meta);
}
let ctx = make_ctx(tmp.path());
JsonFeedPlugin.after_compile(&ctx).unwrap();
let value: Value = serde_json::from_str(
&fs::read_to_string(tmp.path().join("feed.json")).unwrap(),
)
.unwrap();
let items = value["items"].as_array().unwrap();
assert_eq!(items.len(), MAX_ITEMS);
let first = items[0]["date_published"].as_str().unwrap();
let last = items[items.len() - 1]["date_published"].as_str().unwrap();
assert!(first >= last);
Ok(())
}
#[test]
#[serial_test::parallel]
fn test_json_feed_empty_author_shows_unknown() -> Result<()> {
let tmp = tempdir().unwrap();
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "T".to_string());
let _ = meta.insert("description".to_string(), "b".to_string());
let _ = meta.insert(
"item_pub_date".to_string(),
"Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
);
write_meta_sidecar(tmp.path(), "noauth", &meta);
let ctx = make_ctx(tmp.path());
JsonFeedPlugin.after_compile(&ctx).unwrap();
let value: Value = serde_json::from_str(
&fs::read_to_string(tmp.path().join("feed.json")).unwrap(),
)
.unwrap();
assert_eq!(value["items"][0]["authors"][0]["name"], "Unknown");
Ok(())
}
#[test]
fn test_json_feed_locale_detection_from_path() {
let known = vec!["en".to_string(), "fr".to_string()];
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "Bonjour".to_string());
let _ = meta.insert("description".to_string(), "x".to_string());
let item =
build_item("fr/bonjour", &meta, "https://example.com", &known)
.unwrap();
assert_eq!(item.language, Some("fr".to_string()));
}
#[test]
fn test_json_feed_explicit_locale_overrides_path() {
let known = vec!["en".to_string(), "fr".to_string()];
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "T".to_string());
let _ = meta.insert("description".to_string(), "x".to_string());
let _ = meta.insert("language".to_string(), "de".to_string());
let item = build_item("fr/post", &meta, "https://example.com", &known)
.unwrap();
assert_eq!(item.language, Some("de".to_string()));
}
#[test]
fn test_json_feed_skips_empty_title() {
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), String::new());
assert!(build_item("post", &meta, "https://example.com", &[]).is_none());
}
#[test]
fn test_json_feed_skips_empty_path() {
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "T".to_string());
assert!(build_item("", &meta, "https://example.com", &[]).is_none());
}
#[test]
fn test_json_feed_id_matches_url() {
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "T".to_string());
let _ = meta.insert("description".to_string(), "x".to_string());
let item = build_item("p", &meta, "https://example.com", &[]).unwrap();
assert_eq!(item.id, item.url);
assert_eq!(item.url, "https://example.com/p/");
}
#[test]
fn test_build_item_iso_date_normalised_to_rfc3339() {
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "ISO".to_string());
let _ =
meta.insert("item_pub_date".to_string(), "2026-07-01".to_string());
let item = build_item("iso", &meta, "https://example.com", &[])
.expect("valid item");
assert_eq!(item.date_published, "2026-07-01T00:00:00+00:00");
assert_eq!(item.date_modified, "2026-07-01T00:00:00+00:00");
}
#[test]
fn test_build_item_long_form_date_normalised_to_rfc3339() {
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "Long".to_string());
let _ = meta
.insert("item_pub_date".to_string(), "July 1, 2026".to_string());
let _ = meta.insert(
"date_modified".to_string(),
"2026-07-02T07:07:07Z".to_string(),
);
let item = build_item("long", &meta, "https://example.com", &[])
.expect("valid item");
assert_eq!(item.date_published, "2026-07-01T00:00:00+00:00");
assert_eq!(item.date_modified, "2026-07-02T07:07:07+00:00");
}
#[test]
fn test_build_item_unparseable_date_passes_through() {
crate::test_support::init_logger();
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "Bad".to_string());
let _ =
meta.insert("item_pub_date".to_string(), "not-a-date".to_string());
let item = build_item("bad", &meta, "https://example.com", &[])
.expect("valid item");
assert_eq!(item.date_published, "not-a-date");
}
#[test]
fn test_json_feed_plugin_name() {
assert_eq!(JsonFeedPlugin.name(), "json-feed");
}
#[test]
fn test_json_feed_plugin_registers() {
use crate::plugin::PluginManager;
let mut pm = PluginManager::new();
pm.register(JsonFeedPlugin);
assert!(pm.names().contains(&"json-feed"));
}
#[test]
fn test_json_feed_idempotent_link_injection() -> Result<()> {
let tmp = tempdir().unwrap();
let html_path = tmp.path().join("page.html");
fs::write(
&html_path,
"<html><head><title>T</title></head><body></body></html>",
)
.unwrap();
inject_json_feed_link(tmp.path(), "https://example.com/feed.json")
.unwrap();
let first = fs::read_to_string(&html_path).unwrap();
inject_json_feed_link(tmp.path(), "https://example.com/feed.json")
.unwrap();
let second = fs::read_to_string(&html_path).unwrap();
assert_eq!(first, second);
assert_eq!(
second.matches("application/feed+json").count(),
1,
"should inject exactly one link tag"
);
Ok(())
}
#[test]
fn test_json_feed_link_skips_files_without_head() -> Result<()> {
let tmp = tempdir().unwrap();
let html_path = tmp.path().join("frag.html");
fs::write(&html_path, "<div>no head</div>").unwrap();
inject_json_feed_link(tmp.path(), "https://example.com/feed.json")
.unwrap();
let result = fs::read_to_string(&html_path).unwrap();
assert!(!result.contains("application/feed+json"));
Ok(())
}
#[test]
fn test_extract_default_locale_prefers_i18n() {
use crate::i18n::I18nConfig;
let tmp = tempdir().unwrap();
let config = crate::cmd::SsgConfig {
base_url: "https://example.com".to_string(),
site_name: "S".to_string(),
site_title: "S".to_string(),
site_description: String::new(),
language: "en".to_string(),
content_dir: std::path::PathBuf::from("c"),
output_dir: std::path::PathBuf::from("b"),
template_dir: std::path::PathBuf::from("t"),
serve_dir: None,
i18n: Some(I18nConfig {
default_locale: "fr".to_string(),
locales: vec!["en".into(), "fr".into()],
url_prefix: crate::i18n::UrlPrefixStrategy::SubPath,
}),
cdn_prefix: None,
image: crate::cmd::ImageConfig::default(),
edge_headers: crate::cmd::EdgeHeadersConfig::default(),
agents: None,
transitions: false,
security: crate::cmd::SecurityConfig::default(),
};
let ctx = PluginContext::with_config(
Path::new("c"),
Path::new("b"),
tmp.path(),
Path::new("t"),
config,
);
assert_eq!(extract_default_locale(&ctx), "fr");
assert_eq!(extract_known_locales(&ctx), vec!["en", "fr"]);
}
#[test]
fn test_extract_default_locale_falls_back_to_language() {
let tmp = tempdir().unwrap();
let ctx = make_ctx(tmp.path());
assert_eq!(extract_default_locale(&ctx), "en");
assert!(extract_known_locales(&ctx).is_empty());
}
#[test]
fn test_extract_default_locale_no_config_defaults_en() {
let ctx = PluginContext::new(
Path::new("c"),
Path::new("b"),
Path::new("s"),
Path::new("t"),
);
assert_eq!(extract_default_locale(&ctx), "en");
}
#[test]
fn test_build_feed_json_omits_empty_language() {
let items: Vec<JsonFeedItem> = vec![JsonFeedItem {
sort_key: "2026".into(),
id: "https://x/".into(),
url: "https://x/".into(),
title: "T".into(),
content_html: "h".into(),
date_published: "2026".into(),
date_modified: "2026".into(),
author: "A".into(),
tags: vec![],
language: None,
}];
let v = build_feed_json(
"Title",
"https://x/",
"https://x/feed.json",
"",
&items,
);
assert!(v.get("language").is_none());
assert_eq!(v["version"], JSON_FEED_VERSION);
assert_eq!(v["items"].as_array().unwrap().len(), 1);
}
#[test]
fn test_tags_fallback_to_category() {
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "T".to_string());
let _ = meta.insert("category".to_string(), "Tech".to_string());
let item = build_item("p", &meta, "https://example.com", &[]).unwrap();
assert_eq!(item.tags, vec!["Tech"]);
}
#[test]
#[serial_test::parallel]
fn test_json_feed_no_base_url() -> Result<()> {
let tmp = tempdir().unwrap();
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "T".to_string());
let _ = meta.insert("description".to_string(), "x".to_string());
let _ = meta.insert(
"item_pub_date".to_string(),
"Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
);
write_meta_sidecar(tmp.path(), "p", &meta);
let config = crate::cmd::SsgConfig {
base_url: String::new(),
site_name: "S".to_string(),
site_title: "S".to_string(),
site_description: String::new(),
language: "en".to_string(),
content_dir: std::path::PathBuf::from("c"),
output_dir: std::path::PathBuf::from("b"),
template_dir: std::path::PathBuf::from("t"),
serve_dir: None,
i18n: None,
cdn_prefix: None,
image: crate::cmd::ImageConfig::default(),
edge_headers: crate::cmd::EdgeHeadersConfig::default(),
agents: None,
transitions: false,
security: crate::cmd::SecurityConfig::default(),
};
let ctx = PluginContext::with_config(
Path::new("c"),
Path::new("b"),
tmp.path(),
Path::new("t"),
config,
);
JsonFeedPlugin.after_compile(&ctx).unwrap();
let value: Value = serde_json::from_str(
&fs::read_to_string(tmp.path().join("feed.json")).unwrap(),
)
.unwrap();
assert_eq!(value["feed_url"], "feed.json");
assert_eq!(value["home_page_url"], "/");
assert_eq!(value["items"][0]["url"], "p/");
Ok(())
}
#[test]
#[serial_test::parallel]
fn test_json_feed_falls_back_to_build_meta_dir() {
let tmp = tempdir().unwrap();
let build = tmp.path().join("build");
let site = tmp.path().join("site");
fs::create_dir_all(&site).unwrap();
let page_dir = build.join(".meta").join("post");
fs::create_dir_all(&page_dir).unwrap();
fs::write(
page_dir.join("index.meta.json"),
r#"{"title":"From Build Meta","item_pub_date":"Thu, 11 Apr 2026 06:06:06 +0000"}"#,
)
.unwrap();
crate::test_support::init_logger();
let config = crate::cmd::SsgConfig {
base_url: "https://example.com".to_string(),
site_name: "Test Site".to_string(),
site_title: "Test Site".to_string(),
site_description: "A test site".to_string(),
language: "en".to_string(),
content_dir: std::path::PathBuf::from("content"),
output_dir: std::path::PathBuf::from("build"),
template_dir: std::path::PathBuf::from("templates"),
serve_dir: None,
i18n: None,
cdn_prefix: None,
image: crate::cmd::ImageConfig::default(),
edge_headers: crate::cmd::EdgeHeadersConfig::default(),
agents: None,
transitions: false,
security: crate::cmd::SecurityConfig::default(),
};
let ctx = PluginContext::with_config(
Path::new("content"),
&build,
&site,
Path::new("templates"),
config,
);
JsonFeedPlugin.after_compile(&ctx).unwrap();
let raw = fs::read_to_string(site.join("feed.json")).unwrap();
assert!(raw.contains("From Build Meta"));
}
#[test]
fn test_to_json_includes_language_when_present() {
let item = JsonFeedItem {
sort_key: "2026".to_string(),
id: "id".to_string(),
url: "u/".to_string(),
title: "T".to_string(),
content_html: "C".to_string(),
date_published: "2026-01-01T00:00:00+00:00".to_string(),
date_modified: "2026-01-01T00:00:00+00:00".to_string(),
author: "A".to_string(),
tags: Vec::new(),
language: Some("fr".to_string()),
};
let v = item.to_json();
assert_eq!(v["language"], "fr");
}
#[test]
fn test_build_item_ignores_whitespace_only_category() {
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "T".to_string());
let _ = meta.insert("category".to_string(), " ".to_string());
let item = build_item("p", &meta, "", &[]).unwrap();
assert!(item.tags.is_empty(), "blank category must not become a tag");
}
fn ctx_with_locale(
site_dir: &Path,
default_locale: &str,
language: &str,
) -> PluginContext {
let config = crate::cmd::SsgConfig {
base_url: String::new(),
site_name: "S".to_string(),
site_title: String::new(),
site_description: String::new(),
language: language.to_string(),
content_dir: std::path::PathBuf::from("c"),
output_dir: std::path::PathBuf::from("b"),
template_dir: std::path::PathBuf::from("t"),
serve_dir: None,
i18n: Some(crate::i18n::I18nConfig {
default_locale: default_locale.to_string(),
locales: vec!["en".to_string(), "fr".to_string()],
url_prefix: Default::default(),
}),
cdn_prefix: None,
image: crate::cmd::ImageConfig::default(),
edge_headers: crate::cmd::EdgeHeadersConfig::default(),
agents: None,
transitions: false,
security: crate::cmd::SecurityConfig::default(),
};
PluginContext::with_config(
Path::new("c"),
Path::new("b"),
site_dir,
Path::new("t"),
config,
)
}
#[test]
fn test_extract_default_locale_empty_i18n_uses_language() {
let tmp = tempdir().unwrap();
let ctx = ctx_with_locale(tmp.path(), "", "de");
assert_eq!(extract_default_locale(&ctx), "de");
}
#[test]
fn test_extract_default_locale_all_empty_falls_back_to_en() {
let tmp = tempdir().unwrap();
let ctx = ctx_with_locale(tmp.path(), "", "");
assert_eq!(extract_default_locale(&ctx), "en");
}
#[test]
#[serial_test::parallel]
fn test_after_compile_errors_when_feed_json_is_a_directory() {
let tmp = tempdir().unwrap();
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "Post".to_string());
write_meta_sidecar(tmp.path(), "post", &meta);
fs::create_dir_all(tmp.path().join("feed.json")).unwrap();
let ctx = make_ctx(tmp.path());
let err = JsonFeedPlugin.after_compile(&ctx).unwrap_err();
assert!(format!("{err}").contains("feed.json"));
}
#[test]
#[serial_test::parallel]
fn test_after_compile_propagates_unreadable_html_error() {
let tmp = tempdir().unwrap();
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "Post".to_string());
write_meta_sidecar(tmp.path(), "post", &meta);
fs::write(tmp.path().join("bad.html"), [0xFF, 0xFE, 0xFD]).unwrap();
let ctx = make_ctx(tmp.path());
let err = JsonFeedPlugin.after_compile(&ctx).unwrap_err();
assert!(format!("{err}").contains("bad.html"));
}
#[test]
#[cfg(unix)]
fn test_inject_json_feed_link_write_failure_on_readonly_html() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().unwrap();
let html_path = tmp.path().join("index.html");
fs::write(
&html_path,
"<html><head><title>T</title></head><body></body></html>",
)
.unwrap();
fs::set_permissions(&html_path, fs::Permissions::from_mode(0o444))
.unwrap();
let result =
inject_json_feed_link(tmp.path(), "https://x.example/feed.json");
let _ =
fs::set_permissions(&html_path, fs::Permissions::from_mode(0o644));
let err = result.unwrap_err();
assert!(format!("{err}").contains("index.html"));
}
#[test]
#[cfg(unix)]
fn test_inject_json_feed_link_walk_failure_on_unreadable_subdir() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().unwrap();
let locked = tmp.path().join("locked");
fs::create_dir_all(&locked).unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
.unwrap();
let result =
inject_json_feed_link(tmp.path(), "https://x.example/feed.json");
let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
assert!(result.is_err(), "expected an error from the locked path");
}
}
#[cfg(all(test, feature = "test-fault-injection"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod fault_tests {
use super::*;
use crate::plugin::PluginContext;
use serial_test::serial;
use std::path::Path;
use tempfile::tempdir;
struct FailGuard(&'static str);
impl Drop for FailGuard {
fn drop(&mut self) {
let _ = fail::cfg(self.0, "off");
}
}
fn write_meta_sidecar(
dir: &Path,
slug: &str,
meta: &HashMap<String, String>,
) {
let page_dir = dir.join(slug);
fs::create_dir_all(&page_dir).expect("create page dir");
let meta_path = page_dir.join("index.meta.json");
let json = serde_json::to_string(meta).expect("serialize meta");
fs::write(&meta_path, json).expect("write meta");
}
#[test]
#[serial]
fn after_compile_falls_back_to_compact_encoding_on_serialize_failure() {
let _guard = FailGuard("postprocess::json-feed-serialize");
fail::cfg("postprocess::json-feed-serialize", "return")
.expect("activate failpoint");
let tmp = tempdir().unwrap();
let mut meta = HashMap::new();
let _ = meta.insert("title".to_string(), "Fallback".to_string());
let _ = meta.insert("description".to_string(), "x".to_string());
let _ = meta.insert(
"item_pub_date".to_string(),
"Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
);
write_meta_sidecar(tmp.path(), "fallback-post", &meta);
crate::test_support::init_logger();
let ctx = PluginContext::new(
Path::new("content"),
Path::new("build"),
tmp.path(),
Path::new("templates"),
);
JsonFeedPlugin
.after_compile(&ctx)
.expect("fallback path must not surface an error");
let feed_path = tmp.path().join("feed.json");
let raw = fs::read_to_string(&feed_path).unwrap();
assert!(
!raw.contains('\n'),
"expected compact fallback encoding, got: {raw}"
);
let value: Value = serde_json::from_str(&raw).unwrap();
assert_eq!(value["items"][0]["title"], "Fallback");
}
}