use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThemeColor {
Light(String),
Dark(String),
LightDark { light: String, dark: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ThemeColorTag {
pub content: String,
pub media: Option<String>,
}
impl ThemeColor {
#[must_use]
pub fn light(color: impl Into<String>) -> Self {
Self::Light(color.into())
}
#[must_use]
pub fn dark(color: impl Into<String>) -> Self {
Self::Dark(color.into())
}
#[must_use]
pub fn light_dark(light: impl Into<String>, dark: impl Into<String>) -> Self {
Self::LightDark {
light: light.into(),
dark: dark.into(),
}
}
#[must_use]
pub const fn color_scheme(&self) -> &'static str {
match self {
Self::Light(_) => "light",
Self::Dark(_) => "dark",
Self::LightDark { .. } => "light dark",
}
}
#[must_use]
pub fn primary(&self) -> &str {
match self {
Self::Light(color) | Self::Dark(color) => color,
Self::LightDark { light, .. } => light,
}
}
#[must_use]
pub fn tags(&self) -> Vec<ThemeColorTag> {
match self {
Self::Light(color) | Self::Dark(color) => vec![ThemeColorTag {
content: color.clone(),
media: None,
}],
Self::LightDark { light, dark } => vec![
ThemeColorTag {
content: light.clone(),
media: Some(String::from("(prefers-color-scheme: light)")),
},
ThemeColorTag {
content: dark.clone(),
media: Some(String::from("(prefers-color-scheme: dark)")),
},
],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Fallback {
Light,
Dark,
}
impl Fallback {
const fn as_str(self) -> &'static str {
match self {
Self::Light => "light",
Self::Dark => "dark",
}
}
}
pub const THEME_STORAGE_KEY: &str = "theme";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ThemeScript {
fallback: Fallback,
}
impl ThemeScript {
#[must_use]
pub const fn new(fallback: Fallback) -> Self {
Self { fallback }
}
#[must_use]
pub fn source(&self) -> String {
format!(
"(function(){{var t;try{{t=localStorage.getItem(\"{key}\")}}catch(e){{}}\
if(t===\"light\"||t===\"dark\"){{document.documentElement.dataset.theme=t;return}}\
if(!matchMedia(\"(prefers-color-scheme: light)\").matches&&\
!matchMedia(\"(prefers-color-scheme: dark)\").matches)\
{{document.documentElement.dataset.theme=\"{fallback}\"}}}})()",
key = THEME_STORAGE_KEY,
fallback = self.fallback.as_str(),
)
}
}
#[cfg(test)]
mod tests {
use super::{Fallback, ThemeColor, ThemeColorTag, ThemeScript};
#[test]
fn a_single_scheme_site_emits_one_unconditional_tag() {
let expected: Vec<ThemeColorTag> = vec![ThemeColorTag {
content: String::from("#fafafa"),
media: None,
}];
let actual: Vec<ThemeColorTag> = ThemeColor::light("#fafafa").tags();
assert_eq!(expected, actual);
let expected_scheme: &str = "light";
let actual_scheme: &str = ThemeColor::light("#fafafa").color_scheme();
assert_eq!(expected_scheme, actual_scheme);
}
#[test]
fn a_dual_scheme_site_emits_one_media_scoped_tag_per_scheme() {
let theme: ThemeColor = ThemeColor::light_dark("#fafafa", "#121212");
let expected_scheme: &str = "light dark";
let actual_scheme: &str = theme.color_scheme();
assert_eq!(expected_scheme, actual_scheme);
let tags: Vec<ThemeColorTag> = theme.tags();
let expected_len: usize = 2;
let actual_len: usize = tags.len();
assert_eq!(expected_len, actual_len);
assert_eq!(
Some(String::from("(prefers-color-scheme: light)")),
tags[0].media
);
assert_eq!(
Some(String::from("(prefers-color-scheme: dark)")),
tags[1].media
);
}
#[test]
fn the_theme_script_stamps_nothing_when_the_os_states_a_preference() {
let source: String = ThemeScript::new(Fallback::Dark).source();
assert!(source.contains("prefers-color-scheme: light"));
assert!(source.contains("prefers-color-scheme: dark"));
assert!(source.contains("!matchMedia"));
}
#[test]
fn the_theme_script_guards_only_the_storage_read() {
let source: String = ThemeScript::new(Fallback::Light).source();
let expected: usize = 1;
let actual: usize = source.matches("try{").count();
assert_eq!(expected, actual);
assert!(source.contains("try{t=localStorage.getItem(\"theme\")}catch(e){}"));
}
#[test]
fn the_fallback_is_what_gets_stamped_when_nothing_states_a_preference() {
assert!(
ThemeScript::new(Fallback::Dark)
.source()
.contains(r#"theme="dark""#)
);
assert!(
ThemeScript::new(Fallback::Light)
.source()
.contains(r#"theme="light""#)
);
}
}