Skip to main content

yggdrasil/
static_assets.rs

1//! 固定路径静态资源的内容版本号。
2//!
3//! 全局样式、启动脚本和按需加载的浏览器库放在 `public/` 下,文件名固定。页面引用它们时带
4//! `?v=<内容摘要>`:内容一变地址就变。EdgeOne 的缓存键包含查询参数,所以浏览器和 CDN
5//! 节点都能长期缓存,见 `middleware::cache_control_for_path`。只有版本号与当前文件一致时
6//! 才返回 immutable;滚动发布期间旧页面带旧版本号来请求,拿到的新内容仍须每次校验,
7//! 不会以旧地址被长期缓存。
8//!
9//! 服务端按文件内容计算摘要,按修改时间和长度缓存。`make dev-assets` 改了文件,下一次
10//! 渲染就会换新版本号。版本表经 `shell` 里的 `<script type="application/json">`
11//! (id 为 [`VERSIONS_ELEMENT_ID`])下发给浏览器:Rust 端用 [`url`] 读取,libs 用
12//! `@yggdrasil/shared` 的 `assetUrl` 读取。文件不存在时(未构建 libs 的测试或开发环境)
13//! 返回不带版本号的地址,缓存策略也退回 no-cache。
14
15/// 站点样式(Tailwind 产物)。
16pub const STYLE_CSS: &str = "/style.css";
17/// 后台与组件图鉴样式(Tailwind 产物,入口 input-admin.css)。
18pub const ADMIN_CSS: &str = "/admin.css";
19/// 后台样式 `<link>` 的 id:SSR 写入与客户端补插共用,避免重复插入。
20pub const ADMIN_CSS_ELEMENT_ID: &str = "ygg-admin-css";
21/// 代码高亮样式。
22pub const HIGHLIGHT_CSS: &str = "/highlight.css";
23/// KaTeX 样式。
24pub const KATEX_CSS: &str = "/katex/katex.min.css";
25/// yggdrasil-core 样式。
26pub const CORE_CSS: &str = "/yggdrasil-core/yggdrasil-core.css";
27/// yggdrasil-core 启动脚本(导航协调器、主题、浏览器库加载器)。
28pub const CORE_JS: &str = "/yggdrasil-core/yggdrasil-core.js";
29/// 正文增强脚本(mermaid、目录、changelog、瀑布流)。
30pub const ENHANCE_JS: &str = "/yggdrasil-core/yggdrasil-enhance.js";
31/// 灯箱脚本。
32pub const LIGHTBOX_JS: &str = "/lightbox/lightbox.js";
33/// 灯箱样式。
34pub const LIGHTBOX_CSS: &str = "/lightbox/lightbox.css";
35
36/// 下发版本表的 `<script type="application/json">` 元素 id,libs 里同名常量须保持一致。
37pub const VERSIONS_ELEMENT_ID: &str = "ygg-asset-versions";
38
39/// 参与版本化的全部资源。后五项由 libs 按需加载,地址写在
40/// `libs/yggdrasil-core/src/browser-library.ts`。
41pub const ALL: &[&str] = &[
42    STYLE_CSS,
43    ADMIN_CSS,
44    HIGHLIGHT_CSS,
45    KATEX_CSS,
46    CORE_CSS,
47    CORE_JS,
48    ENHANCE_JS,
49    LIGHTBOX_JS,
50    LIGHTBOX_CSS,
51    "/tiptap/editor.js",
52    "/tiptap/editor.css",
53    "/codemirror/editor.js",
54    "/xterm/terminal.js",
55    "/xterm/terminal.css",
56];
57
58/// 带版本号的地址;没有版本号(文件缺失)时原样返回。
59pub fn url(path: &'static str) -> String {
60    match version(path) {
61        Some(version) => format!("{path}?v={version}"),
62        None => path.to_string(),
63    }
64}
65
66/// 该路径是否需要后台样式:后台 `/admin…` 与组件图鉴 `/about/components…`。
67#[cfg(feature = "server")]
68pub fn needs_admin_css(path: &str) -> bool {
69    crate::routes::is_admin_path(path) || crate::routes::is_showcase_path(path)
70}
71
72/// 客户端导航进入需要后台样式的路由时补插 `<link>`:已存在(SSR 写入或之前插过)就不动,
73/// 离开后也不移除。由这些路由的 `LazyRoute::data()` 调用,样式与视图分包并行下载。
74pub fn ensure_admin_css() {
75    #[cfg(target_arch = "wasm32")]
76    {
77        let Some(document) = web_sys::window().and_then(|w| w.document()) else {
78            return;
79        };
80        if document.get_element_by_id(ADMIN_CSS_ELEMENT_ID).is_some() {
81            return;
82        }
83        let (Ok(link), Ok(Some(head))) = (
84            document.create_element("link"),
85            document.query_selector("head"),
86        ) else {
87            return;
88        };
89        let _ = link.set_attribute("id", ADMIN_CSS_ELEMENT_ID);
90        let _ = link.set_attribute("rel", "stylesheet");
91        let _ = link.set_attribute("href", &url(ADMIN_CSS));
92        let _ = head.append_child(&link);
93    }
94}
95
96#[cfg(feature = "server")]
97pub use server::{is_current, versions_json};
98
99#[cfg(feature = "server")]
100use server::version;
101
102#[cfg(feature = "server")]
103mod server {
104    use std::collections::HashMap;
105    use std::sync::{LazyLock, Mutex, PoisonError};
106    use std::time::SystemTime;
107
108    use sha2::Digest;
109
110    use super::ALL;
111
112    struct Hashed {
113        modified: SystemTime,
114        len: u64,
115        version: String,
116    }
117
118    static CACHE: LazyLock<Mutex<HashMap<&'static str, Hashed>>> =
119        LazyLock::new(|| Mutex::new(HashMap::new()));
120
121    /// 当前文件内容的版本号:SHA-256 前 8 字节的十六进制。不在 [`ALL`] 里或文件不存在时为 None。
122    pub(super) fn version(path: &str) -> Option<String> {
123        let path = *ALL.iter().find(|known| **known == path)?;
124        let file = crate::startup::site_root().join(path.trim_start_matches('/'));
125        let meta = std::fs::metadata(&file).ok()?;
126        let (modified, len) = (meta.modified().ok()?, meta.len());
127        {
128            let cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
129            if let Some(hashed) = cache.get(path) {
130                if hashed.modified == modified && hashed.len == len {
131                    return Some(hashed.version.clone());
132                }
133            }
134        }
135        let bytes = std::fs::read(&file).ok()?;
136        let version = hex::encode(&sha2::Sha256::digest(&bytes)[..8]);
137        CACHE.lock().unwrap_or_else(PoisonError::into_inner).insert(
138            path,
139            Hashed {
140                modified,
141                len,
142                version: version.clone(),
143            },
144        );
145        Some(version)
146    }
147
148    /// 请求的 `v` 参数是否等于该资源当前的版本号。
149    pub fn is_current(path: &str, query: Option<&str>) -> bool {
150        let Some(requested) = query
151            .into_iter()
152            .flat_map(|query| query.split('&'))
153            .find_map(|pair| pair.strip_prefix("v="))
154        else {
155            return false;
156        };
157        version(path).is_some_and(|current| current == requested)
158    }
159
160    /// 版本表 JSON(`{"/style.css":"…",…}`),只含存在的文件。路径与摘要都不含需转义的字符。
161    pub fn versions_json() -> String {
162        let entries: Vec<String> = ALL
163            .iter()
164            .filter_map(|path| version(path).map(|version| format!("\"{path}\":\"{version}\"")))
165            .collect();
166        format!("{{{}}}", entries.join(","))
167    }
168}
169
170/// 浏览器端从 SSR 下发的版本表里读取。
171#[cfg(not(feature = "server"))]
172fn version(path: &str) -> Option<String> {
173    #[cfg(target_arch = "wasm32")]
174    {
175        let text = web_sys::window()?
176            .document()?
177            .get_element_by_id(VERSIONS_ELEMENT_ID)?
178            .text_content()?;
179        let versions = js_sys::JSON::parse(&text).ok()?;
180        js_sys::Reflect::get(&versions, &path.into())
181            .ok()?
182            .as_string()
183    }
184    #[cfg(not(target_arch = "wasm32"))]
185    {
186        let _ = path;
187        None
188    }
189}
190
191#[cfg(all(test, feature = "server"))]
192mod tests {
193    use super::*;
194
195    /// 在临时站点目录里写文件,验证版本号跟随内容变化、只认当前版本。
196    #[test]
197    #[serial_test::serial]
198    fn version_follows_content_and_only_current_is_immutable() {
199        let root = std::env::temp_dir().join(format!("ygg-static-assets-{}", std::process::id()));
200        std::fs::create_dir_all(&root).unwrap();
201        let previous = std::env::var_os("LEPTOS_SITE_ROOT");
202        std::env::set_var("LEPTOS_SITE_ROOT", &root);
203
204        assert_eq!(url(HIGHLIGHT_CSS), HIGHLIGHT_CSS, "文件缺失时不带版本号");
205        assert!(!is_current(HIGHLIGHT_CSS, Some("v=anything")));
206
207        let file = root.join("highlight.css");
208        std::fs::write(&file, "a{}").unwrap();
209        let first = url(HIGHLIGHT_CSS);
210        let v1 = first.strip_prefix("/highlight.css?v=").unwrap().to_string();
211        assert_eq!(v1.len(), 16);
212        assert!(is_current(HIGHLIGHT_CSS, Some(&format!("x=1&v={v1}"))));
213        assert!(!is_current(HIGHLIGHT_CSS, None));
214        assert!(!is_current(HIGHLIGHT_CSS, Some("v=0000000000000000")));
215        assert_eq!(versions_json(), format!("{{\"/highlight.css\":\"{v1}\"}}"));
216
217        // 改内容(长度也变)后版本号随之改变,旧版本号不再算当前。
218        std::fs::write(&file, "a{color:red}").unwrap();
219        let v2 = url(HIGHLIGHT_CSS)
220            .strip_prefix("/highlight.css?v=")
221            .unwrap()
222            .to_string();
223        assert_ne!(v1, v2);
224        assert!(!is_current(HIGHLIGHT_CSS, Some(&format!("v={v1}"))));
225
226        // 不在清单里的路径一律不版本化,防止借此读取任意文件。
227        std::fs::write(root.join("other.css"), "b{}").unwrap();
228        assert!(!is_current("/other.css", Some("v=x")));
229
230        match previous {
231            Some(value) => std::env::set_var("LEPTOS_SITE_ROOT", value),
232            None => std::env::remove_var("LEPTOS_SITE_ROOT"),
233        }
234        std::fs::remove_dir_all(&root).unwrap();
235    }
236
237    #[test]
238    fn admin_css_only_for_admin_and_showcase_paths() {
239        for path in [
240            "/admin",
241            "/admin/posts",
242            "/about/components",
243            "/about/components/button",
244        ] {
245            assert!(needs_admin_css(path), "{path}");
246        }
247        for path in ["/administrator", "/about", "/about/componentsx", "/"] {
248            assert!(!needs_admin_css(path), "{path}");
249        }
250    }
251}