Skip to main content

marco_core/render/
theme_meta.rs

1/// Theme metadata: parses descriptive `--theme-*` custom properties
2/// out of a theme token CSS file.
3///
4/// Theme files may declare these alongside their colour/font tokens in `:root`,
5/// e.g.:
6///
7/// ```css
8/// :root {
9///   --theme-name: 'GitHub';
10///   --theme-author: 'Kim Skov Rasmussen';
11///   --theme-license: 'MIT';
12///   --theme-version: '0.24.0';
13///   --theme-description: 'GitHub-inspired theme (Primer palette)';
14///   /* ...regular colour/font tokens... */
15/// }
16/// ```
17///
18/// Because these are ordinary CSS custom properties, they need no support from
19/// [`base_css()`](super::base_css::base_css) — they simply carry data through
20/// the same mechanism as every other token. Consumers (e.g. a theme picker UI)
21/// call [`parse_theme_metadata`] on the raw theme CSS to read them back out,
22/// instead of deriving a display name from the filename.
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub struct ThemeMetadata {
25    pub name: Option<String>,
26    pub author: Option<String>,
27    pub license: Option<String>,
28    pub version: Option<String>,
29    pub description: Option<String>,
30}
31
32/// Extract [`ThemeMetadata`] from a theme's raw CSS text.
33///
34/// Looks for declarations of the form `--theme-<field>: '<value>';`
35/// (single or double quotes). Missing or malformed declarations are left as
36/// `None` rather than erroring — metadata is optional, descriptive data.
37pub fn parse_theme_metadata(css: &str) -> ThemeMetadata {
38    ThemeMetadata {
39        name: extract_token(css, "--theme-name"),
40        author: extract_token(css, "--theme-author"),
41        license: extract_token(css, "--theme-license"),
42        version: extract_token(css, "--theme-version"),
43        description: extract_token(css, "--theme-description"),
44    }
45}
46
47fn extract_token(css: &str, property: &str) -> Option<String> {
48    let mut search_from = 0;
49    while let Some(offset) = css[search_from..].find(property) {
50        let start = search_from + offset;
51        let after_name = &css[start + property.len()..];
52        // Require the match to end the property name: next non-space char must
53        // be ':' , not another identifier char (e.g. `-extra`) that would mean
54        // this is actually a different, longer custom property name.
55        let next_non_space = after_name.trim_start();
56        if let Some(value) = next_non_space.strip_prefix(':') {
57            let semicolon = value.find(';')?;
58            let raw = value[..semicolon].trim();
59            let quote = raw.chars().next()?;
60            if quote == '\'' || quote == '"' {
61                if let Some(rest) = raw.strip_prefix(quote) {
62                    if let Some(unquoted) = rest.strip_suffix(quote) {
63                        return Some(unquoted.trim().to_string());
64                    }
65                }
66            }
67            return None;
68        }
69        search_from = start + property.len();
70    }
71    None
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn parses_all_fields_single_quotes() {
80        let css = r#"
81:root {
82  --theme-name: 'GitHub';
83  --theme-author: 'Kim Skov Rasmussen';
84  --theme-license: 'MIT';
85  --theme-version: '0.24.0';
86  --theme-description: 'GitHub-inspired theme';
87  --text-color: #1f2328;
88}
89"#;
90        let meta = parse_theme_metadata(css);
91        assert_eq!(meta.name.as_deref(), Some("GitHub"));
92        assert_eq!(meta.author.as_deref(), Some("Kim Skov Rasmussen"));
93        assert_eq!(meta.license.as_deref(), Some("MIT"));
94        assert_eq!(meta.version.as_deref(), Some("0.24.0"));
95        assert_eq!(meta.description.as_deref(), Some("GitHub-inspired theme"));
96    }
97
98    #[test]
99    fn parses_double_quotes() {
100        let css = r#"--theme-name: "Nord";"#;
101        assert_eq!(parse_theme_metadata(css).name.as_deref(), Some("Nord"));
102    }
103
104    #[test]
105    fn missing_fields_are_none() {
106        let css = ":root { --text-color: #333; }";
107        let meta = parse_theme_metadata(css);
108        assert_eq!(meta, ThemeMetadata::default());
109    }
110
111    #[test]
112    fn does_not_match_longer_property_names_with_same_prefix() {
113        let css = "--theme-name-extra: 'not-a-name'; --theme-name: 'Real';";
114        let meta = parse_theme_metadata(css);
115        assert_eq!(meta.name.as_deref(), Some("Real"));
116    }
117}