ssg/postprocess/
manifest.rs1use super::helpers::{read_meta_sidecars, truncate_at_word};
7use crate::plugin::{Plugin, PluginContext};
8use anyhow::{Context, Result};
9use std::fs;
10
11#[derive(Debug, Clone, Copy)]
14pub struct ManifestFixPlugin;
15
16impl Plugin for ManifestFixPlugin {
17 fn name(&self) -> &'static str {
18 "manifest-fix"
19 }
20
21 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
22 let manifest_path = ctx.site_dir.join("manifest.json");
23 if !manifest_path.exists() {
24 return Ok(());
25 }
26
27 let content =
28 fs::read_to_string(&manifest_path).with_context(|| {
29 format!("cannot read {}", manifest_path.display())
30 })?;
31
32 let mut manifest: serde_json::Value = serde_json::from_str(&content)
33 .with_context(|| {
34 format!("invalid JSON in {}", manifest_path.display())
35 })?;
36
37 let meta_entries =
38 read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
39
40 let full_description = find_full_description(&meta_entries);
41
42 if let Some(desc) = full_description {
43 let truncated = truncate_at_word(&desc, 200);
44 manifest["description"] = serde_json::Value::String(truncated);
45 } else if let Some(current) =
46 manifest.get("description").and_then(|v| v.as_str())
47 {
48 if let Some(fixed) = fix_truncated_description(current) {
49 manifest["description"] = serde_json::Value::String(fixed);
50 }
51 }
52
53 drop_empty_icons(&mut manifest);
57
58 let output = serde_json::to_string_pretty(&manifest)?;
59 fs::write(&manifest_path, output).with_context(|| {
60 format!("cannot write {}", manifest_path.display())
61 })?;
62
63 log::info!("[manifest-fix] Fixed manifest.json description");
64 Ok(())
65 }
66}
67
68fn find_full_description(
70 meta_entries: &[(String, std::collections::HashMap<String, String>)],
71) -> Option<String> {
72 meta_entries
73 .iter()
74 .find(|(rel, _)| rel.is_empty() || rel == ".")
75 .and_then(|(_, meta)| meta.get("description"))
76 .or_else(|| {
77 meta_entries
78 .iter()
79 .find_map(|(_, meta)| meta.get("description"))
80 })
81 .cloned()
82}
83
84fn drop_empty_icons(manifest: &mut serde_json::Value) {
88 let Some(icons) = manifest.get_mut("icons").and_then(|v| v.as_array_mut())
89 else {
90 return;
91 };
92 icons.retain(|icon| {
93 icon.get("src")
94 .and_then(|s| s.as_str())
95 .is_some_and(|s| !s.is_empty())
96 });
97 if icons.is_empty() {
98 if let Some(map) = manifest.as_object_mut() {
102 let _ = map.remove("icons");
103 }
104 }
105}
106
107fn fix_truncated_description(current: &str) -> Option<String> {
110 if current.ends_with('.')
111 || current.ends_with('!')
112 || current.ends_with('?')
113 || current.ends_with("...")
114 {
115 return None;
116 }
117 Some(if let Some(last_space) = current.rfind(' ') {
118 format!("{}...", ¤t[..last_space])
119 } else {
120 format!("{current}...")
121 })
122}
123
124#[cfg(test)]
125#[allow(clippy::unwrap_used, clippy::expect_used)]
126mod tests {
127 use super::*;
128 use crate::plugin::PluginContext;
129 use std::path::Path;
130 use tempfile::tempdir;
131
132 fn test_ctx(site_dir: &Path) -> PluginContext {
133 crate::test_support::init_logger();
134 PluginContext::new(
135 Path::new("content"),
136 Path::new("build"),
137 site_dir,
138 Path::new("templates"),
139 )
140 }
141
142 #[test]
143 fn test_drop_empty_icons_removes_empty_src() {
144 let mut m: serde_json::Value = serde_json::from_str(
145 r#"{"icons":[{"src":"","sizes":"512x512"},{"src":"/icon.svg","sizes":"512x512"}]}"#,
146 )
147 .unwrap();
148 drop_empty_icons(&mut m);
149 let icons = m["icons"].as_array().unwrap();
150 assert_eq!(icons.len(), 1);
151 assert_eq!(icons[0]["src"], "/icon.svg");
152 }
153
154 #[test]
155 fn test_drop_empty_icons_removes_key_when_all_empty() {
156 let mut m: serde_json::Value =
157 serde_json::from_str(r#"{"name":"x","icons":[{"src":""}]}"#)
158 .unwrap();
159 drop_empty_icons(&mut m);
160 assert!(m.get("icons").is_none(), "icons key should be dropped");
161 }
162
163 #[test]
164 fn name_is_stable() {
165 assert_eq!(ManifestFixPlugin.name(), "manifest-fix");
166 }
167
168 #[test]
169 fn after_compile_no_op_when_manifest_missing() -> Result<()> {
170 let tmp = tempdir()?;
171 let ctx = test_ctx(tmp.path());
172 ManifestFixPlugin.after_compile(&ctx)?;
173 assert!(!tmp.path().join("manifest.json").exists());
174 Ok(())
175 }
176
177 #[test]
178 fn after_compile_returns_error_on_invalid_json() {
179 let tmp = tempdir().unwrap();
180 fs::write(tmp.path().join("manifest.json"), "not valid json").unwrap();
181 let ctx = test_ctx(tmp.path());
182 let err = ManifestFixPlugin.after_compile(&ctx).unwrap_err();
183 assert!(
184 err.to_string().contains("invalid JSON")
185 || err.to_string().contains("manifest"),
186 "expected JSON parse error, got: {err}"
187 );
188 }
189
190 #[test]
191 fn drop_empty_icons_keeps_array_with_real_entries() {
192 let mut m: serde_json::Value = serde_json::from_str(
193 r#"{"icons":[{"src":"/a.svg"},{"src":"/b.svg"}]}"#,
194 )
195 .unwrap();
196 drop_empty_icons(&mut m);
197 let icons = m["icons"].as_array().unwrap();
198 assert_eq!(icons.len(), 2);
199 }
200
201 #[test]
202 fn drop_empty_icons_no_op_when_no_icons_key() {
203 let mut m: serde_json::Value =
204 serde_json::from_str(r#"{"name":"x"}"#).unwrap();
205 drop_empty_icons(&mut m);
206 assert!(m.get("icons").is_none());
207 assert_eq!(m["name"], "x");
208 }
209
210 #[test]
211 fn drop_empty_icons_no_op_when_icons_not_array() {
212 let mut m: serde_json::Value =
214 serde_json::from_str(r#"{"icons":"not an array"}"#).unwrap();
215 drop_empty_icons(&mut m);
216 assert_eq!(m["icons"], "not an array");
217 }
218
219 #[test]
220 fn fix_truncated_description_returns_none_when_already_terminated() {
221 assert!(fix_truncated_description("ends with period.").is_none());
222 assert!(fix_truncated_description("ends with bang!").is_none());
223 assert!(fix_truncated_description("ends with question?").is_none());
224 assert!(fix_truncated_description("ends with ellipsis...").is_none());
225 }
226
227 #[test]
228 fn fix_truncated_description_truncates_at_word_boundary() {
229 let out =
230 fix_truncated_description("a long description without ending");
231 assert_eq!(out.as_deref(), Some("a long description without..."));
232 }
233
234 #[test]
235 fn fix_truncated_description_no_space_appends_ellipsis() {
236 let out = fix_truncated_description("supercalifragilistic");
238 assert_eq!(out.as_deref(), Some("supercalifragilistic..."));
239 }
240
241 #[test]
242 fn after_compile_drops_empty_icons_in_manifest() -> Result<()> {
243 let tmp = tempdir()?;
244 let manifest_path = tmp.path().join("manifest.json");
245 fs::write(
246 &manifest_path,
247 r#"{"name":"X","description":"Already terminated.","icons":[{"src":""}]}"#,
248 )?;
249 let ctx = test_ctx(tmp.path());
250 ManifestFixPlugin.after_compile(&ctx)?;
251 let after: serde_json::Value =
252 serde_json::from_str(&fs::read_to_string(&manifest_path)?)?;
253 assert!(after.get("icons").is_none(), "empty icon should be dropped");
254 Ok(())
255 }
256
257 #[test]
258 fn test_manifest_fix_repairs_truncated_description() -> Result<()> {
259 let tmp = tempdir()?;
260 let manifest_path = tmp.path().join("manifest.json");
261 fs::write(
262 &manifest_path,
263 r#"{"name":"Test","description":"A new paper suggests Shor's algorithm could run on as few as 10,000 qubits. The threshold for cryptographically relevant"}"#,
264 )?;
265
266 let ctx = test_ctx(tmp.path());
267 ManifestFixPlugin.after_compile(&ctx)?;
268
269 let result = fs::read_to_string(&manifest_path)?;
270 let manifest: serde_json::Value = serde_json::from_str(&result)?;
271 let desc = manifest["description"].as_str().unwrap();
272 assert!(
273 desc.ends_with("...") || desc.ends_with('.') || desc.ends_with('!'),
274 "Description should end cleanly, got: {desc}"
275 );
276 Ok(())
277 }
278}