1use crate::{AssetFileNameTemplate, ParserError, Result, DEFAULT_ASSET_FILE_NAME_TEMPLATE};
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10pub const DEFAULT_CSS_FILE_NAME_TEMPLATE: &str = DEFAULT_ASSET_FILE_NAME_TEMPLATE;
12
13#[derive(Debug, Clone)]
15pub struct CssLinkOptions {
16 file_name_template: AssetFileNameTemplate,
17 public_base: Option<String>,
18 cache: Arc<Mutex<HashMap<String, CssLinkHref>>>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CssLinkHref {
24 pub filename: String,
26 pub href: String,
28}
29
30impl CssLinkOptions {
31 pub fn try_new(file_name_template: String, public_base: Option<String>) -> Result<Self> {
38 let file_name_template =
39 AssetFileNameTemplate::try_new(file_name_template, "css_file_name_template")
40 .map_err(|error| ParserError::Css(error.to_string()))?;
41 if let Some(base) = public_base.as_deref() {
42 validate_css_public_base(base)?;
43 }
44 Ok(Self {
45 file_name_template,
46 public_base,
47 cache: Arc::new(Mutex::new(HashMap::new())),
48 })
49 }
50
51 #[must_use]
57 pub fn resolve(&self, tag_name: &str, css_content: &str) -> CssLinkHref {
58 {
59 let cache = self
60 .cache
61 .lock()
62 .unwrap_or_else(std::sync::PoisonError::into_inner);
63 if let Some(cached) = cache.get(tag_name) {
64 return cached.clone();
65 }
66 }
67
68 let filename = self
69 .file_name_template
70 .resolve(tag_name, "css", css_content.as_bytes());
71 let href = match self.public_base.as_deref() {
72 Some(base) => join_css_public_base(base, &filename),
73 None => filename.clone(),
74 };
75 let resolved = CssLinkHref { filename, href };
76
77 let mut cache = self
78 .cache
79 .lock()
80 .unwrap_or_else(std::sync::PoisonError::into_inner);
81 cache
82 .entry(tag_name.to_string())
83 .or_insert_with(|| resolved.clone())
84 .clone()
85 }
86
87 #[must_use]
94 pub fn resolve_chunk(&self, chunk_name: &str, css_content: &str) -> CssLinkHref {
95 let filename = self
96 .file_name_template
97 .resolve(chunk_name, "css", css_content.as_bytes());
98 let href = match self.public_base.as_deref() {
99 Some(base) => join_css_public_base(base, &filename),
100 None => filename.clone(),
101 };
102 CssLinkHref { filename, href }
103 }
104
105 #[must_use]
107 pub fn file_name_template(&self) -> &str {
108 self.file_name_template.file_name_template()
109 }
110
111 #[must_use]
113 pub fn public_base(&self) -> Option<&str> {
114 self.public_base.as_deref()
115 }
116}
117
118impl Default for CssLinkOptions {
119 fn default() -> Self {
120 Self {
121 file_name_template: AssetFileNameTemplate::new_unchecked(
122 DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
123 ),
124 public_base: None,
125 cache: Arc::new(Mutex::new(HashMap::new())),
126 }
127 }
128}
129
130impl PartialEq for CssLinkOptions {
131 fn eq(&self, other: &Self) -> bool {
132 self.file_name_template == other.file_name_template && self.public_base == other.public_base
133 }
134}
135
136impl Eq for CssLinkOptions {}
137
138fn validate_css_public_base(base: &str) -> Result<()> {
139 if base.is_empty() {
140 return Err(ParserError::Css(
141 "css_public_base cannot be empty".to_string(),
142 ));
143 }
144 if contains_invalid_href_byte(base) {
145 return Err(ParserError::Css(
146 "css_public_base cannot contain quotes, angle brackets, backslashes, or whitespace"
147 .to_string(),
148 ));
149 }
150 Ok(())
151}
152
153fn join_css_public_base(base: &str, css_filename: &str) -> String {
154 let mut href = String::with_capacity(base.len() + css_filename.len() + 1);
155 href.push_str(base);
156 if !base.ends_with('/') {
157 href.push('/');
158 }
159 href.push_str(css_filename);
160 href
161}
162
163fn contains_invalid_href_byte(value: &str) -> bool {
164 value
165 .bytes()
166 .any(|b| matches!(b, b'"' | b'\'' | b'<' | b'>' | b'\\') || b.is_ascii_whitespace())
167}
168
169#[cfg(test)]
170#[allow(clippy::disallowed_methods)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn default_template_does_not_require_hash() {
176 let options = CssLinkOptions::default();
177 assert!(!options.file_name_template.uses_hash());
178 let resolved = options.resolve("my-card", ".card { color: red; }");
179 assert_eq!(resolved.filename, "my-card.css");
180 }
181
182 #[test]
183 fn hash_template_uses_content_hash() {
184 let options = CssLinkOptions::try_new("[name]-[hash].[ext]".to_string(), None).unwrap();
185 assert!(options.file_name_template.uses_hash());
186 let first = options.resolve("my-card", ".card { color: red; }");
187 let second = options.resolve("other-card", ".card { color: blue; }");
188
189 assert!(first.filename.starts_with("my-card-"));
190 assert!(second.filename.starts_with("other-card-"));
191 assert_ne!(
192 first.filename.rsplit_once('-').map(|(_, hash)| hash),
193 second.filename.rsplit_once('-').map(|(_, hash)| hash)
194 );
195 }
196
197 #[test]
198 fn filename_template_rejects_filesystem_unsafe_characters() {
199 for template in [
200 "[name]:[hash].[ext]",
201 "[name]*[hash].[ext]",
202 "[name]?[hash].[ext]",
203 "[name]|[hash].[ext]",
204 "résumé-[hash].[ext]",
205 ] {
206 let result = CssLinkOptions::try_new(template.to_string(), None);
207 assert!(result.is_err(), "template should be rejected: {template}");
208 }
209 }
210}