Skip to main content

eui_core/
extension.rs

1//! Extension catalogs: their own catalog id, `extends` the core, their
2//! components (natives a host paints, macros expanded from their own
3//! templates) and no name that shadows a core one. Mirrors `src/extension.ts`.
4
5use crate::catalog::{CORE, CORE_CATALOG_ID};
6use crate::types::ExtensionDef;
7
8/// `^https?:\/\/[^\s]+$`
9fn is_catalog_id(id: &str) -> bool {
10    id.strip_prefix("https://")
11        .or_else(|| id.strip_prefix("http://"))
12        .is_some_and(|rest| !rest.is_empty() && !rest.chars().any(char::is_whitespace))
13}
14
15/// `^[A-Z][A-Za-z0-9]*$`
16fn is_pascal_name(name: &str) -> bool {
17    let mut chars = name.chars();
18    chars.next().is_some_and(|c| c.is_ascii_uppercase()) && chars.all(|c| c.is_ascii_alphanumeric())
19}
20
21/// Every problem with the definition (empty = valid).
22pub fn validate_extension(def: &ExtensionDef) -> Vec<String> {
23    let core = &*CORE;
24    let mut errors = Vec::new();
25    if !is_catalog_id(&def.id) {
26        errors.push("id must be a URL-shaped catalog id".to_string());
27    }
28    if def.extends != CORE_CATALOG_ID {
29        errors.push(format!("extends must be {CORE_CATALOG_ID}"));
30    }
31    if def.name.is_empty() {
32        errors.push("name is required".to_string());
33    }
34    if def.components.is_empty() {
35        errors.push("an extension defines at least one component".to_string());
36    }
37    let ext_enums = def.enums.as_ref();
38    let ext_defs = def.defs.as_ref();
39    for (name, component) in &def.components {
40        if !is_pascal_name(name) {
41            errors.push(format!("{name}: component names are PascalCase"));
42        }
43        if core.components.contains_key(name) {
44            errors.push(format!("{name}: shadows a core component"));
45        }
46        if component.description.is_empty() {
47            errors.push(format!("{name}: description is required"));
48        }
49        for (prop, schema) in &component.props {
50            if schema.description.is_empty() {
51                errors.push(format!("{name}.{prop}: description is required"));
52            }
53            if let Some(e) = schema.enum_.as_deref().filter(|e| !e.is_empty()) {
54                if schema.type_ == "enum" && !core.enums.contains_key(e) && !ext_enums.is_some_and(|m| m.contains_key(e)) {
55                    errors.push(format!("{name}.{prop}: enum {e} is not defined"));
56                }
57            }
58            if let Some(s) = schema.shape.as_deref().filter(|s| !s.is_empty()) {
59                if schema.type_ == "object" && !core.defs.contains_key(s) && !ext_defs.is_some_and(|m| m.contains_key(s)) {
60                    errors.push(format!("{name}.{prop}: shape {s} is not defined"));
61                }
62            }
63        }
64        if component.is_macro() && !def.macros.as_ref().is_some_and(|m| m.contains_key(name)) {
65            errors.push(format!("{name}: a macro needs a template in macros"));
66        }
67    }
68    for name in def.macros.iter().flat_map(|m| m.keys()) {
69        if !def.components.get(name).is_some_and(|c| c.is_macro()) {
70            errors.push(format!("macros.{name}: no macro component of that name"));
71        }
72    }
73    for name in def.enums.iter().flat_map(|m| m.keys()) {
74        if core.enums.contains_key(name) {
75            errors.push(format!("enums.{name}: shadows a core enum"));
76        }
77    }
78    errors
79}
80
81/// The definition, checked; `Err` lists every problem.
82pub fn define_extension(def: ExtensionDef) -> Result<ExtensionDef, String> {
83    let errors = validate_extension(&def);
84    if errors.is_empty() {
85        Ok(def)
86    } else {
87        Err(format!("extension {}:\n{}", def.id, errors.join("\n")))
88    }
89}
90
91/// Parse an extension catalog from JSON and check it.
92pub fn parse_extension(json: &str) -> Result<ExtensionDef, String> {
93    let def: ExtensionDef = serde_json::from_str(json).map_err(|e| format!("extension: {e}"))?;
94    define_extension(def)
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn example() -> ExtensionDef {
102        let text = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../packages/exponential-ui/fixtures/catalog-extension.json"))
103            .expect("catalog-extension.json");
104        let file: serde_json::Value = serde_json::from_str(&text).unwrap();
105        serde_json::from_value(file["extension"].clone()).unwrap()
106    }
107
108    #[test]
109    fn define_extension_accepts_the_example_extension() {
110        let ex = example();
111        assert_eq!(validate_extension(&ex), Vec::<String>::new());
112        assert_eq!(define_extension(ex.clone()), Ok(ex.clone()));
113        let json = serde_json::to_string(&ex).unwrap();
114        assert_eq!(parse_extension(&json), Ok(ex));
115    }
116
117    #[test]
118    fn define_extension_refuses_a_core_name_a_missing_macro_template_a_wrong_base_and_a_bad_id() {
119        let ex = example();
120        let mut shadow = ex.clone();
121        shadow.components.insert("Button".into(), ex.components["TrendLine"].clone());
122        assert!(validate_extension(&shadow).contains(&"Button: shadows a core component".to_string()));
123        let mut no_template = ex.clone();
124        no_template.macros = Some(Default::default());
125        assert!(validate_extension(&no_template).contains(&"StatCard: a macro needs a template in macros".to_string()));
126        let mut wrong_base = ex.clone();
127        wrong_base.extends = "https://example.com/other".into();
128        assert!(validate_extension(&wrong_base).contains(&format!("extends must be {CORE_CATALOG_ID}")));
129        let mut bad_id = ex.clone();
130        bad_id.id = "example".into();
131        assert!(validate_extension(&bad_id).contains(&"id must be a URL-shaped catalog id".to_string()));
132        assert!(define_extension(bad_id).unwrap_err().contains("catalog id"));
133        assert!(parse_extension("{").is_err());
134    }
135}