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
74        let output = serialize_manifest(&manifest)
75            .map_err(|e| SsgError::io(e, &manifest_path))?;
76        fs::write(&manifest_path, output).with_path(&manifest_path)?;
77
78        log::info!("[manifest-fix] Fixed manifest.json description");
79        Ok(())
80    }
81}
82
83/// Finds the full description from meta sidecars, preferring the root page.
84fn find_full_description(
85    meta_entries: &[(String, std::collections::HashMap<String, String>)],
86) -> Option<String> {
87    meta_entries
88        .iter()
89        .find(|(rel, _)| rel.is_empty() || rel == ".")
90        .and_then(|(_, meta)| meta.get("description"))
91        .or_else(|| {
92            meta_entries
93                .iter()
94                .find_map(|(_, meta)| meta.get("description"))
95        })
96        .cloned()
97}
98
99/// Removes any entry from the manifest's `icons` array whose `src` is
100/// missing or empty. Chrome logs a manifest icon download error for each
101/// such entry, even though the manifest itself is otherwise valid.
102fn drop_empty_icons(manifest: &mut serde_json::Value) {
103    let Some(icons) = manifest.get_mut("icons").and_then(|v| v.as_array_mut())
104    else {
105        return;
106    };
107    icons.retain(|icon| {
108        icon.get("src")
109            .and_then(|s| s.as_str())
110            .is_some_and(|s| !s.is_empty())
111    });
112    if icons.is_empty() {
113        // An empty array is preferable to `[{src:""}]` — but if there are
114        // truly no usable icons, drop the key entirely so the manifest
115        // doesn't advertise an empty icon set. (`get_mut("icons")`
116        // succeeding above guarantees this is an object.)
117        let _ = manifest.as_object_mut().and_then(|map| map.remove("icons"));
118    }
119}
120
121/// Serialize the manifest with a fault-injection hook so tests can
122/// drive the error branch (pretty-printing a `Value` cannot fail in
123/// practice).
124fn serialize_manifest(
125    manifest: &serde_json::Value,
126) -> serde_json::Result<String> {
127    fail_point!("postprocess::manifest-serialize", |_| Err(
128        <serde_json::Error as serde::ser::Error>::custom(
129            "injected: postprocess::manifest-serialize"
130        )
131    ));
132    serde_json::to_string_pretty(manifest)
133}
134
135/// Fixes a truncated description by ensuring it ends at a word boundary.
136/// Returns `None` if the description already ends with proper punctuation.
137fn fix_truncated_description(current: &str) -> Option<String> {
138    if current.ends_with('.')
139        || current.ends_with('!')
140        || current.ends_with('?')
141        || current.ends_with("...")
142    {
143        return None;
144    }
145    Some(if let Some(last_space) = current.rfind(' ') {
146        format!("{}...", &current[..last_space])
147    } else {
148        format!("{current}...")
149    })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::plugin::PluginContext;
156    use anyhow::Result;
157    use std::path::Path;
158    use tempfile::tempdir;
159
160    fn test_ctx(site_dir: &Path) -> PluginContext {
161        crate::test_support::init_logger();
162        PluginContext::new(
163            Path::new("content"),
164            Path::new("build"),
165            site_dir,
166            Path::new("templates"),
167        )
168    }
169
170    #[test]
171    fn test_drop_empty_icons_removes_empty_src() {
172        let mut m: serde_json::Value = serde_json::from_str(
173            r#"{"icons":[{"src":"","sizes":"512x512"},{"src":"/icon.svg","sizes":"512x512"}]}"#,
174        )
175        .unwrap();
176        drop_empty_icons(&mut m);
177        let icons = m["icons"].as_array().unwrap();
178        assert_eq!(icons.len(), 1);
179        assert_eq!(icons[0]["src"], "/icon.svg");
180    }
181
182    #[test]
183    fn test_drop_empty_icons_removes_key_when_all_empty() {
184        let mut m: serde_json::Value =
185            serde_json::from_str(r#"{"name":"x","icons":[{"src":""}]}"#)
186                .unwrap();
187        drop_empty_icons(&mut m);
188        assert!(m.get("icons").is_none(), "icons key should be dropped");
189    }
190
191    #[test]
192    fn name_is_stable() {
193        assert_eq!(ManifestFixPlugin.name(), "manifest-fix");
194    }
195
196    #[test]
197    #[serial_test::parallel]
198    fn after_compile_no_op_when_manifest_missing() -> Result<()> {
199        let tmp = tempdir().unwrap();
200        let ctx = test_ctx(tmp.path());
201        ManifestFixPlugin.after_compile(&ctx).unwrap();
202        assert!(!tmp.path().join("manifest.json").exists());
203        Ok(())
204    }
205
206    #[test]
207    #[serial_test::parallel]
208    fn after_compile_returns_error_on_invalid_json() {
209        let tmp = tempdir().unwrap();
210        fs::write(tmp.path().join("manifest.json"), "not valid json").unwrap();
211        let ctx = test_ctx(tmp.path());
212        let err = ManifestFixPlugin.after_compile(&ctx).unwrap_err();
213        assert!(
214            err.to_string().contains("invalid JSON")
215                || err.to_string().contains("manifest"),
216            "expected JSON parse error, got: {err}"
217        );
218    }
219
220    #[test]
221    fn drop_empty_icons_keeps_array_with_real_entries() {
222        let mut m: serde_json::Value = serde_json::from_str(
223            r#"{"icons":[{"src":"/a.svg"},{"src":"/b.svg"}]}"#,
224        )
225        .unwrap();
226        drop_empty_icons(&mut m);
227        let icons = m["icons"].as_array().unwrap();
228        assert_eq!(icons.len(), 2);
229    }
230
231    #[test]
232    fn drop_empty_icons_no_op_when_no_icons_key() {
233        let mut m: serde_json::Value =
234            serde_json::from_str(r#"{"name":"x"}"#).unwrap();
235        drop_empty_icons(&mut m);
236        assert!(m.get("icons").is_none());
237        assert_eq!(m["name"], "x");
238    }
239
240    #[test]
241    fn drop_empty_icons_no_op_when_icons_not_array() {
242        // Defensive: malformed manifest with non-array icons.
243        let mut m: serde_json::Value =
244            serde_json::from_str(r#"{"icons":"not an array"}"#).unwrap();
245        drop_empty_icons(&mut m);
246        assert_eq!(m["icons"], "not an array");
247    }
248
249    #[test]
250    fn fix_truncated_description_returns_none_when_already_terminated() {
251        assert!(fix_truncated_description("ends with period.").is_none());
252        assert!(fix_truncated_description("ends with bang!").is_none());
253        assert!(fix_truncated_description("ends with question?").is_none());
254        assert!(fix_truncated_description("ends with ellipsis...").is_none());
255    }
256
257    #[test]
258    fn fix_truncated_description_truncates_at_word_boundary() {
259        let out =
260            fix_truncated_description("a long description without ending");
261        assert_eq!(out.as_deref(), Some("a long description without..."));
262    }
263
264    #[test]
265    fn fix_truncated_description_no_space_appends_ellipsis() {
266        // Edge case: a single very long word without spaces.
267        let out = fix_truncated_description("supercalifragilistic");
268        assert_eq!(out.as_deref(), Some("supercalifragilistic..."));
269    }
270
271    #[test]
272    #[serial_test::parallel]
273    fn after_compile_drops_empty_icons_in_manifest() -> Result<()> {
274        let tmp = tempdir().unwrap();
275        let manifest_path = tmp.path().join("manifest.json");
276        fs::write(
277            &manifest_path,
278            r#"{"name":"X","description":"Already terminated.","icons":[{"src":""}]}"#,
279        ).unwrap();
280        let ctx = test_ctx(tmp.path());
281        ManifestFixPlugin.after_compile(&ctx).unwrap();
282        let after: serde_json::Value =
283            serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap())
284                .unwrap();
285        assert!(after.get("icons").is_none(), "empty icon should be dropped");
286        Ok(())
287    }
288
289    #[test]
290    #[serial_test::parallel]
291    fn test_manifest_fix_repairs_truncated_description() -> Result<()> {
292        let tmp = tempdir().unwrap();
293        let manifest_path = tmp.path().join("manifest.json");
294        fs::write(
295            &manifest_path,
296            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"}"#,
297        ).unwrap();
298
299        let ctx = test_ctx(tmp.path());
300        ManifestFixPlugin.after_compile(&ctx).unwrap();
301
302        let result = fs::read_to_string(&manifest_path).unwrap();
303        let manifest: serde_json::Value =
304            serde_json::from_str(&result).unwrap();
305        let desc = manifest["description"].as_str().unwrap();
306        // Non-short-circuiting `|` so every operand is evaluated.
307        let clean =
308            desc.ends_with("...") | desc.ends_with('.') | desc.ends_with('!');
309        assert!(clean, "Description should end cleanly, got: {desc}");
310        Ok(())
311    }
312
313    #[test]
314    #[serial_test::parallel]
315    fn test_manifest_fix_uses_sidecar_description() -> Result<()> {
316        let tmp = tempdir().unwrap();
317        let manifest_path = tmp.path().join("manifest.json");
318        fs::write(
319            &manifest_path,
320            r#"{"name":"Test","description":"Short description"}"#,
321        )
322        .unwrap();
323        fs::write(
324            tmp.path().join("index.meta.json"),
325            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."}"#,
326        ).unwrap();
327
328        let ctx = test_ctx(tmp.path());
329        ManifestFixPlugin.after_compile(&ctx).unwrap();
330
331        let result = fs::read_to_string(&manifest_path).unwrap();
332        let manifest: serde_json::Value =
333            serde_json::from_str(&result).unwrap();
334        let desc = manifest["description"].as_str().unwrap();
335        assert!(desc.starts_with("This is a very long"));
336        assert!(desc.ends_with("..."));
337        Ok(())
338    }
339
340    #[test]
341    fn test_find_full_description_fallback() {
342        let mut entries = Vec::new();
343        let mut meta1 = std::collections::HashMap::new();
344        let _ = meta1.insert("title".to_string(), "No description".to_string());
345        entries.push(("root".to_string(), meta1));
346
347        let mut meta2 = std::collections::HashMap::new();
348        let _ = meta2
349            .insert("description".to_string(), "Fallback desc".to_string());
350        entries.push(("subpage".to_string(), meta2));
351
352        let desc = find_full_description(&entries);
353        assert_eq!(desc.as_deref(), Some("Fallback desc"));
354    }
355
356    #[test]
357    fn test_find_full_description_none() {
358        let mut entries = Vec::new();
359        let mut meta1 = std::collections::HashMap::new();
360        let _ = meta1.insert("title".to_string(), "No description".to_string());
361        entries.push(("root".to_string(), meta1));
362
363        let desc = find_full_description(&entries);
364        assert!(desc.is_none());
365    }
366
367    // -----------------------------------------------------------------
368    // after_compile: manifest without a description key
369    // -----------------------------------------------------------------
370
371    #[test]
372    #[serial_test::parallel]
373    fn after_compile_handles_manifest_without_description() {
374        let tmp = tempdir().unwrap();
375        let manifest_path = tmp.path().join("manifest.json");
376        fs::write(&manifest_path, r#"{"name":"X"}"#).unwrap();
377        let ctx = test_ctx(tmp.path());
378        ManifestFixPlugin.after_compile(&ctx).unwrap();
379        let after: serde_json::Value =
380            serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap())
381                .unwrap();
382        assert!(
383            after.get("description").is_none(),
384            "no description key must be invented"
385        );
386    }
387
388    // -----------------------------------------------------------------
389    // Error paths
390    // -----------------------------------------------------------------
391
392    #[test]
393    #[serial_test::parallel]
394    fn after_compile_errors_on_invalid_utf8_manifest() {
395        let tmp = tempdir().unwrap();
396        let manifest_path = tmp.path().join("manifest.json");
397        fs::write(&manifest_path, [0xFF, 0xFE, 0xFD]).unwrap();
398        let ctx = test_ctx(tmp.path());
399        let err = ManifestFixPlugin.after_compile(&ctx).unwrap_err();
400        assert!(format!("{err}").contains("manifest.json"));
401    }
402
403    #[test]
404    #[cfg(unix)]
405    fn after_compile_write_failure_on_readonly_manifest() {
406        use std::os::unix::fs::PermissionsExt;
407        let tmp = tempdir().unwrap();
408        let manifest_path = tmp.path().join("manifest.json");
409        fs::write(&manifest_path, r#"{"name":"X","description":"Done."}"#)
410            .unwrap();
411        fs::set_permissions(&manifest_path, fs::Permissions::from_mode(0o444))
412            .unwrap();
413
414        let ctx = test_ctx(tmp.path());
415        let result = ManifestFixPlugin.after_compile(&ctx);
416        let _ = fs::set_permissions(
417            &manifest_path,
418            fs::Permissions::from_mode(0o644),
419        );
420        let err = result.unwrap_err();
421        assert!(format!("{err}").contains("manifest.json"));
422    }
423}
424
425#[cfg(all(test, feature = "test-fault-injection"))]
426mod fault_tests {
427    use super::*;
428    use crate::plugin::PluginContext;
429    use serial_test::serial;
430    use std::path::Path;
431    use tempfile::tempdir;
432
433    /// RAII guard that disables a failpoint on drop.
434    struct FailGuard(&'static str);
435
436    impl Drop for FailGuard {
437        fn drop(&mut self) {
438            let _ = fail::cfg(self.0, "off");
439        }
440    }
441
442    #[test]
443    #[serial]
444    fn after_compile_maps_serialize_failure_to_io_error() {
445        let _guard = FailGuard("postprocess::manifest-serialize");
446        fail::cfg("postprocess::manifest-serialize", "return")
447            .expect("activate failpoint");
448
449        let tmp = tempdir().unwrap();
450        fs::write(
451            tmp.path().join("manifest.json"),
452            r#"{"name":"X","description":"Already terminated."}"#,
453        )
454        .unwrap();
455        crate::test_support::init_logger();
456        let ctx = PluginContext::new(
457            Path::new("content"),
458            Path::new("build"),
459            tmp.path(),
460            Path::new("templates"),
461        );
462        let err = ManifestFixPlugin
463            .after_compile(&ctx)
464            .expect_err("injected serialize failure must propagate");
465        let msg = format!("{err}");
466        assert!(msg.contains("manifest.json"), "got: {msg}");
467        assert!(
468            msg.contains("injected: postprocess::manifest-serialize"),
469            "got: {msg}"
470        );
471    }
472}