Skip to main content

ssg/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::plugin::{Plugin, PluginContext};
8use anyhow::{Context, Result};
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<()> {
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 icon entries whose `src` is empty; Chrome logs
54        // "Error while trying to use the following icon from the Manifest"
55        // when it tries to fetch them.
56        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
68/// Finds the full description from meta sidecars, preferring the root page.
69fn 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
84/// Removes any entry from the manifest's `icons` array whose `src` is
85/// missing or empty. Chrome logs a manifest icon download error for each
86/// such entry, even though the manifest itself is otherwise valid.
87fn 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        // An empty array is preferable to `[{src:""}]` — but if there are
99        // truly no usable icons, drop the key entirely so the manifest
100        // doesn't advertise an empty icon set.
101        if let Some(map) = manifest.as_object_mut() {
102            let _ = map.remove("icons");
103        }
104    }
105}
106
107/// Fixes a truncated description by ensuring it ends at a word boundary.
108/// Returns `None` if the description already ends with proper punctuation.
109fn 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!("{}...", &current[..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        // Defensive: malformed manifest with non-array icons.
213        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        // Edge case: a single very long word without spaces.
237        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}