eui_core/host/
components.rs1use std::collections::{HashMap, HashSet};
12use std::sync::Arc;
13
14use serde_json::{Map, Value};
15
16use super::package::PackageIssue;
17use crate::catalog::{CatalogView, CORE_CATALOG_ID};
18use crate::reducer::{reduce_surface, ReduceOptions};
19use crate::types::FlatComponent;
20
21fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
22 PackageIssue { path: path.into(), message: message.into() }
23}
24
25fn escape(key: &str) -> String {
26 key.replace('~', "~0").replace('/', "~1")
27}
28
29fn set(v: Option<&Value>) -> Option<&Value> {
30 v.filter(|v| !v.is_null())
31}
32
33fn shape_issues(c: &Map<String, Value>, at: &str, out: &mut Vec<PackageIssue>) {
36 if let Some(ch) = set(c.get("children")) {
37 let ids = ch.as_array().is_some_and(|list| list.iter().all(Value::is_string));
38 let template = ch.as_object().is_some_and(|o| {
39 o.get("componentId").is_some_and(Value::is_string) && o.get("path").is_some_and(Value::is_string) && set(o.get("key")).is_none_or(Value::is_string)
40 });
41 if !ids && !template {
42 out.push(issue(format!("{at}/children"), "children is a list of ids or {componentId, path, key?}"));
43 }
44 }
45 if let Some(slots) = set(c.get("slots")) {
46 if !slots.as_object().is_some_and(|o| o.values().all(Value::is_string)) {
47 out.push(issue(format!("{at}/slots"), "slots is an object of component ids"));
48 }
49 }
50 if set(c.get("on")).is_some_and(|v| !v.is_object()) {
51 out.push(issue(format!("{at}/on"), "on is an object of actions"));
52 }
53 if set(c.get("style")).is_some_and(|v| !v.is_object()) {
54 out.push(issue(format!("{at}/style"), "style is an object"));
55 }
56}
57
58fn references(components: &[&Map<String, Value>], id: &str, at: &str) -> Vec<String> {
60 let mut out = Vec::new();
61 for (i, c) in components.iter().enumerate() {
62 let here = format!("{at}/{i}");
63 match c.get("children") {
64 Some(Value::Array(list)) => {
65 for (j, x) in list.iter().enumerate() {
66 if x.as_str() == Some(id) {
67 out.push(format!("{here}/children/{j}"));
68 }
69 }
70 }
71 Some(Value::Object(o)) if o.get("componentId").and_then(Value::as_str) == Some(id) => out.push(format!("{here}/children/componentId")),
72 _ => {}
73 }
74 if let Some(slots) = c.get("slots").and_then(Value::as_object) {
75 for (name, x) in slots {
76 if x.as_str() == Some(id) {
77 out.push(format!("{here}/slots/{}", escape(name)));
78 }
79 }
80 }
81 }
82 out
83}
84
85pub fn validate_package_components<S: AsRef<str>>(pkg: &Map<String, Value>, catalog_ids: &[S], view: &Arc<CatalogView>) -> Vec<PackageIssue> {
92 let mut issues = Vec::new();
93 let catalog_id = pkg.get("catalogId").and_then(Value::as_str).unwrap_or("");
94 if !catalog_ids.iter().any(|c| c.as_ref() == catalog_id) {
95 return issues;
96 }
97 let Some(templates) = pkg.get("templates").and_then(Value::as_object) else { return issues };
98 let declared_unknown = pkg
99 .get("extensions")
100 .and_then(Value::as_array)
101 .is_some_and(|list| list.iter().filter_map(|e| e.get("id").and_then(Value::as_str)).any(|id| !id.is_empty() && !view.knows_catalog(id)));
102 let open = !view.knows_catalog(catalog_id) || declared_unknown;
103 let reduce_as = if view.knows_catalog(catalog_id) { catalog_id } else { CORE_CATALOG_ID };
104 for (id, template) in super::package::sorted_entries(templates) {
105 let Some(components) = template.as_object().and_then(|t| t.get("components")).and_then(Value::as_array) else { continue };
106 let at = format!("/templates/{}/components", escape(id));
107 let mut ids: HashSet<&str> = HashSet::new();
108 let mut list: Vec<&Map<String, Value>> = Vec::new();
109 let mut clean = !components.is_empty();
110 for c in components {
111 let entry = c.as_object().filter(|o| o.get("component").is_some_and(Value::is_string));
112 match entry.and_then(|o| o.get("id").and_then(Value::as_str).map(|id| (o, id))) {
113 Some((o, cid)) if !ids.contains(cid) => {
114 ids.insert(cid);
115 list.push(o);
116 }
117 _ => clean = false,
118 }
119 }
120 if !clean || !ids.contains("root") {
121 continue;
122 }
123 let mut shape = Vec::new();
124 for (i, c) in list.iter().enumerate() {
125 shape_issues(c, &format!("{at}/{i}"), &mut shape);
126 }
127 if !shape.is_empty() {
128 issues.extend(shape);
129 continue;
130 }
131 let values: Vec<Value> = list.iter().map(|c| Value::Object((*c).clone())).collect();
134 let reduced = if super::record_macros::uses_record_macros(&values) { super::record_macros::fill_record_macros(&values, &[], pkg.get("records"), false).0 } else { values };
135 let Ok(flat) = reduced.iter().map(|c| serde_json::from_value::<FlatComponent>(c.clone())).collect::<Result<Vec<_>, _>>() else { continue };
136 let mut index: HashMap<String, usize> = list.iter().enumerate().filter_map(|(i, c)| c.get("id").and_then(Value::as_str).map(|id| (id.to_string(), i))).collect();
137 for c in &reduced {
138 let Some(cid) = c.get("id").and_then(Value::as_str) else { continue };
139 if let Some(owner) = cid.strip_suffix("__row").and_then(|o| index.get(o).copied()) {
140 index.entry(cid.to_string()).or_insert(owner);
141 }
142 }
143 let mut seen: HashSet<(String, String)> = HashSet::new();
144 let mut push = |path: String, message: String, issues: &mut Vec<PackageIssue>| {
145 if seen.insert((path.clone(), message.clone())) {
146 issues.push(PackageIssue { path, message });
147 }
148 };
149 let options = ReduceOptions::new(reduce_as).with_view(view.clone());
150 for found in reduce_surface(&flat, &options).issues {
151 if open && found.message.starts_with("unknown component ") {
152 continue;
153 }
154 if let Some(i) = index.get(found.id.as_str()) {
155 push(format!("{at}/{i}"), found.message, &mut issues);
156 continue;
157 }
158 let refs = references(&list, &found.id, &at);
159 let message = format!("{}: {}", found.id, found.message);
160 if refs.is_empty() {
161 push(at.clone(), message.clone(), &mut issues);
162 }
163 for r in refs {
164 push(r, message.clone(), &mut issues);
165 }
166 }
167 }
168 issues
169}