Skip to main content

laterite_admin/
skin.rs

1//! Skins: a named look for the admin chrome, chosen per deployment.
2//!
3//! A skin is a stylesheet layered after the framework's own and before the
4//! brand's variables, keyed on the `data-lat-skin` attribute the document
5//! element carries. The framework ships `standard` (no extra stylesheet) and
6//! `compact` (tighter spacing, smaller controls, a narrower sidebar). A module
7//! contributes one with [`crate::AdminRegistry::add_skin`] and its stylesheet
8//! with [`crate::AdminRegistry::add_asset`]; `backend.skin` in the
9//! configuration selects a skin by code. A code nobody registered, or a
10//! stylesheet key nobody registered, aborts boot.
11
12use std::sync::Arc;
13
14use laterite_core::i18n::Text;
15
16use crate::AssetUrls;
17
18/// The stylesheet key of the compact skin, under the admin's asset registry.
19pub const COMPACT_STYLESHEET: &str = "skins/compact.css";
20
21/// A skin as a module declares it.
22///
23/// ```ignore
24/// registry.add_asset("acme/dense.css", AdminAsset::css(include_bytes!("../assets/dense.css")));
25/// registry.add_skin(Skin {
26///     code: "acme.dense",
27///     label: t!("Dense"),
28///     stylesheet: Some("acme/dense.css"),
29/// });
30/// ```
31#[derive(Debug, Clone)]
32pub struct Skin {
33    /// Selected by `backend.skin`; a built-in is a bare word, a module's is
34    /// prefixed with its vendor (`acme.dense`).
35    pub code: &'static str,
36    /// The skin's name, for a listing.
37    pub label: Text,
38    /// The stylesheet to load after `laterite.css`, by asset key. `None`
39    /// styles nothing beyond the attribute.
40    pub stylesheet: Option<&'static str>,
41}
42
43/// The selected skin as every page reads it: the code on the document element
44/// and the stylesheet URL, when it has one.
45#[derive(Debug, Clone)]
46pub struct Active {
47    pub code: Arc<str>,
48    pub stylesheet: Option<String>,
49}
50
51impl Default for Active {
52    fn default() -> Self {
53        Self {
54            code: Arc::from("standard"),
55            stylesheet: None,
56        }
57    }
58}
59
60/// The skins the framework ships.
61pub fn builtin() -> Vec<Skin> {
62    vec![
63        Skin {
64            code: "standard",
65            label: Text::new("Standard"),
66            stylesheet: None,
67        },
68        Skin {
69            code: "compact",
70            label: Text::new("Compact"),
71            stylesheet: Some(COMPACT_STYLESHEET),
72        },
73    ]
74}
75
76/// Resolves the configured code against the registered skins. A wiring mistake
77/// is a boot failure: an unknown code, or a stylesheet key with no asset.
78pub(crate) fn resolve(code: &str, skins: &[Skin], base: &str, urls: &AssetUrls) -> Active {
79    let code = if code.trim().is_empty() {
80        "standard"
81    } else {
82        code.trim()
83    };
84    let Some(skin) = skins.iter().find(|s| s.code == code) else {
85        let known: Vec<&str> = skins.iter().map(|s| s.code).collect();
86        panic!(
87            "unknown admin skin `{code}` (registered: {})",
88            known.join(", ")
89        );
90    };
91    let stylesheet = skin.stylesheet.map(|key| {
92        let Some(path) = urls.get(key) else {
93            panic!("admin skin `{code}` names stylesheet `{key}`, which no module registered");
94        };
95        format!("{base}/assets/{path}")
96    });
97    Active {
98        code: Arc::from(skin.code),
99        stylesheet,
100    }
101}
102
103/// Checks the registered skins for a duplicate code, which is a wiring bug.
104pub(crate) fn check(skins: &[Skin]) {
105    for (i, skin) in skins.iter().enumerate() {
106        if skins[..i].iter().any(|s| s.code == skin.code) {
107            panic!("duplicate admin skin `{}`", skin.code);
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn urls() -> AssetUrls {
117        let mut urls = AssetUrls::new();
118        urls.insert(COMPACT_STYLESHEET, "skins/compact.abc123.css".to_string());
119        urls
120    }
121
122    #[test]
123    fn standard_has_no_stylesheet_and_blank_means_standard() {
124        let active = resolve("", &builtin(), "/admin", &urls());
125        assert_eq!(&*active.code, "standard");
126        assert!(active.stylesheet.is_none());
127    }
128
129    #[test]
130    fn compact_resolves_to_its_fingerprinted_stylesheet() {
131        let active = resolve("compact", &builtin(), "/manage", &urls());
132        assert_eq!(&*active.code, "compact");
133        assert_eq!(
134            active.stylesheet.as_deref(),
135            Some("/manage/assets/skins/compact.abc123.css")
136        );
137    }
138
139    #[test]
140    #[should_panic(expected = "unknown admin skin `dense`")]
141    fn an_unknown_code_aborts() {
142        resolve("dense", &builtin(), "/admin", &urls());
143    }
144
145    #[test]
146    #[should_panic(expected = "which no module registered")]
147    fn a_missing_stylesheet_aborts() {
148        resolve("compact", &builtin(), "/admin", &AssetUrls::new());
149    }
150
151    #[test]
152    #[should_panic(expected = "duplicate admin skin `compact`")]
153    fn a_duplicate_code_aborts() {
154        let mut skins = builtin();
155        skins.push(Skin {
156            code: "compact",
157            label: Text::new("Again"),
158            stylesheet: None,
159        });
160        check(&skins);
161    }
162}