Skip to main content

eui_core/host/
components.rs

1//! VAPP-203: install checks every template the way the renderer will. Each
2//! template's components go through the reducer ([`reduce_surface`]:
3//! unknown components, missing or wrong props, child ids that name nothing,
4//! cycles, slots, styles), so a package that would render broken never
5//! installs. A reducer issue (keyed by a component id) becomes a JSON
6//! pointer: the component's own entry, or, for an id no component has,
7//! every place that names it (`children/<j>`, `children/componentId`,
8//! `slots/<name>`). The twin of `src/host/components.ts`
9//! (fixtures/host-router.json).
10
11use 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
33/// The shape the reducer reads: `children` = ids or `{componentId, path,
34/// key?}`, `slots` = ids by name, `on` and `style` objects. Null = unset.
35fn 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
58/// Every place a component list names `id` as a child.
59fn 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
85/// VAPP-203: every template's components, reduced as the host will render
86/// them against `view` (the core plus the extensions the host has
87/// definitions for). An unsupported catalog is reported elsewhere. A
88/// catalog or declared extension `view` does not know leaves its component
89/// names open (an unknown component is not reported); every other check
90/// still holds.
91pub 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 Ok(flat) = list.iter().map(|c| serde_json::from_value::<FlatComponent>(Value::Object((*c).clone()))).collect::<Result<Vec<_>, _>>() else { continue };
132        let index: HashMap<&str, usize> = list.iter().enumerate().filter_map(|(i, c)| c.get("id").and_then(Value::as_str).map(|id| (id, i))).collect();
133        let mut seen: HashSet<(String, String)> = HashSet::new();
134        let mut push = |path: String, message: String, issues: &mut Vec<PackageIssue>| {
135            if seen.insert((path.clone(), message.clone())) {
136                issues.push(PackageIssue { path, message });
137            }
138        };
139        let options = ReduceOptions::new(reduce_as).with_view(view.clone());
140        for found in reduce_surface(&flat, &options).issues {
141            if open && found.message.starts_with("unknown component ") {
142                continue;
143            }
144            if let Some(i) = index.get(found.id.as_str()) {
145                push(format!("{at}/{i}"), found.message, &mut issues);
146                continue;
147            }
148            let refs = references(&list, &found.id, &at);
149            let message = format!("{}: {}", found.id, found.message);
150            if refs.is_empty() {
151                push(at.clone(), message.clone(), &mut issues);
152            }
153            for r in refs {
154                push(r, message.clone(), &mut issues);
155            }
156        }
157    }
158    issues
159}