1use std::collections::HashMap;
24use std::sync::OnceLock;
25
26use schemars::schema_for;
27use serde_json::{to_value, Value};
28
29use crate::component::{
30 ActionCardProps, AlertProps, AvatarProps, BadgeProps, BreadcrumbProps, ButtonGroupProps,
31 ButtonProps, CalendarCellProps, CardProps, CheckboxListProps, CheckboxProps, ChecklistProps,
32 CollapsibleProps, DataTableProps, DescriptionListProps, DetailPageProps, DropdownMenuProps,
33 EmptyStateProps, FormProps, FormSectionProps, GridProps, HeaderProps, ImageProps, InputProps,
34 KanbanBoardProps, MediaCardGridProps, ModalProps, NotificationDropdownProps, PageHeaderProps,
35 PaginationProps, ProductTileProps, ProgressProps, RawHtmlProps, SelectProps, SeparatorProps,
36 SidebarProps, SkeletonProps, StatCardProps, SwitchProps, TableProps, TabsProps, TextProps,
37 ToastProps,
38};
39
40pub struct ComponentSpec {
47 pub name: String,
49 pub description: String,
51 pub props_schema: Value,
53 pub is_plugin: bool,
55 pub slot_fields: Vec<String>,
60}
61
62pub struct Catalog {
68 pub(crate) components: HashMap<String, ComponentSpec>,
70 pub(crate) plugin_components: HashMap<String, ComponentSpec>,
72 pub(crate) full_schema: Value,
74 pub(crate) per_component_schemas: HashMap<String, Value>,
76 pub(crate) validator: jsonschema::Validator,
78}
79
80#[derive(Debug, thiserror::Error)]
82pub enum CatalogError {
83 #[error("unknown component type '{type_name}' at element '{element_id}'")]
85 UnknownType {
86 element_id: String,
88 type_name: String,
90 },
91 #[error("props invalid for '{type_name}' at element '{element_id}': {errors:?}")]
93 PropsInvalid {
94 element_id: String,
96 type_name: String,
98 errors: Vec<String>,
100 },
101 #[error("spec invalid: {errors:?}")]
103 SpecInvalid {
104 errors: Vec<String>,
106 },
107 #[error("catalog build failed: {0}")]
109 BuildFailed(String),
110 #[error("schema serialization error: {0}")]
112 SchemaSerialization(#[from] serde_json::Error),
113}
114
115type SchemaFn = fn() -> Value;
118
119static BUILTIN_SPECS: &[(&str, &str, SchemaFn, &[&str])] = &[
125 (
127 "Text",
128 "Semantic text element (p / h1 / h2 / h3 / span / div / section).",
129 || to_value(schema_for!(TextProps)).unwrap(),
130 &[],
131 ),
132 (
133 "Button",
134 "Interactive button with variant, size, optional icon, and disabled state.",
135 || to_value(schema_for!(ButtonProps)).unwrap(),
136 &[],
137 ),
138 (
139 "Badge",
140 "Small variant-styled label.",
141 || to_value(schema_for!(BadgeProps)).unwrap(),
142 &[],
143 ),
144 (
145 "Alert",
146 "Inline notice with info / success / warning / error variants.",
147 || to_value(schema_for!(AlertProps)).unwrap(),
148 &[],
149 ),
150 (
151 "Separator",
152 "Horizontal or vertical divider between content sections.",
153 || to_value(schema_for!(SeparatorProps)).unwrap(),
154 &[],
155 ),
156 (
157 "Progress",
158 "Progress bar with 0–100 percentage value and optional label.",
159 || to_value(schema_for!(ProgressProps)).unwrap(),
160 &[],
161 ),
162 (
163 "Avatar",
164 "Circular user image with fallback initials and size variants.",
165 || to_value(schema_for!(AvatarProps)).unwrap(),
166 &[],
167 ),
168 (
169 "Image",
170 "Image with optional aspect ratio and skeleton fallback on load error.",
171 || to_value(schema_for!(ImageProps)).unwrap(),
172 &[],
173 ),
174 (
175 "Skeleton",
176 "Loading placeholder with configurable width / height / rounding.",
177 || to_value(schema_for!(SkeletonProps)).unwrap(),
178 &[],
179 ),
180 (
181 "Breadcrumb",
182 "Navigation trail of label + optional URL items.",
183 || to_value(schema_for!(BreadcrumbProps)).unwrap(),
184 &[],
185 ),
186 (
187 "Pagination",
188 "Page navigation for paginated data (current / per_page / total).",
189 || to_value(schema_for!(PaginationProps)).unwrap(),
190 &[],
191 ),
192 (
193 "DescriptionList",
194 "Key-value pairs displayed as a description list with optional format.",
195 || to_value(schema_for!(DescriptionListProps)).unwrap(),
196 &[],
197 ),
198 (
199 "EmptyState",
200 "Standardized empty view with title, description, and optional CTA.",
201 || to_value(schema_for!(EmptyStateProps)).unwrap(),
202 &[],
203 ),
204 (
205 "StatCard",
206 "Live-updatable metric card with label, value, icon, SSE target.",
207 || to_value(schema_for!(StatCardProps)).unwrap(),
208 &[],
209 ),
210 (
211 "Checklist",
212 "Onboarding-style checklist with dismissal and server-side state.",
213 || to_value(schema_for!(ChecklistProps)).unwrap(),
214 &[],
215 ),
216 (
217 "Toast",
218 "Declarative notification intent consumed by the runtime JS via data attributes.",
219 || to_value(schema_for!(ToastProps)).unwrap(),
220 &[],
221 ),
222 (
223 "NotificationDropdown",
224 "Dropdown listing notification items with icons, timestamps, read state.",
225 || to_value(schema_for!(NotificationDropdownProps)).unwrap(),
226 &[],
227 ),
228 (
229 "Sidebar",
230 "Dashboard sidebar with fixed top / bottom items and collapsible nav groups.",
231 || to_value(schema_for!(SidebarProps)).unwrap(),
232 &[],
233 ),
234 (
235 "Header",
236 "Dashboard top bar with business name, notification badge, user menu.",
237 || to_value(schema_for!(HeaderProps)).unwrap(),
238 &[],
239 ),
240 (
241 "DropdownMenu",
242 "Trigger button with an absolutely-positioned kebab-style action panel.",
243 || to_value(schema_for!(DropdownMenuProps)).unwrap(),
244 &[],
245 ),
246 (
247 "CalendarCell",
248 "Single day in a month grid with today highlight, out-of-month muting, event dots.",
249 || to_value(schema_for!(CalendarCellProps)).unwrap(),
250 &[],
251 ),
252 (
253 "ActionCard",
254 "Clickable row with icon, title, description, chevron, and variant-colored border.",
255 || to_value(schema_for!(ActionCardProps)).unwrap(),
256 &[],
257 ),
258 (
259 "ProductTile",
260 "Touch-friendly POS tile with name, price, and +/- quantity controls.",
261 || to_value(schema_for!(ProductTileProps)).unwrap(),
262 &[],
263 ),
264 (
265 "RawHtml",
266 "Server-injected HTML island. CONSUMER is responsible for sanitization — see docs/src/json-ui/plugins.md.",
267 || to_value(schema_for!(RawHtmlProps)).unwrap(),
268 &[],
269 ),
270 (
272 "Card",
273 "Content container with title, description, optional badge and subtitle, body children, and optional footer slot.",
274 || to_value(schema_for!(CardProps)).unwrap(),
275 &["footer"],
276 ),
277 (
278 "Modal",
279 "Dialog overlay with title, description, body children, and optional footer slot.",
280 || to_value(schema_for!(ModalProps)).unwrap(),
281 &["footer"],
282 ),
283 (
284 "Tabs",
285 "Tabbed content; per-tab children live in TabsProps.tabs[i].children.",
286 || to_value(schema_for!(TabsProps)).unwrap(),
287 &[],
288 ),
289 (
290 "KanbanBoard",
291 "Horizontally scrollable kanban columns on desktop, tab-switched on mobile.",
292 || to_value(schema_for!(KanbanBoardProps)).unwrap(),
293 &[],
294 ),
295 (
296 "PageHeader",
297 "Page title with optional breadcrumb and action button slot.",
298 || to_value(schema_for!(PageHeaderProps)).unwrap(),
299 &["actions"],
300 ),
301 (
302 "DetailPage",
303 "Canonical resource-detail skeleton: PageHeader chrome, optional info Card slot, and stacked body sections from Element.children.",
304 || to_value(schema_for!(DetailPageProps)).unwrap(),
305 &["actions", "info"],
306 ),
307 (
308 "Grid",
309 "Responsive multi-column grid with configurable breakpoint columns, gap, scroll.",
310 || to_value(schema_for!(GridProps)).unwrap(),
311 &[],
312 ),
313 (
314 "Collapsible",
315 "Expandable <details> / <summary> section.",
316 || to_value(schema_for!(CollapsibleProps)).unwrap(),
317 &[],
318 ),
319 (
320 "FormSection",
321 "Visual grouping within a form with title, description, and layout variant.",
322 || to_value(schema_for!(FormSectionProps)).unwrap(),
323 &[],
324 ),
325 (
326 "ButtonGroup",
327 "Horizontal button row with configurable gap.",
328 || to_value(schema_for!(ButtonGroupProps)).unwrap(),
329 &[],
330 ),
331 (
333 "Form",
334 "Form container with action binding and field components.",
335 || to_value(schema_for!(FormProps)).unwrap(),
336 &[],
337 ),
338 (
339 "Input",
340 "Text input with type variants, validation error, data_path pre-fill.",
341 || to_value(schema_for!(InputProps)).unwrap(),
342 &[],
343 ),
344 (
345 "Select",
346 "Dropdown select with options, error, data_path pre-fill.",
347 || to_value(schema_for!(SelectProps)).unwrap(),
348 &[],
349 ),
350 (
351 "Checkbox",
352 "Boolean checkbox with label, description, data binding.",
353 || to_value(schema_for!(CheckboxProps)).unwrap(),
354 &[],
355 ),
356 (
357 "Switch",
358 "Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
359 || to_value(schema_for!(SwitchProps)).unwrap(),
360 &[],
361 ),
362 (
363 "CheckboxList",
364 "Multi-select checkbox group from static options or data-driven array. \
365 Each checked option submits as field=value.",
366 || to_value(schema_for!(CheckboxListProps)).unwrap(),
367 &[],
368 ),
369 (
370 "CheckboxGroup",
371 "Multi-select checkbox group (alias for CheckboxList). Each checked option \
372 submits as field=value with array-submit semantics. Identical props to \
373 CheckboxList; see that entry for full schema.",
374 || to_value(schema_for!(CheckboxListProps)).unwrap(),
375 &[],
376 ),
377 (
379 "Table",
380 "Data table with columns, row_actions, sorting, empty_message.",
381 || to_value(schema_for!(TableProps)).unwrap(),
382 &[],
383 ),
384 (
385 "DataTable",
386 "Stripe-style alternating-row table with per-row DropdownMenu and mobile card fallback.",
387 || to_value(schema_for!(DataTableProps)).unwrap(),
388 &[],
389 ),
390 (
391 "MediaCardGrid",
392 "Responsive card grid backed by a data array. Each card shows an optional screenshot image, title, description, status badge, and per-row dropdown actions.",
393 || to_value(schema_for!(MediaCardGridProps)).unwrap(),
394 &[],
395 ),
396];
397
398fn sanitize_schema(mut schema: Value) -> Value {
406 fn walk(v: &mut Value) {
407 if let Some(obj) = v.as_object_mut() {
408 if let Some(defs) = obj.remove("definitions") {
409 obj.entry("$defs".to_string()).or_insert(defs);
410 }
411 if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
412 if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
413 *ref_str = format!("#/$defs/{suffix}");
414 }
415 }
416 let keys: Vec<String> = obj.keys().cloned().collect();
418 for k in keys {
419 if let Some(child) = obj.get_mut(&k) {
420 walk(child);
421 }
422 }
423 } else if let Some(arr) = v.as_array_mut() {
424 for item in arr.iter_mut() {
425 walk(item);
426 }
427 }
428 }
429 walk(&mut schema);
430 schema
431}
432
433fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
442 if let Some(obj) = schema.as_object_mut() {
443 if let Some(Value::Object(defs)) = obj.remove("$defs") {
444 for (k, v) in defs {
445 shared_defs.entry(k).or_insert(v);
446 }
447 }
448 }
449}
450
451fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
463 let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
465 let mut visibility_schema =
466 sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
467
468 let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
470 hoist_defs(&mut action_schema, &mut shared_defs);
471 hoist_defs(&mut visibility_schema, &mut shared_defs);
472
473 let mut names: Vec<&String> = per_component.keys().collect();
477 names.sort();
478 let one_of: Vec<Value> = names
479 .into_iter()
480 .map(|name| {
481 let mut props_schema = per_component[name].clone();
482 hoist_defs(&mut props_schema, &mut shared_defs);
484 serde_json::json!({
485 "allOf": [
486 {
487 "type": "object",
488 "required": ["type"],
489 "properties": {
490 "type": { "const": name }
491 }
492 },
493 {
494 "type": "object",
495 "properties": {
496 "props": props_schema,
497 "children": { "type": "array", "items": { "type": "string" } },
498 "action": { "$ref": "#/$defs/Action" },
499 "visible": { "$ref": "#/$defs/Visibility" }
500 }
501 }
502 ]
503 })
504 })
505 .collect();
506
507 shared_defs
510 .entry("Action".to_string())
511 .or_insert(action_schema);
512 shared_defs
513 .entry("Visibility".to_string())
514 .or_insert(visibility_schema);
515 shared_defs.insert(
517 "Element".to_string(),
518 serde_json::json!({ "oneOf": one_of }),
519 );
520
521 Ok(serde_json::json!({
522 "$schema": "https://json-schema.org/draft/2020-12/schema",
523 "$id": "ferro-json-ui/v2",
524 "type": "object",
525 "required": ["$schema", "root", "elements"],
526 "properties": {
527 "$schema": { "const": "ferro-json-ui/v2" },
528 "root": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
529 "elements": {
530 "type": "object",
531 "additionalProperties": { "$ref": "#/$defs/Element" }
532 },
533 "title": { "type": ["string", "null"] },
534 "layout": { "type": ["string", "null"] },
535 "data": true
536 },
537 "$defs": shared_defs
538 }))
539}
540
541impl Catalog {
544 pub fn build() -> Result<Self, CatalogError> {
555 if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
559 return Err(CatalogError::BuildFailed(format!(
560 "BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
561 add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
562 BUILTIN_SPECS.len(),
563 crate::render::BUILTIN_TYPES.len(),
564 )));
565 }
566
567 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
569 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
570 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
571 let raw = schema_fn();
572 let schema = sanitize_schema(raw);
573 per_component_schemas.insert((*name).to_string(), schema.clone());
574 components.insert(
575 (*name).to_string(),
576 ComponentSpec {
577 name: (*name).to_string(),
578 description: (*desc).to_string(),
579 props_schema: schema,
580 is_plugin: false,
581 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
582 },
583 );
584 }
585
586 let mut plugin_components = HashMap::new();
592 for plugin_type in crate::plugin::registered_plugin_types() {
593 if components.contains_key(&plugin_type) {
595 continue;
596 }
597 let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
598 .unwrap_or(Value::Null);
599 let schema = sanitize_schema(raw);
600 if jsonschema::validator_for(&schema).is_err() {
602 return Err(CatalogError::BuildFailed(format!(
603 "plugin '{plugin_type}' returned an invalid JSON Schema"
604 )));
605 }
606 per_component_schemas.insert(plugin_type.clone(), schema.clone());
607 plugin_components.insert(
608 plugin_type.clone(),
609 ComponentSpec {
610 name: plugin_type,
611 description: String::from("Plugin component."),
612 props_schema: schema,
613 is_plugin: true,
614 slot_fields: Vec::new(),
615 },
616 );
617 }
618
619 let full_schema = assemble_full_schema(&per_component_schemas)?;
621
622 let validator = jsonschema::validator_for(&full_schema)
624 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
625
626 Ok(Catalog {
627 components,
628 plugin_components,
629 full_schema,
630 per_component_schemas,
631 validator,
632 })
633 }
634
635 pub fn json_schema(&self) -> &Value {
642 &self.full_schema
643 }
644
645 pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
665 let mut errors: Vec<CatalogError> = Vec::new();
666
667 for (id, el) in &spec.elements {
669 let known = self.components.contains_key(&el.type_name)
670 || self.plugin_components.contains_key(&el.type_name);
671 if !known {
672 errors.push(CatalogError::UnknownType {
673 element_id: id.clone(),
674 type_name: el.type_name.clone(),
675 });
676 }
677 }
678 if !errors.is_empty() {
683 return Err(errors);
684 }
685
686 for (id, el) in &spec.elements {
688 if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
689 if el.props.is_null() {
694 continue;
695 }
696 let v = match jsonschema::validator_for(schema) {
700 Ok(v) => v,
701 Err(e) => {
702 errors.push(CatalogError::BuildFailed(format!(
703 "compiling per-component schema for '{}': {e}",
704 el.type_name
705 )));
706 continue;
707 }
708 };
709 let validation_props = strip_expr_objects(&el.props);
715 let mut per_elem_errs: Vec<String> = Vec::new();
716 for err in v.iter_errors(&validation_props) {
717 per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
718 }
719 if !per_elem_errs.is_empty() {
720 errors.push(CatalogError::PropsInvalid {
721 element_id: id.clone(),
722 type_name: el.type_name.clone(),
723 errors: per_elem_errs,
724 });
725 }
726 }
727 }
728
729 let spec_value = match serde_json::to_value(spec) {
731 Ok(v) => v,
732 Err(e) => {
733 errors.push(CatalogError::SchemaSerialization(e));
734 return Err(errors);
735 }
736 };
737 let stripped_spec_value = strip_expr_objects(&spec_value);
739 let mut envelope_errs: Vec<String> = Vec::new();
740 for err in self.validator.iter_errors(&stripped_spec_value) {
741 envelope_errs.push(format!("{}: {}", err.instance_path(), err));
742 }
743 if !envelope_errs.is_empty() {
744 errors.push(CatalogError::SpecInvalid {
745 errors: envelope_errs,
746 });
747 }
748
749 if errors.is_empty() {
750 Ok(())
751 } else {
752 Err(errors)
753 }
754 }
755
756 pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
770 self.per_component_schemas.get(type_name)
771 }
772
773 pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
780 let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
781 entries.sort_by(|a, b| a.name.cmp(&b.name));
782 entries.into_iter()
783 }
784
785 pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
791 let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
792 entries.sort_by(|a, b| a.name.cmp(&b.name));
793 entries.into_iter()
794 }
795
796 pub fn prompt(&self) -> String {
811 let mut out = String::with_capacity(8 * 1024);
812 out.push_str("## Component Catalog\n\n");
813 for spec in self.components_sorted() {
814 render_component_section(&mut out, spec);
815 }
816 if self.plugin_components.is_empty() {
817 return out;
818 }
819 out.push_str("## Plugin Components\n\n");
820 for spec in self.plugin_components_sorted() {
821 render_component_section(&mut out, spec);
822 }
823 out
824 }
825}
826
827fn render_component_section(out: &mut String, spec: &ComponentSpec) {
840 out.push_str("### ");
841 out.push_str(&spec.name);
842 out.push('\n');
843 out.push_str(&spec.description);
844 out.push('\n');
845
846 let props_line = render_props_line(&spec.props_schema);
847 if !props_line.is_empty() {
848 out.push_str("Props: ");
849 out.push_str(&props_line);
850 out.push('\n');
851 }
852 if !spec.slot_fields.is_empty() {
853 out.push_str("Slots: ");
854 out.push_str(&spec.slot_fields.join(", "));
855 out.push_str(" (Vec<String> of element IDs) — body children come from Element.children.\n");
856 }
857 out.push('\n');
858}
859
860fn render_props_line(schema: &Value) -> String {
871 let Some(obj) = schema.as_object() else {
872 return String::new();
873 };
874 let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
875 return String::new();
876 };
877 let required: std::collections::HashSet<&str> = obj
878 .get("required")
879 .and_then(|v| v.as_array())
880 .map(|arr| {
881 arr.iter()
882 .filter_map(|v| v.as_str())
883 .collect::<std::collections::HashSet<_>>()
884 })
885 .unwrap_or_default();
886
887 let parts: Vec<String> = props
888 .iter()
889 .map(|(name, field_schema)| {
890 let ty = render_field_type(field_schema, required.contains(name.as_str()));
891 format!("{name} ({ty})")
892 })
893 .collect();
894 parts.join(", ")
895}
896
897fn render_field_type(schema: &Value, is_required: bool) -> String {
899 if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
901 let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
902 let inner = render_enum_inline(&names);
903 return wrap_optional(inner, is_required);
904 }
905 for key in ["anyOf", "oneOf"] {
907 if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
908 let has_null = arr
909 .iter()
910 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
911 let non_null: Vec<&Value> = arr
912 .iter()
913 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
914 .collect();
915 if has_null && non_null.len() == 1 {
916 let inner = render_field_type(non_null[0], true);
917 return format!("Option<{inner}>");
918 }
919 }
920 }
921 if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
923 let non_null: Vec<&str> = types
924 .iter()
925 .filter_map(|v| v.as_str())
926 .filter(|s| *s != "null")
927 .collect();
928 let has_null = types.iter().any(|v| v.as_str() == Some("null"));
929 if has_null && non_null.len() == 1 {
930 return format!("Option<{}>", rust_for_json_type(non_null[0], schema));
931 }
932 }
933 if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
935 let inner = rust_for_json_type(t, schema);
936 return wrap_optional(inner, is_required);
937 }
938 wrap_optional("<see schema>".to_string(), is_required)
940}
941
942fn rust_for_json_type(t: &str, schema: &Value) -> String {
944 match t {
945 "string" => "String".to_string(),
946 "integer" => "i64".to_string(),
947 "number" => "f64".to_string(),
948 "boolean" => "bool".to_string(),
949 "array" => {
950 if let Some(items) = schema.get("items") {
951 let inner = render_field_type(items, true);
952 format!("Vec<{inner}>")
953 } else {
954 "Vec<Value>".to_string()
955 }
956 }
957 "object" => "Object".to_string(),
958 other => other.to_string(),
959 }
960}
961
962fn render_enum_inline(variants: &[&str]) -> String {
964 if variants.len() <= 8 {
965 variants.join("|")
966 } else {
967 format!("one of {} — see schema", variants.len())
968 }
969}
970
971fn wrap_optional(inner: String, is_required: bool) -> String {
973 if is_required {
974 inner
975 } else {
976 format!("Option<{inner}>")
977 }
978}
979
980fn strip_expr_objects(val: &Value) -> Value {
989 match val {
990 Value::Object(map) => {
991 if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
992 Value::String(String::new())
993 } else {
994 Value::Object(
995 map.iter()
996 .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
997 .collect(),
998 )
999 }
1000 }
1001 Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1002 other => other.clone(),
1003 }
1004}
1005
1006pub fn global_catalog() -> &'static Catalog {
1019 static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1020 GLOBAL_CATALOG.get_or_init(|| {
1021 Catalog::build().expect("catalog build failed — see CatalogError for details")
1022 })
1023}
1024
1025#[cfg(test)]
1028impl Catalog {
1029 pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1034 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1035 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1036 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1037 let raw = schema_fn();
1038 let schema = sanitize_schema(raw);
1039 per_component_schemas.insert((*name).to_string(), schema.clone());
1040 components.insert(
1041 (*name).to_string(),
1042 ComponentSpec {
1043 name: (*name).to_string(),
1044 description: (*desc).to_string(),
1045 props_schema: schema,
1046 is_plugin: false,
1047 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1048 },
1049 );
1050 }
1051 let full_schema = assemble_full_schema(&per_component_schemas)?;
1052 let validator = jsonschema::validator_for(&full_schema)
1053 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1054 Ok(Catalog {
1055 components,
1056 plugin_components: HashMap::new(),
1057 full_schema,
1058 per_component_schemas,
1059 validator,
1060 })
1061 }
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use super::*;
1067
1068 #[test]
1069 fn builtin_types_count_is_39() {
1070 assert_eq!(crate::render::BUILTIN_TYPES.len(), 44);
1077 }
1078
1079 #[test]
1080 fn builtin_specs_len_matches_dispatch() {
1081 assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1082 assert_eq!(BUILTIN_SPECS.len(), 44);
1083 }
1084
1085 #[test]
1086 fn builtin_specs_names_match_dispatch() {
1087 use std::collections::HashSet;
1088 let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1089 let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1090 assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1091 }
1092
1093 #[test]
1094 fn build_populates_all_builtins() {
1095 let cat = Catalog::build_builtins_only().expect("build succeeds");
1097 for name in crate::render::BUILTIN_TYPES.iter() {
1098 assert!(
1099 cat.components.contains_key(*name),
1100 "built-in '{name}' missing from catalog.components"
1101 );
1102 let spec = &cat.components[*name];
1103 assert_eq!(spec.name, *name);
1104 assert!(
1105 !spec.description.is_empty(),
1106 "'{name}' has empty description"
1107 );
1108 assert!(
1109 spec.props_schema.is_object(),
1110 "'{name}' props_schema is not a JSON object"
1111 );
1112 assert!(!spec.is_plugin);
1113 }
1114 }
1115
1116 #[test]
1117 fn build_card_has_footer_slot() {
1118 let cat = Catalog::build_builtins_only().expect("build succeeds");
1120 let card = &cat.components["Card"];
1121 assert_eq!(card.slot_fields, vec!["footer"]);
1122 }
1123
1124 #[test]
1125 fn build_modal_has_footer_slot() {
1126 let cat = Catalog::build_builtins_only().expect("build succeeds");
1128 let modal = &cat.components["Modal"];
1129 assert_eq!(modal.slot_fields, vec!["footer"]);
1130 }
1131
1132 #[test]
1133 fn build_pageheader_has_actions_slot() {
1134 let cat = Catalog::build_builtins_only().expect("build succeeds");
1136 let ph = &cat.components["PageHeader"];
1137 assert_eq!(ph.slot_fields, vec!["actions"]);
1138 }
1139
1140 #[test]
1141 fn build_text_has_no_slots() {
1142 let cat = Catalog::build_builtins_only().expect("build succeeds");
1144 assert!(cat.components["Text"].slot_fields.is_empty());
1145 }
1146
1147 #[test]
1148 fn build_populates_per_component_schemas() {
1149 let cat = Catalog::build_builtins_only().expect("build succeeds");
1151 assert_eq!(
1152 cat.per_component_schemas.len(),
1153 BUILTIN_SPECS.len() + cat.plugin_components.len()
1154 );
1155 }
1156
1157 #[test]
1158 fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1159 let raw = serde_json::json!({
1160 "type": "object",
1161 "definitions": { "Foo": { "type": "string" } },
1162 "properties": {
1163 "x": { "$ref": "#/definitions/Foo" }
1164 }
1165 });
1166 let out = sanitize_schema(raw);
1167 assert!(out.get("definitions").is_none());
1168 assert!(out.get("$defs").is_some());
1169 assert_eq!(
1170 out["properties"]["x"]["$ref"].as_str().unwrap(),
1171 "#/$defs/Foo"
1172 );
1173 }
1174
1175 #[test]
1176 fn sanitize_schema_is_idempotent() {
1177 let raw = serde_json::json!({
1178 "type": "object",
1179 "$defs": { "Foo": { "type": "string" } },
1180 "properties": {
1181 "x": { "$ref": "#/$defs/Foo" }
1182 }
1183 });
1184 let once = sanitize_schema(raw.clone());
1185 let twice = sanitize_schema(once.clone());
1186 assert_eq!(once, twice);
1187 assert!(twice.get("definitions").is_none());
1189 assert!(twice.get("$defs").is_some());
1190 }
1191
1192 #[test]
1193 fn json_schema_has_spec_envelope_shape() {
1194 let cat = Catalog::build_builtins_only().expect("build");
1197 let schema = cat.json_schema();
1198 assert_eq!(schema["$id"], "ferro-json-ui/v2");
1199 assert_eq!(schema["type"], "object");
1200 let required: Vec<&str> = schema["required"]
1201 .as_array()
1202 .unwrap()
1203 .iter()
1204 .map(|v| v.as_str().unwrap())
1205 .collect();
1206 assert!(required.contains(&"$schema"));
1207 assert!(required.contains(&"root"));
1208 assert!(required.contains(&"elements"));
1209 }
1210
1211 #[test]
1212 fn json_schema_has_action_and_visibility_defs() {
1213 let cat = Catalog::build_builtins_only().expect("build");
1214 let schema = cat.json_schema();
1215 assert!(
1216 schema["$defs"]["Action"].is_object(),
1217 "$defs/Action missing"
1218 );
1219 assert!(
1220 schema["$defs"]["Visibility"].is_object(),
1221 "$defs/Visibility missing"
1222 );
1223 assert!(
1224 schema["$defs"]["Element"].is_object(),
1225 "$defs/Element missing"
1226 );
1227 }
1228
1229 #[test]
1230 fn json_schema_oneof_covers_all_builtins() {
1231 let cat = Catalog::build_builtins_only().expect("build");
1232 let schema = cat.json_schema();
1233 let one_of = schema["$defs"]["Element"]["oneOf"]
1235 .as_array()
1236 .expect("Element.oneOf is an array");
1237
1238 let mut discriminators: std::collections::HashSet<String> =
1240 std::collections::HashSet::new();
1241 for variant in one_of {
1242 let c = variant["allOf"][0]["properties"]["type"]["const"]
1243 .as_str()
1244 .expect("every variant pins a type const");
1245 discriminators.insert(c.to_string());
1246 }
1247
1248 for name in crate::render::BUILTIN_TYPES.iter() {
1249 assert!(
1250 discriminators.contains(*name),
1251 "oneOf is missing discriminator for '{name}'"
1252 );
1253 }
1254
1255 assert_eq!(
1257 discriminators.len(),
1258 crate::render::BUILTIN_TYPES.len(),
1259 "oneOf variant count mismatch"
1260 );
1261 }
1262
1263 #[test]
1264 fn json_schema_is_valid() {
1265 use jsonschema::draft202012;
1266 let cat = Catalog::build_builtins_only().expect("build");
1267 let schema = cat.json_schema();
1268 assert!(
1269 draft202012::meta::is_valid(schema),
1270 "assembled full_schema did not meta-validate as Draft 2020-12"
1271 );
1272 }
1273
1274 #[test]
1275 fn validator_is_compiled_once_and_usable() {
1276 let cat = Catalog::build_builtins_only().expect("build");
1277 let minimal_valid = serde_json::json!({
1281 "$schema": "ferro-json-ui/v2",
1282 "root": "r",
1283 "elements": {
1284 "r": { "type": "Text", "props": { "content": "hi" } }
1285 }
1286 });
1287 assert!(cat.validator.is_valid(&minimal_valid));
1289 }
1290
1291 #[test]
1292 fn validator_rejects_wrong_schema_version() {
1293 let cat = Catalog::build_builtins_only().expect("build");
1294 let wrong_version = serde_json::json!({
1295 "$schema": "ferro-json-ui/v99-wrong",
1296 "root": "r",
1297 "elements": {
1298 "r": { "type": "Text", "props": { "content": "hi" } }
1299 }
1300 });
1301 assert!(
1302 !cat.validator.is_valid(&wrong_version),
1303 "validator should reject unknown $schema version via const"
1304 );
1305 }
1306
1307 #[test]
1308 fn oneof_variants_are_deterministic_sorted() {
1309 let cat1 = Catalog::build_builtins_only().expect("build 1");
1310 let cat2 = Catalog::build_builtins_only().expect("build 2");
1311 assert_eq!(
1313 serde_json::to_string(cat1.json_schema()).unwrap(),
1314 serde_json::to_string(cat2.json_schema()).unwrap()
1315 );
1316 }
1317
1318 fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1322 use crate::spec::{Element, Spec};
1323 use std::collections::HashMap;
1324 let mut elements = HashMap::new();
1325 elements.insert(
1326 "r".to_string(),
1327 Element {
1328 type_name: type_name.to_string(),
1329 props,
1330 children: Vec::new(),
1331 action: None,
1332 visible: None,
1333 each: None,
1334 if_: None,
1335 },
1336 );
1337 Spec {
1338 schema: crate::spec::SCHEMA_VERSION.to_string(),
1339 root: "r".to_string(),
1340 elements,
1341 title: None,
1342 layout: None,
1343 data: Value::Null,
1344 }
1345 }
1346
1347 #[test]
1348 fn validate_positive_per_type() {
1349 let cat = Catalog::build_builtins_only().expect("build");
1352 let cases: Vec<(&str, Value)> = vec![
1353 ("Text", serde_json::json!({ "content": "hi" })),
1354 ("Button", serde_json::json!({ "label": "Save" })),
1355 ("Badge", serde_json::json!({ "label": "New" })),
1356 ("Separator", serde_json::json!({})),
1357 ];
1358 for (ty, props) in cases {
1359 let spec = test_spec_with(ty, props.clone());
1360 match cat.validate(&spec) {
1361 Ok(()) => {}
1362 Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1363 }
1364 }
1365 }
1366
1367 #[test]
1368 fn validate_unknown_type() {
1369 let cat = Catalog::build_builtins_only().expect("build");
1370 let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1371 let errs = cat.validate(&spec).expect_err("should fail");
1372 assert!(
1373 errs.iter().any(|e| matches!(
1374 e,
1375 CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1376 )),
1377 "expected UnknownType for NotARealComponent; got {errs:?}"
1378 );
1379 }
1380
1381 #[test]
1382 fn validate_missing_required_prop() {
1383 let cat = Catalog::build_builtins_only().expect("build");
1386 let spec = test_spec_with("Card", serde_json::json!({}));
1387 let errs = cat.validate(&spec).expect_err("should fail");
1388 assert!(
1389 errs.iter().any(|e| matches!(
1390 e,
1391 CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1392 )),
1393 "expected PropsInvalid for missing required 'title'; got {errs:?}"
1394 );
1395 }
1396
1397 #[test]
1398 fn validate_bad_schema_version() {
1399 let cat = Catalog::build_builtins_only().expect("build");
1400 let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1401 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1402 let errs = cat.validate(&spec).expect_err("should fail");
1403 assert!(
1404 errs.iter()
1405 .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1406 "expected SpecInvalid for wrong $schema version; got {errs:?}"
1407 );
1408 }
1409
1410 #[test]
1411 fn validate_pre_dispatch_short_circuits() {
1412 let cat = Catalog::build_builtins_only().expect("build");
1415 let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1416 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1417 let errs = cat.validate(&spec).expect_err("should fail");
1418
1419 let has_unknown = errs
1420 .iter()
1421 .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1422 let has_spec_invalid = errs
1423 .iter()
1424 .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1425 let has_props_invalid = errs
1426 .iter()
1427 .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1428
1429 assert!(has_unknown, "expected UnknownType");
1430 assert!(
1431 !has_spec_invalid,
1432 "Stage 3 ran despite Stage 1 failing: {errs:?}"
1433 );
1434 assert!(
1435 !has_props_invalid,
1436 "Stage 2 ran despite Stage 1 failing: {errs:?}"
1437 );
1438 }
1439
1440 #[test]
1441 fn validator_is_cached_not_recompiled() {
1442 let cat = Catalog::build_builtins_only().expect("build");
1446 for _ in 0..100 {
1447 let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1448 assert!(cat.validate(&spec).is_ok());
1449 }
1450 }
1451
1452 #[test]
1453 fn validate_accumulates_multiple_errors_across_elements() {
1454 use crate::spec::{Element, Spec};
1456 use std::collections::HashMap;
1457 let cat = Catalog::build_builtins_only().expect("build");
1458 let mut elements = HashMap::new();
1459 elements.insert(
1460 "a".to_string(),
1461 Element {
1462 type_name: "Card".to_string(),
1463 props: serde_json::json!({}), children: Vec::new(),
1465 action: None,
1466 visible: None,
1467 each: None,
1468 if_: None,
1469 },
1470 );
1471 elements.insert(
1472 "b".to_string(),
1473 Element {
1474 type_name: "Button".to_string(),
1475 props: serde_json::json!({}), children: Vec::new(),
1477 action: None,
1478 visible: None,
1479 each: None,
1480 if_: None,
1481 },
1482 );
1483 let spec = Spec {
1484 schema: crate::spec::SCHEMA_VERSION.to_string(),
1485 root: "a".to_string(),
1486 elements,
1487 title: None,
1488 layout: None,
1489 data: Value::Null,
1490 };
1491 let errs = cat.validate(&spec).expect_err("should fail");
1492 let props_invalid_count = errs
1493 .iter()
1494 .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
1495 .count();
1496 assert!(
1497 props_invalid_count >= 2,
1498 "expected at least 2 PropsInvalid errors; got {errs:?}"
1499 );
1500 }
1501
1502 #[test]
1508 fn build_discovers_plugins_and_rejects_invalid_schema() {
1509 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
1510
1511 struct GoodPlugin;
1512 impl JsonUiPlugin for GoodPlugin {
1513 fn component_type(&self) -> &str {
1514 "GoodPlugin_117"
1515 }
1516 fn props_schema(&self) -> Value {
1517 serde_json::json!({ "type": "object" })
1518 }
1519 fn render(&self, _: &Value, _: &Value) -> String {
1520 String::new()
1521 }
1522 fn css_assets(&self) -> Vec<Asset> {
1523 vec![]
1524 }
1525 fn js_assets(&self) -> Vec<Asset> {
1526 vec![]
1527 }
1528 fn init_script(&self) -> Option<String> {
1529 None
1530 }
1531 }
1532
1533 register_plugin(GoodPlugin);
1534
1535 let cat = Catalog::build().expect("build succeeds with valid plugin only");
1537 assert!(
1538 cat.plugin_components.contains_key("GoodPlugin_117"),
1539 "plugin 'GoodPlugin_117' should have been discovered"
1540 );
1541 assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
1542
1543 struct BadPlugin;
1545 impl JsonUiPlugin for BadPlugin {
1546 fn component_type(&self) -> &str {
1547 "BadPlugin_117"
1548 }
1549 fn props_schema(&self) -> Value {
1550 serde_json::json!({ "type": 42 })
1553 }
1554 fn render(&self, _: &Value, _: &Value) -> String {
1555 String::new()
1556 }
1557 fn css_assets(&self) -> Vec<Asset> {
1558 vec![]
1559 }
1560 fn js_assets(&self) -> Vec<Asset> {
1561 vec![]
1562 }
1563 fn init_script(&self) -> Option<String> {
1564 None
1565 }
1566 }
1567
1568 register_plugin(BadPlugin);
1569 match Catalog::build() {
1570 Err(CatalogError::BuildFailed(msg)) => {
1571 assert!(
1572 msg.contains("BadPlugin_117"),
1573 "error should mention plugin name, got: {msg}"
1574 );
1575 }
1576 Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
1577 Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
1578 }
1579 }
1580
1581 #[test]
1584 fn component_schema_returns_props_only() {
1585 let cat = Catalog::build_builtins_only().expect("build");
1589 let schema = cat
1590 .component_schema("Card")
1591 .expect("Card is a built-in component");
1592
1593 let obj = schema
1597 .as_object()
1598 .expect("Card props schema is a JSON object");
1599
1600 assert!(
1602 obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
1603 "CardProps schema should be a structural object schema; got {obj:?}"
1604 );
1605
1606 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1608 assert!(
1609 props.contains_key("title"),
1610 "CardProps schema.properties should include 'title'; got keys: {:?}",
1611 props.keys().collect::<Vec<_>>()
1612 );
1613 } else {
1614 panic!(
1615 "CardProps schema missing top-level 'properties' map — \
1616 sanitizer or Plan 02 may be wrong. Got: {}",
1617 serde_json::to_string_pretty(schema).unwrap_or_default()
1618 );
1619 }
1620
1621 let is_element_wrapper = obj
1624 .get("properties")
1625 .and_then(|v| v.as_object())
1626 .map(|p| p.contains_key("children") && p.contains_key("props"))
1627 .unwrap_or(false);
1628 assert!(
1629 !is_element_wrapper,
1630 "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
1631 );
1632 }
1633
1634 #[test]
1635 fn component_schema_none_for_unknown() {
1636 let cat = Catalog::build_builtins_only().expect("build");
1637 assert!(
1638 cat.component_schema("NotARealComponent_117_05").is_none(),
1639 "unknown component must return None"
1640 );
1641 assert!(cat.component_schema("").is_none());
1643 }
1644
1645 #[test]
1646 fn component_schema_resolves_every_builtin() {
1647 let cat = Catalog::build_builtins_only().expect("build");
1651 for name in crate::render::BUILTIN_TYPES.iter() {
1652 assert!(
1653 cat.component_schema(name).is_some(),
1654 "built-in '{name}' has no per-component schema"
1655 );
1656 }
1657 }
1658
1659 #[test]
1660 fn components_sorted_yields_ascending_by_name() {
1661 let cat = Catalog::build_builtins_only().expect("build");
1662 let names: Vec<String> = cat
1663 .components_sorted()
1664 .map(|spec| spec.name.clone())
1665 .collect();
1666 assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
1667 let mut sorted = names.clone();
1668 sorted.sort();
1669 assert_eq!(
1670 names, sorted,
1671 "components_sorted must yield ascending order"
1672 );
1673
1674 let plugin_names: Vec<String> = cat
1676 .plugin_components_sorted()
1677 .map(|spec| spec.name.clone())
1678 .collect();
1679 let mut plugin_sorted = plugin_names.clone();
1680 plugin_sorted.sort();
1681 assert_eq!(
1682 plugin_names, plugin_sorted,
1683 "plugin_components_sorted must yield ascending order"
1684 );
1685 }
1686
1687 #[test]
1690 fn prompt_under_size_budget() {
1691 let cat = Catalog::build_builtins_only().expect("build");
1692 let prompt = cat.prompt();
1693 let bytes = prompt.len();
1694 assert!(
1697 bytes <= 10 * 1024,
1698 "prompt() is {bytes} bytes, exceeds 10 KB budget (CONTEXT D-17)"
1699 );
1700 }
1701
1702 #[test]
1703 fn prompt_mentions_every_builtin() {
1704 let cat = Catalog::build_builtins_only().expect("build");
1705 let prompt = cat.prompt();
1706 for name in crate::render::BUILTIN_TYPES.iter() {
1707 let heading = format!("### {name}\n");
1708 assert!(
1709 prompt.contains(&heading),
1710 "prompt() missing section heading for '{name}'"
1711 );
1712 }
1713 }
1714
1715 #[test]
1716 fn prompt_is_deterministic() {
1717 let cat1 = Catalog::build_builtins_only().expect("build 1");
1718 let cat2 = Catalog::build_builtins_only().expect("build 2");
1719 assert_eq!(
1720 cat1.prompt(),
1721 cat2.prompt(),
1722 "prompt() must be deterministic"
1723 );
1724 }
1725
1726 #[test]
1727 fn prompt_documents_slot_fields() {
1728 let cat = Catalog::build_builtins_only().expect("build");
1731 let prompt = cat.prompt();
1732 let card_start = prompt.find("### Card\n").expect("Card section present");
1733 let card_slice = &prompt[card_start..];
1734 let end = card_slice[3..]
1736 .find("### ")
1737 .map(|i| i + 3)
1738 .unwrap_or(card_slice.len());
1739 let card_section = &card_slice[..end];
1740 assert!(
1741 card_section.contains("Slots: footer"),
1742 "Card section missing 'Slots: footer' line:\n{card_section}"
1743 );
1744 }
1745
1746 #[test]
1747 fn prompt_is_not_raw_json_schema() {
1748 let cat = Catalog::build_builtins_only().expect("build");
1749 let prompt = cat.prompt();
1750 assert!(
1751 prompt.starts_with("## Component Catalog"),
1752 "prompt() should start with Markdown header, not JSON"
1753 );
1754 assert!(
1755 !prompt.contains("\"$schema\""),
1756 "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
1757 );
1758 }
1759
1760 #[test]
1761 fn catalog_contains_checkbox_group() {
1762 let cat = Catalog::build_builtins_only().expect("build");
1763 assert!(
1764 cat.component_schema("CheckboxGroup").is_some(),
1765 "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
1766 );
1767 }
1768}