yggdrasil/
static_assets.rs1pub const STYLE_CSS: &str = "/style.css";
17pub const ADMIN_CSS: &str = "/admin.css";
19pub const ADMIN_CSS_ELEMENT_ID: &str = "ygg-admin-css";
21pub const HIGHLIGHT_CSS: &str = "/highlight.css";
23pub const KATEX_CSS: &str = "/katex/katex.min.css";
25pub const CORE_CSS: &str = "/yggdrasil-core/yggdrasil-core.css";
27pub const CORE_JS: &str = "/yggdrasil-core/yggdrasil-core.js";
29pub const ENHANCE_JS: &str = "/yggdrasil-core/yggdrasil-enhance.js";
31pub const LIGHTBOX_JS: &str = "/lightbox/lightbox.js";
33pub const LIGHTBOX_CSS: &str = "/lightbox/lightbox.css";
35
36pub const VERSIONS_ELEMENT_ID: &str = "ygg-asset-versions";
38
39pub 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
58pub 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#[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
72pub 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 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 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 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#[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 #[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 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 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}