1use super::helpers::{read_meta_sidecars, truncate_at_word};
7use crate::error::{PathErrorExt, SsgError};
8use crate::plugin::{Plugin, PluginContext};
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<(), SsgError> {
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_path(&manifest_path)?;
29
30 if content.trim().is_empty() {
31 let meta_entries =
32 read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
33 let mut manifest = serde_json::json!({
34 "name": "Static Site",
35 "short_name": "Site",
36 "start_url": "/",
37 "display": "standalone"
38 });
39 if let Some(desc) = find_full_description(&meta_entries) {
40 let truncated = truncate_at_word(&desc, 200);
41 manifest["description"] = serde_json::Value::String(truncated);
42 }
43 let output = serialize_manifest(&manifest)
44 .map_err(|e| SsgError::io(e, &manifest_path))?;
45 fs::write(&manifest_path, output).with_path(&manifest_path)?;
46 log::info!("[manifest-fix] Generated manifest.json from metadata");
47 return Ok(());
48 }
49
50 let mut manifest: serde_json::Value = serde_json::from_str(&content)
51 .map_err(|e| SsgError::io(e, &manifest_path))?;
52
53 let meta_entries =
54 read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
55
56 let full_description = find_full_description(&meta_entries);
57
58 if let Some(desc) = full_description {
59 let truncated = truncate_at_word(&desc, 200);
60 manifest["description"] = serde_json::Value::String(truncated);
61 } else if let Some(current) =
62 manifest.get("description").and_then(|v| v.as_str())
63 {
64 if let Some(fixed) = fix_truncated_description(current) {
65 manifest["description"] = serde_json::Value::String(fixed);
66 }
67 }
68
69 drop_empty_icons(&mut manifest);
73 set_icon_types(&mut manifest);
74
75 let output = serialize_manifest(&manifest)
76 .map_err(|e| SsgError::io(e, &manifest_path))?;
77 fs::write(&manifest_path, output).with_path(&manifest_path)?;
78
79 log::info!("[manifest-fix] Fixed manifest.json description");
80 Ok(())
81 }
82}
83
84fn find_full_description(
86 meta_entries: &[(String, std::collections::HashMap<String, String>)],
87) -> Option<String> {
88 meta_entries
89 .iter()
90 .find(|(rel, _)| rel.is_empty() || rel == ".")
91 .and_then(|(_, meta)| meta.get("description"))
92 .or_else(|| {
93 meta_entries
94 .iter()
95 .find_map(|(_, meta)| meta.get("description"))
96 })
97 .cloned()
98}
99
100fn drop_empty_icons(manifest: &mut serde_json::Value) {
104 let Some(icons) = manifest.get_mut("icons").and_then(|v| v.as_array_mut())
105 else {
106 return;
107 };
108 icons.retain(|icon| {
109 icon.get("src")
110 .and_then(|s| s.as_str())
111 .is_some_and(|s| !s.is_empty())
112 });
113 if icons.is_empty() {
114 let _ = manifest.as_object_mut().and_then(|map| map.remove("icons"));
119 }
120}
121
122fn set_icon_types(manifest: &mut serde_json::Value) {
130 let Some(icons) = manifest.get_mut("icons").and_then(|v| v.as_array_mut())
131 else {
132 return;
133 };
134 for icon in icons {
135 let Some(mime) = icon
136 .get("src")
137 .and_then(|s| s.as_str())
138 .and_then(image_type_for)
139 else {
140 continue;
141 };
142 if let Some(entry) = icon.as_object_mut() {
143 let _ = entry.insert("type".into(), mime.into());
144 }
145 }
146}
147
148fn image_type_for(src: &str) -> Option<&'static str> {
150 let path = src.split(['?', '#']).next().unwrap_or(src);
151 let file = path.rsplit('/').next().unwrap_or(path);
152 let (_, ext) = file.rsplit_once('.')?;
153 match ext.to_ascii_lowercase().as_str() {
154 "png" => Some("image/png"),
155 "svg" => Some("image/svg+xml"),
156 "webp" => Some("image/webp"),
157 "avif" => Some("image/avif"),
158 "jpg" | "jpeg" => Some("image/jpeg"),
159 "gif" => Some("image/gif"),
160 "ico" => Some("image/x-icon"),
161 _ => None,
162 }
163}
164
165fn serialize_manifest(
169 manifest: &serde_json::Value,
170) -> serde_json::Result<String> {
171 fail_point!("postprocess::manifest-serialize", |_| Err(
172 <serde_json::Error as serde::ser::Error>::custom(
173 "injected: postprocess::manifest-serialize"
174 )
175 ));
176 serde_json::to_string_pretty(manifest)
177}
178
179fn fix_truncated_description(current: &str) -> Option<String> {
182 if current.ends_with('.')
183 || current.ends_with('!')
184 || current.ends_with('?')
185 || current.ends_with("...")
186 {
187 return None;
188 }
189 Some(if let Some(last_space) = current.rfind(' ') {
190 format!("{}...", ¤t[..last_space])
191 } else {
192 format!("{current}...")
193 })
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::plugin::PluginContext;
200 use anyhow::Result;
201 use std::path::Path;
202 use tempfile::tempdir;
203
204 fn test_ctx(site_dir: &Path) -> PluginContext {
205 crate::test_support::init_logger();
206 PluginContext::new(
207 Path::new("content"),
208 Path::new("build"),
209 site_dir,
210 Path::new("templates"),
211 )
212 }
213
214 #[test]
215 fn test_drop_empty_icons_removes_empty_src() {
216 let mut m: serde_json::Value = serde_json::from_str(
217 r#"{"icons":[{"src":"","sizes":"512x512"},{"src":"/icon.svg","sizes":"512x512"}]}"#,
218 )
219 .unwrap();
220 drop_empty_icons(&mut m);
221 let icons = m["icons"].as_array().unwrap();
222 assert_eq!(icons.len(), 1);
223 assert_eq!(icons[0]["src"], "/icon.svg");
224 }
225
226 #[test]
227 fn test_drop_empty_icons_removes_key_when_all_empty() {
228 let mut m: serde_json::Value =
229 serde_json::from_str(r#"{"name":"x","icons":[{"src":""}]}"#)
230 .unwrap();
231 drop_empty_icons(&mut m);
232 assert!(m.get("icons").is_none(), "icons key should be dropped");
233 }
234
235 #[test]
236 fn test_set_icon_types_follows_the_file_extension() {
237 let mut m: serde_json::Value = serde_json::from_str(
238 r#"{"icons":[
239 {"src":"https://cdn.example/icons/512x512.png","type":"image/svg+xml"},
240 {"src":"/icon.svg","type":"image/svg+xml"},
241 {"src":"/icon.WEBP?v=2#x"},
242 {"src":"/favicon.ico","type":"image/x-icon"},
243 {"src":"/photo.jpeg","type":"image/png"},
244 {"src":"/icon","type":"image/svg+xml"},
245 {"src":"/icon.bin","type":"application/x-custom"}
246 ]}"#,
247 )
248 .unwrap();
249 set_icon_types(&mut m);
250 let types: Vec<Option<&str>> = m["icons"]
251 .as_array()
252 .unwrap()
253 .iter()
254 .map(|i| i.get("type").and_then(|t| t.as_str()))
255 .collect();
256 assert_eq!(
257 types,
258 [
259 Some("image/png"),
260 Some("image/svg+xml"),
261 Some("image/webp"),
262 Some("image/x-icon"),
263 Some("image/jpeg"),
264 Some("image/svg+xml"),
267 Some("application/x-custom"),
268 ]
269 );
270 }
271
272 #[test]
273 fn test_set_icon_types_without_icons_is_a_no_op() {
274 let mut m: serde_json::Value =
275 serde_json::from_str(r#"{"name":"x"}"#).unwrap();
276 set_icon_types(&mut m);
277 assert_eq!(m, serde_json::json!({"name":"x"}));
278 }
279
280 #[test]
281 #[serial_test::parallel]
282 fn test_manifest_fix_corrects_a_png_icon_declared_as_svg() -> Result<()> {
283 let tmp = tempdir().unwrap();
286 let manifest_path = tmp.path().join("manifest.json");
287 fs::write(
288 &manifest_path,
289 r#"{"name":"Test","icons":[{"purpose":"any maskable","sizes":"512x512","src":"https://cdn.example/icons/512x512.png","type":"image/svg+xml"}]}"#,
290 )
291 .unwrap();
292 let ctx = test_ctx(tmp.path());
293 ManifestFixPlugin.after_compile(&ctx).unwrap();
294 let manifest: serde_json::Value =
295 serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap())
296 .unwrap();
297 assert_eq!(manifest["icons"][0]["type"], "image/png");
298 assert_eq!(manifest["icons"][0]["sizes"], "512x512");
299 Ok(())
300 }
301
302 #[test]
303 fn name_is_stable() {
304 assert_eq!(ManifestFixPlugin.name(), "manifest-fix");
305 }
306
307 #[test]
308 #[serial_test::parallel]
309 fn after_compile_no_op_when_manifest_missing() -> Result<()> {
310 let tmp = tempdir().unwrap();
311 let ctx = test_ctx(tmp.path());
312 ManifestFixPlugin.after_compile(&ctx).unwrap();
313 assert!(!tmp.path().join("manifest.json").exists());
314 Ok(())
315 }
316
317 #[test]
318 #[serial_test::parallel]
319 fn after_compile_returns_error_on_invalid_json() {
320 let tmp = tempdir().unwrap();
321 fs::write(tmp.path().join("manifest.json"), "not valid json").unwrap();
322 let ctx = test_ctx(tmp.path());
323 let err = ManifestFixPlugin.after_compile(&ctx).unwrap_err();
324 assert!(
325 err.to_string().contains("invalid JSON")
326 || err.to_string().contains("manifest"),
327 "expected JSON parse error, got: {err}"
328 );
329 }
330
331 #[test]
332 fn drop_empty_icons_keeps_array_with_real_entries() {
333 let mut m: serde_json::Value = serde_json::from_str(
334 r#"{"icons":[{"src":"/a.svg"},{"src":"/b.svg"}]}"#,
335 )
336 .unwrap();
337 drop_empty_icons(&mut m);
338 let icons = m["icons"].as_array().unwrap();
339 assert_eq!(icons.len(), 2);
340 }
341
342 #[test]
343 fn drop_empty_icons_no_op_when_no_icons_key() {
344 let mut m: serde_json::Value =
345 serde_json::from_str(r#"{"name":"x"}"#).unwrap();
346 drop_empty_icons(&mut m);
347 assert!(m.get("icons").is_none());
348 assert_eq!(m["name"], "x");
349 }
350
351 #[test]
352 fn drop_empty_icons_no_op_when_icons_not_array() {
353 let mut m: serde_json::Value =
355 serde_json::from_str(r#"{"icons":"not an array"}"#).unwrap();
356 drop_empty_icons(&mut m);
357 assert_eq!(m["icons"], "not an array");
358 }
359
360 #[test]
361 fn fix_truncated_description_returns_none_when_already_terminated() {
362 assert!(fix_truncated_description("ends with period.").is_none());
363 assert!(fix_truncated_description("ends with bang!").is_none());
364 assert!(fix_truncated_description("ends with question?").is_none());
365 assert!(fix_truncated_description("ends with ellipsis...").is_none());
366 }
367
368 #[test]
369 fn fix_truncated_description_truncates_at_word_boundary() {
370 let out =
371 fix_truncated_description("a long description without ending");
372 assert_eq!(out.as_deref(), Some("a long description without..."));
373 }
374
375 #[test]
376 fn fix_truncated_description_no_space_appends_ellipsis() {
377 let out = fix_truncated_description("supercalifragilistic");
379 assert_eq!(out.as_deref(), Some("supercalifragilistic..."));
380 }
381
382 #[test]
383 #[serial_test::parallel]
384 fn after_compile_drops_empty_icons_in_manifest() -> Result<()> {
385 let tmp = tempdir().unwrap();
386 let manifest_path = tmp.path().join("manifest.json");
387 fs::write(
388 &manifest_path,
389 r#"{"name":"X","description":"Already terminated.","icons":[{"src":""}]}"#,
390 ).unwrap();
391 let ctx = test_ctx(tmp.path());
392 ManifestFixPlugin.after_compile(&ctx).unwrap();
393 let after: serde_json::Value =
394 serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap())
395 .unwrap();
396 assert!(after.get("icons").is_none(), "empty icon should be dropped");
397 Ok(())
398 }
399
400 #[test]
401 #[serial_test::parallel]
402 fn test_manifest_fix_repairs_truncated_description() -> Result<()> {
403 let tmp = tempdir().unwrap();
404 let manifest_path = tmp.path().join("manifest.json");
405 fs::write(
406 &manifest_path,
407 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"}"#,
408 ).unwrap();
409
410 let ctx = test_ctx(tmp.path());
411 ManifestFixPlugin.after_compile(&ctx).unwrap();
412
413 let result = fs::read_to_string(&manifest_path).unwrap();
414 let manifest: serde_json::Value =
415 serde_json::from_str(&result).unwrap();
416 let desc = manifest["description"].as_str().unwrap();
417 let clean =
419 desc.ends_with("...") | desc.ends_with('.') | desc.ends_with('!');
420 assert!(clean, "Description should end cleanly, got: {desc}");
421 Ok(())
422 }
423
424 #[test]
425 #[serial_test::parallel]
426 fn test_manifest_fix_uses_sidecar_description() -> Result<()> {
427 let tmp = tempdir().unwrap();
428 let manifest_path = tmp.path().join("manifest.json");
429 fs::write(
430 &manifest_path,
431 r#"{"name":"Test","description":"Short description"}"#,
432 )
433 .unwrap();
434 fs::write(
435 tmp.path().join("index.meta.json"),
436 r#"{"description":"This is a very long description that we are using to test manifest metadata sidecar description truncation logic in the manifest fix plugin. We need to make sure that the total length of this text exceeds two hundred characters so that the truncation is triggered."}"#,
437 ).unwrap();
438
439 let ctx = test_ctx(tmp.path());
440 ManifestFixPlugin.after_compile(&ctx).unwrap();
441
442 let result = fs::read_to_string(&manifest_path).unwrap();
443 let manifest: serde_json::Value =
444 serde_json::from_str(&result).unwrap();
445 let desc = manifest["description"].as_str().unwrap();
446 assert!(desc.starts_with("This is a very long"));
447 assert!(desc.ends_with("..."));
448 Ok(())
449 }
450
451 #[test]
452 fn test_find_full_description_fallback() {
453 let mut entries = Vec::new();
454 let mut meta1 = std::collections::HashMap::new();
455 let _ = meta1.insert("title".to_string(), "No description".to_string());
456 entries.push(("root".to_string(), meta1));
457
458 let mut meta2 = std::collections::HashMap::new();
459 let _ = meta2
460 .insert("description".to_string(), "Fallback desc".to_string());
461 entries.push(("subpage".to_string(), meta2));
462
463 let desc = find_full_description(&entries);
464 assert_eq!(desc.as_deref(), Some("Fallback desc"));
465 }
466
467 #[test]
468 fn test_find_full_description_none() {
469 let mut entries = Vec::new();
470 let mut meta1 = std::collections::HashMap::new();
471 let _ = meta1.insert("title".to_string(), "No description".to_string());
472 entries.push(("root".to_string(), meta1));
473
474 let desc = find_full_description(&entries);
475 assert!(desc.is_none());
476 }
477
478 #[test]
483 #[serial_test::parallel]
484 fn after_compile_handles_manifest_without_description() {
485 let tmp = tempdir().unwrap();
486 let manifest_path = tmp.path().join("manifest.json");
487 fs::write(&manifest_path, r#"{"name":"X"}"#).unwrap();
488 let ctx = test_ctx(tmp.path());
489 ManifestFixPlugin.after_compile(&ctx).unwrap();
490 let after: serde_json::Value =
491 serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap())
492 .unwrap();
493 assert!(
494 after.get("description").is_none(),
495 "no description key must be invented"
496 );
497 }
498
499 #[test]
504 #[serial_test::parallel]
505 fn after_compile_errors_on_invalid_utf8_manifest() {
506 let tmp = tempdir().unwrap();
507 let manifest_path = tmp.path().join("manifest.json");
508 fs::write(&manifest_path, [0xFF, 0xFE, 0xFD]).unwrap();
509 let ctx = test_ctx(tmp.path());
510 let err = ManifestFixPlugin.after_compile(&ctx).unwrap_err();
511 assert!(format!("{err}").contains("manifest.json"));
512 }
513
514 #[test]
515 #[cfg(unix)]
516 fn after_compile_write_failure_on_readonly_manifest() {
517 use std::os::unix::fs::PermissionsExt;
518 let tmp = tempdir().unwrap();
519 let manifest_path = tmp.path().join("manifest.json");
520 fs::write(&manifest_path, r#"{"name":"X","description":"Done."}"#)
521 .unwrap();
522 fs::set_permissions(&manifest_path, fs::Permissions::from_mode(0o444))
523 .unwrap();
524
525 let ctx = test_ctx(tmp.path());
526 let result = ManifestFixPlugin.after_compile(&ctx);
527 let _ = fs::set_permissions(
528 &manifest_path,
529 fs::Permissions::from_mode(0o644),
530 );
531 let err = result.unwrap_err();
532 assert!(format!("{err}").contains("manifest.json"));
533 }
534}
535
536#[cfg(all(test, feature = "test-fault-injection"))]
537mod fault_tests {
538 use super::*;
539 use crate::plugin::PluginContext;
540 use serial_test::serial;
541 use std::path::Path;
542 use tempfile::tempdir;
543
544 struct FailGuard(&'static str);
546
547 impl Drop for FailGuard {
548 fn drop(&mut self) {
549 let _ = fail::cfg(self.0, "off");
550 }
551 }
552
553 #[test]
554 #[serial]
555 fn after_compile_maps_serialize_failure_to_io_error() {
556 let _guard = FailGuard("postprocess::manifest-serialize");
557 fail::cfg("postprocess::manifest-serialize", "return")
558 .expect("activate failpoint");
559
560 let tmp = tempdir().unwrap();
561 fs::write(
562 tmp.path().join("manifest.json"),
563 r#"{"name":"X","description":"Already terminated."}"#,
564 )
565 .unwrap();
566 crate::test_support::init_logger();
567 let ctx = PluginContext::new(
568 Path::new("content"),
569 Path::new("build"),
570 tmp.path(),
571 Path::new("templates"),
572 );
573 let err = ManifestFixPlugin
574 .after_compile(&ctx)
575 .expect_err("injected serialize failure must propagate");
576 let msg = format!("{err}");
577 assert!(msg.contains("manifest.json"), "got: {msg}");
578 assert!(
579 msg.contains("injected: postprocess::manifest-serialize"),
580 "got: {msg}"
581 );
582 }
583}