webserver_base/templates/
theme.rs1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub enum ThemeColor {
15 Light(String),
17 Dark(String),
19 LightDark { light: String, dark: String },
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct ThemeColorTag {
26 pub content: String,
27 pub media: Option<String>,
29}
30
31impl ThemeColor {
32 #[must_use]
34 pub fn light(color: impl Into<String>) -> Self {
35 Self::Light(color.into())
36 }
37
38 #[must_use]
40 pub fn dark(color: impl Into<String>) -> Self {
41 Self::Dark(color.into())
42 }
43
44 #[must_use]
50 pub fn light_dark(light: impl Into<String>, dark: impl Into<String>) -> Self {
51 Self::LightDark {
52 light: light.into(),
53 dark: dark.into(),
54 }
55 }
56
57 #[must_use]
59 pub const fn color_scheme(&self) -> &'static str {
60 match self {
61 Self::Light(_) => "light",
62 Self::Dark(_) => "dark",
63 Self::LightDark { .. } => "light dark",
64 }
65 }
66
67 #[must_use]
71 pub fn primary(&self) -> &str {
72 match self {
73 Self::Light(color) | Self::Dark(color) => color,
74 Self::LightDark { light, .. } => light,
75 }
76 }
77
78 #[must_use]
80 pub fn tags(&self) -> Vec<ThemeColorTag> {
81 match self {
82 Self::Light(color) | Self::Dark(color) => vec![ThemeColorTag {
83 content: color.clone(),
84 media: None,
85 }],
86 Self::LightDark { light, dark } => vec![
87 ThemeColorTag {
88 content: light.clone(),
89 media: Some(String::from("(prefers-color-scheme: light)")),
90 },
91 ThemeColorTag {
92 content: dark.clone(),
93 media: Some(String::from("(prefers-color-scheme: dark)")),
94 },
95 ],
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum Fallback {
104 Light,
105 Dark,
106}
107
108impl Fallback {
109 const fn as_str(self) -> &'static str {
110 match self {
111 Self::Light => "light",
112 Self::Dark => "dark",
113 }
114 }
115}
116
117pub const THEME_STORAGE_KEY: &str = "theme";
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct ThemeScript {
140 fallback: Fallback,
141}
142
143impl ThemeScript {
144 #[must_use]
146 pub const fn new(fallback: Fallback) -> Self {
147 Self { fallback }
148 }
149
150 #[must_use]
156 pub fn source(&self) -> String {
157 format!(
158 "(function(){{var t;try{{t=localStorage.getItem(\"{key}\")}}catch(e){{}}\
159 if(t===\"light\"||t===\"dark\"){{document.documentElement.dataset.theme=t;return}}\
160 if(!matchMedia(\"(prefers-color-scheme: light)\").matches&&\
161 !matchMedia(\"(prefers-color-scheme: dark)\").matches)\
162 {{document.documentElement.dataset.theme=\"{fallback}\"}}}})()",
163 key = THEME_STORAGE_KEY,
164 fallback = self.fallback.as_str(),
165 )
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::{Fallback, ThemeColor, ThemeColorTag, ThemeScript};
172
173 #[test]
174 fn a_single_scheme_site_emits_one_unconditional_tag() {
175 let expected: Vec<ThemeColorTag> = vec![ThemeColorTag {
176 content: String::from("#fafafa"),
177 media: None,
178 }];
179 let actual: Vec<ThemeColorTag> = ThemeColor::light("#fafafa").tags();
180 assert_eq!(expected, actual);
181
182 let expected_scheme: &str = "light";
183 let actual_scheme: &str = ThemeColor::light("#fafafa").color_scheme();
184 assert_eq!(expected_scheme, actual_scheme);
185 }
186
187 #[test]
188 fn a_dual_scheme_site_emits_one_media_scoped_tag_per_scheme() {
189 let theme: ThemeColor = ThemeColor::light_dark("#fafafa", "#121212");
190
191 let expected_scheme: &str = "light dark";
192 let actual_scheme: &str = theme.color_scheme();
193 assert_eq!(expected_scheme, actual_scheme);
194
195 let tags: Vec<ThemeColorTag> = theme.tags();
196
197 let expected_len: usize = 2;
198 let actual_len: usize = tags.len();
199 assert_eq!(expected_len, actual_len);
200
201 assert_eq!(
202 Some(String::from("(prefers-color-scheme: light)")),
203 tags[0].media
204 );
205 assert_eq!(
206 Some(String::from("(prefers-color-scheme: dark)")),
207 tags[1].media
208 );
209 }
210
211 #[test]
212 fn the_theme_script_stamps_nothing_when_the_os_states_a_preference() {
213 let source: String = ThemeScript::new(Fallback::Dark).source();
214
215 assert!(source.contains("prefers-color-scheme: light"));
218 assert!(source.contains("prefers-color-scheme: dark"));
219 assert!(source.contains("!matchMedia"));
220 }
221
222 #[test]
223 fn the_theme_script_guards_only_the_storage_read() {
224 let source: String = ThemeScript::new(Fallback::Light).source();
225
226 let expected: usize = 1;
227 let actual: usize = source.matches("try{").count();
228 assert_eq!(expected, actual);
229 assert!(source.contains("try{t=localStorage.getItem(\"theme\")}catch(e){}"));
230 }
231
232 #[test]
233 fn the_fallback_is_what_gets_stamped_when_nothing_states_a_preference() {
234 assert!(
235 ThemeScript::new(Fallback::Dark)
236 .source()
237 .contains(r#"theme="dark""#)
238 );
239 assert!(
240 ThemeScript::new(Fallback::Light)
241 .source()
242 .contains(r#"theme="light""#)
243 );
244 }
245}