Skip to main content

autumn_plugin_gsap/
assets.rs

1//! Vendored GSAP files, embedded at compile time.
2//!
3//! [`GSAP_ASSETS`] holds the upstream GSAP builds and the plugin files.
4//! [`GsapPlugin`](crate::GsapPlugin) installs it with `AppBuilder::plugin_assets`.
5//! The framework serves each file under `/static/_plugins/gsap/`.
6//! Each file has a hashed URL (immutable) and a plain URL (`must-revalidate`).
7//! Both URLs send `ETag`/`304`, `Range` and a computed `sha384` SRI hash.
8//!
9//! The bundle lists its files one by one. Thus the framework does not serve `manifest.json`.
10
11use autumn_web::assets::PluginAssets;
12
13/// URL namespace: files serve under `/static/_plugins/gsap/`.
14pub const ASSETS_NAMESPACE: &str = "gsap";
15
16/// GSAP core (upstream, sets `window.gsap`).
17pub(crate) const GSAP_JS: &str = "gsap.min.js";
18/// ScrollTrigger plugin (upstream).
19pub(crate) const SCROLL_TRIGGER_JS: &str = "ScrollTrigger.min.js";
20/// SplitText plugin (upstream).
21pub(crate) const SPLIT_TEXT_JS: &str = "SplitText.min.js";
22/// The declarative scanner (plugin file).
23pub(crate) const INIT_JS: &str = "init.js";
24/// Default styles (plugin file).
25pub(crate) const GSAP_CSS: &str = "gsap.css";
26
27/// The asset bundle: GSAP, ScrollTrigger, SplitText, `init.js` and `gsap.css`.
28///
29/// ```rust
30/// use autumn_plugin_gsap::GSAP_ASSETS;
31///
32/// let url = GSAP_ASSETS.url("init.js");
33/// assert!(url.starts_with("/static/_plugins/gsap/init."), "{url}");
34/// assert!(GSAP_ASSETS.integrity("gsap.min.js").is_some());
35/// ```
36pub static GSAP_ASSETS: PluginAssets = PluginAssets::from_files(
37    ASSETS_NAMESPACE,
38    &[
39        (GSAP_JS, include_bytes!("../assets/gsap.min.js")),
40        (
41            SCROLL_TRIGGER_JS,
42            include_bytes!("../assets/ScrollTrigger.min.js"),
43        ),
44        (SPLIT_TEXT_JS, include_bytes!("../assets/SplitText.min.js")),
45        (INIT_JS, include_bytes!("../assets/init.js")),
46        (GSAP_CSS, include_bytes!("../assets/gsap.css")),
47    ],
48);
49
50/// The vendored GSAP version.
51pub const GSAP_VERSION: &str = "3.15.0";
52
53/// The GSAP license of the vendored files. The plugin code is Apache-2.0.
54pub const GSAP_LICENSE: &str = "https://gsap.com/standard-license";
55
56/// One vendored upstream file.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58#[non_exhaustive]
59pub struct UpstreamFile {
60    /// The logical path in [`GSAP_ASSETS`].
61    pub path: &'static str,
62    /// The source URL.
63    pub source: &'static str,
64    /// The pinned `sha384` SRI hash of the upstream bytes.
65    pub integrity: &'static str,
66}
67
68/// The vendored upstream files, with their pinned hashes.
69///
70/// The `<script>` tags do not read these values. A test checks that the
71/// embedded bytes still match them.
72pub const GSAP_UPSTREAM: &[UpstreamFile] = &[
73    UpstreamFile {
74        path: GSAP_JS,
75        source: "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js",
76        integrity: "sha384-XmJ9SoHtVOHoQUcKvFAzVXwdkKo1Ie3bhmSoIAkcdsHGaIrVJIkmozyq0FJeb/Ly",
77    },
78    UpstreamFile {
79        path: SCROLL_TRIGGER_JS,
80        source: "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js",
81        integrity: "sha384-wl5TeDVvOWt30Pbf8aSo2ZrzsOjddu3avOBvHe+p+OhJt9gP6w9YXmDkN5DK2/dF",
82    },
83    UpstreamFile {
84        path: SPLIT_TEXT_JS,
85        source: "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js",
86        integrity: "sha384-SWJ0lLVRoipvHh59xj0pL7uC7Ih51F+5smaFtrG+2nr+TlDZU5SYJHmxfolbeNTr",
87    },
88];
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use base64::Engine as _;
94    use sha2::{Digest as _, Sha384};
95
96    fn sri(bytes: &[u8]) -> String {
97        let digest = Sha384::digest(bytes);
98        format!(
99            "sha384-{}",
100            base64::engine::general_purpose::STANDARD.encode(digest)
101        )
102    }
103
104    #[test]
105    fn bundle_holds_exactly_the_served_files() {
106        let files: Vec<&str> = GSAP_ASSETS
107            .iter()
108            .map(autumn_web::assets::PluginAsset::logical_path)
109            .collect();
110        assert_eq!(
111            files,
112            [SCROLL_TRIGGER_JS, SPLIT_TEXT_JS, GSAP_CSS, GSAP_JS, INIT_JS],
113            "sorted by path; manifest.json is absent"
114        );
115        assert_eq!(GSAP_ASSETS.namespace(), ASSETS_NAMESPACE);
116        assert_eq!(GSAP_ASSETS.mount_path(), "/static/_plugins/gsap");
117    }
118
119    #[test]
120    fn plugin_files_are_not_empty() {
121        for path in [INIT_JS, GSAP_CSS] {
122            let asset = GSAP_ASSETS.get(path).expect("bundled");
123            assert!(asset.bytes().len() > 100, "{path} has content");
124        }
125    }
126
127    #[test]
128    fn bundle_integrity_matches_embedded_bytes() {
129        for asset in GSAP_ASSETS.iter() {
130            assert_eq!(
131                asset.integrity(),
132                sri(asset.bytes()),
133                "{}",
134                asset.logical_path()
135            );
136        }
137    }
138
139    #[test]
140    fn vendored_files_match_the_pinned_upstream_hashes() {
141        for f in GSAP_UPSTREAM {
142            let (path, source) = (f.path, f.source);
143            let asset = GSAP_ASSETS.get(path).expect("bundled");
144            assert_eq!(sri(asset.bytes()), f.integrity, "{path}");
145            assert!(
146                source.contains(&format!("gsap@{GSAP_VERSION}/dist/{path}")),
147                "{source}"
148            );
149        }
150    }
151
152    #[test]
153    fn vendored_files_keep_the_upstream_license_header() {
154        for path in GSAP_UPSTREAM.iter().map(|f| f.path) {
155            let bytes = GSAP_ASSETS.get(path).expect("bundled").bytes();
156            let head = String::from_utf8_lossy(&bytes[..300]);
157            assert!(head.contains(GSAP_VERSION), "{path}: {head}");
158            assert!(head.contains(GSAP_LICENSE), "{path}: {head}");
159        }
160    }
161
162    #[test]
163    fn urls_are_fingerprinted_under_the_plugin_mount() {
164        for asset in GSAP_ASSETS.iter() {
165            let path = asset.logical_path();
166            assert_eq!(asset.plain_url(), format!("/static/_plugins/gsap/{path}"));
167            let (stem, ext) = path.rsplit_once('.').expect("extension");
168            let url = asset.url();
169            let hash = url
170                .strip_prefix(&format!("/static/_plugins/gsap/{stem}."))
171                .and_then(|rest| rest.strip_suffix(&format!(".{ext}")))
172                .unwrap_or_else(|| panic!("{url} is the hashed form of {path}"));
173            assert_eq!(hash.len(), 8, "{url}");
174            assert!(hash.bytes().all(|b| b.is_ascii_hexdigit()), "{url}");
175        }
176    }
177
178    #[test]
179    fn content_types_match_the_files() {
180        for asset in GSAP_ASSETS.iter() {
181            let want = if std::path::Path::new(asset.logical_path())
182                .extension()
183                .is_some_and(|e| e == "css")
184            {
185                "text/css; charset=utf-8"
186            } else {
187                "text/javascript; charset=utf-8"
188            };
189            assert_eq!(asset.content_type(), want, "{}", asset.logical_path());
190        }
191    }
192
193    #[test]
194    fn manifest_agrees_with_constants() {
195        let manifest: serde_json::Value =
196            serde_json::from_str(include_str!("../assets/manifest.json")).expect("json");
197        assert_eq!(manifest["version"], GSAP_VERSION);
198        assert_eq!(manifest["license"], GSAP_LICENSE);
199        for f in GSAP_UPSTREAM {
200            let path = f.path;
201            let entry = &manifest["files"][path];
202            assert_eq!(entry["source"], f.source, "{path}");
203            assert_eq!(entry["integrity"], f.integrity, "{path}");
204            let bytes = GSAP_ASSETS.get(path).expect("bundled").bytes().len();
205            assert_eq!(entry["bytes"], bytes, "{path}");
206        }
207    }
208}