Skip to main content

ssg/plugins/postprocess/
manifest.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Manifest fix plugin.
5
6use super::helpers::{read_meta_sidecars, truncate_at_word};
7use crate::error::{PathErrorExt, SsgError};
8use crate::plugin::{Plugin, PluginContext};
9use std::fs;
10
11/// Fixes manifest.json description truncation by using full text or
12/// word-boundary-safe truncation at 200 characters.
13#[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 icon entries whose `src` is empty; Chrome logs
70        // "Error while trying to use the following icon from the Manifest"
71        // when it tries to fetch them.
72        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
84/// Finds the full description from meta sidecars, preferring the root page.
85fn 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
100/// Removes any entry from the manifest's `icons` array whose `src` is
101/// missing or empty. Chrome logs a manifest icon download error for each
102/// such entry, even though the manifest itself is otherwise valid.
103fn 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        // An empty array is preferable to `[{src:""}]` — but if there are
115        // truly no usable icons, drop the key entirely so the manifest
116        // doesn't advertise an empty icon set. (`get_mut("icons")`
117        // succeeding above guarantees this is an object.)
118        let _ = manifest.as_object_mut().and_then(|map| map.remove("icons"));
119    }
120}
121
122/// Sets each icon's `type` from its file extension.
123///
124/// staticdatagen's generator declares every icon as `image/svg+xml`, so an
125/// `icon` front-matter value naming a PNG produced a manifest that told the
126/// browser the PNG was an SVG. The extension of `src` (query and fragment
127/// stripped, case ignored) decides the type when it names an image format;
128/// otherwise the declared type is the only information and is kept.
129fn 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
148/// The image MIME type a URL's file extension names, if any.
149fn 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
165/// Serialize the manifest with a fault-injection hook so tests can
166/// drive the error branch (pretty-printing a `Value` cannot fail in
167/// practice).
168fn 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
179/// Fixes a truncated description by ensuring it ends at a word boundary.
180/// Returns `None` if the description already ends with proper punctuation.
181fn 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!("{}...", &current[..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                // No extension, or one that is not an image format: the
265                // declared type is the only information, so it stays.
266                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        // staticdatagen's manifest generator declares every icon as
284        // image/svg+xml, whatever the `icon` front matter points at.
285        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        // Defensive: malformed manifest with non-array icons.
354        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        // Edge case: a single very long word without spaces.
378        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        // Non-short-circuiting `|` so every operand is evaluated.
418        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    // -----------------------------------------------------------------
479    // after_compile: manifest without a description key
480    // -----------------------------------------------------------------
481
482    #[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    // -----------------------------------------------------------------
500    // Error paths
501    // -----------------------------------------------------------------
502
503    #[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    /// RAII guard that disables a failpoint on drop.
545    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}