1use serde_json::Value;
19use std::collections::HashSet;
20
21use crate::plugin::{collect_plugin_assets, with_plugin, Asset};
22use crate::spec::{Spec, MAX_NESTING_DEPTH};
23
24pub(crate) mod atoms;
25pub mod classes;
26pub(crate) mod containers;
27pub(crate) mod data;
28pub(crate) mod form;
29
30pub struct RenderResult {
32 pub html: String,
33 pub css_head: String,
34 pub scripts: String,
35}
36
37pub(crate) const BUILTIN_TYPES: &[&str] = &[
45 "Text",
47 "Button",
48 "Badge",
49 "Alert",
50 "Separator",
51 "Progress",
52 "Avatar",
53 "Image",
54 "Skeleton",
55 "Breadcrumb",
56 "Pagination",
57 "DescriptionList",
58 "EmptyState",
59 "StatCard",
60 "Checklist",
61 "Toast",
62 "NotificationDropdown",
63 "Sidebar",
64 "Header",
65 "CalendarCell",
66 "ActionCard",
67 "Tile",
68 "FilterTabs",
69 "RawHtml",
70 "StreamText",
71 "QuantityStepper",
72 "Numpad",
73 "Card",
75 "Modal",
76 "Tabs",
77 "KanbanBoard",
78 "PageHeader",
79 "DetailPage",
80 "Grid",
81 "TileGrid",
82 "Collapsible",
83 "FormSection",
84 "ButtonGroup",
85 "SegmentedControl",
86 "SidebarLayout",
87 "ActionGroup",
88 "SelectionPanel",
89 "LiveFragment",
91 "Form",
93 "Input",
94 "Select",
95 "Checkbox",
96 "Switch",
97 "CheckboxList",
98 "CheckboxGroup",
99 "Table",
101 "DataTable",
102 "MediaCardGrid",
103];
104
105pub fn render_spec_to_html(spec: &Spec, data: &Value) -> String {
112 let body = render_element(&spec.root, spec, data, 1);
113 let body_or_root_hidden = if body.is_empty() && spec_root_was_hidden(spec, data) {
114 String::from("<!-- ferro-json-ui: root hidden -->")
115 } else {
116 body
117 };
118 format!(
119 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">{body_or_root_hidden}</div>"
120 )
121}
122
123pub fn render_spec_to_html_with_plugins(spec: &Spec, data: &Value) -> RenderResult {
128 let html = render_spec_to_html(spec, data);
129 let builtin_scripts = collect_builtin_init_scripts(spec);
130 let plugin_types = collect_plugin_types(spec);
131 if plugin_types.is_empty() && builtin_scripts.is_empty() {
132 return RenderResult {
133 html,
134 css_head: String::new(),
135 scripts: String::new(),
136 };
137 }
138 let type_names: Vec<String> = plugin_types.into_iter().collect();
139 let assets = collect_plugin_assets(&type_names);
140 let all_init_scripts: Vec<String> = assets
141 .init_scripts
142 .iter()
143 .chain(builtin_scripts.iter())
144 .cloned()
145 .collect();
146 RenderResult {
147 html,
148 css_head: render_css_tags(&assets.css),
149 scripts: render_js_tags(&assets.js, &all_init_scripts),
150 }
151}
152
153pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
157 if depth > MAX_NESTING_DEPTH + 1 {
163 return format!(
164 "<!-- ferro-json-ui: depth limit exceeded at depth {depth} (max={MAX_NESTING_DEPTH}) — spec should have been rejected at parse time -->"
165 );
166 }
167
168 let Some(el) = spec.elements.get(id) else {
170 return format!(
171 "<!-- ferro-json-ui: element references missing id '{}' -->",
172 html_escape(id)
173 );
174 };
175
176 if let Some(vis) = &el.visible {
178 if !vis.evaluate(data) {
179 return String::new();
180 }
181 }
182
183 match el.type_name.as_str() {
185 "Text" => atoms::render_text(el, spec, data, depth),
187 "Button" => atoms::render_button(el, spec, data, depth),
188 "Badge" => atoms::render_badge(el, spec, data, depth),
189 "Alert" => atoms::render_alert(el, spec, data, depth),
190 "Separator" => atoms::render_separator(el, spec, data, depth),
191 "Progress" => atoms::render_progress(el, spec, data, depth),
192 "Avatar" => atoms::render_avatar(el, spec, data, depth),
193 "Image" => atoms::render_image(el, spec, data, depth),
194 "Skeleton" => atoms::render_skeleton(el, spec, data, depth),
195 "Breadcrumb" => atoms::render_breadcrumb(el, spec, data, depth),
196 "Pagination" => atoms::render_pagination(el, spec, data, depth),
197 "DescriptionList" => atoms::render_description_list(el, spec, data, depth),
198 "EmptyState" => atoms::render_empty_state(el, spec, data, depth),
199 "StatCard" => atoms::render_stat_card(el, spec, data, depth),
200 "Checklist" => atoms::render_checklist(el, spec, data, depth),
201 "Toast" => atoms::render_toast(el, spec, data, depth),
202 "NotificationDropdown" => atoms::render_notification_dropdown(el, spec, data, depth),
203 "Sidebar" => atoms::render_sidebar(el, spec, data, depth),
204 "Header" => atoms::render_header(el, spec, data, depth),
205 "CalendarCell" => atoms::render_calendar_cell(el, spec, data, depth),
206 "ActionCard" => atoms::render_action_card(el, spec, data, depth),
207 "Tile" => atoms::render_tile(el, spec, data, depth),
208 "FilterTabs" => atoms::render_filter_tabs(el, spec, data, depth),
209 "RawHtml" => atoms::render_raw_html(el, spec, data, depth),
210 "StreamText" => atoms::render_streamtext(el, spec, data, depth),
211 "QuantityStepper" => atoms::render_quantity_stepper(el, spec, data, depth),
212 "Numpad" => atoms::render_numpad(el, spec, data, depth),
213 "Card" => containers::render_card(el, spec, data, depth),
215 "Modal" => containers::render_modal(el, spec, data, depth),
216 "Tabs" => containers::render_tabs(el, spec, data, depth),
217 "KanbanBoard" => containers::render_kanban_board(el, spec, data, depth),
218 "PageHeader" => containers::render_page_header(el, spec, data, depth),
219 "DetailPage" => containers::render_detail_page(el, spec, data, depth),
220 "Grid" => containers::render_grid(el, spec, data, depth),
221 "TileGrid" => containers::render_tile_grid(el, spec, data, depth),
222 "Collapsible" => containers::render_collapsible(el, spec, data, depth),
223 "FormSection" => containers::render_form_section(el, spec, data, depth),
224 "ButtonGroup" => containers::render_button_group(el, spec, data, depth),
225 "SegmentedControl" => containers::render_segmented_control(el, spec, data, depth),
226 "SidebarLayout" => containers::render_sidebar_layout(el, spec, data, depth),
227 "ActionGroup" => containers::render_action_group(el, spec, data, depth),
228 "SelectionPanel" => containers::render_selection_panel(el, spec, data, depth),
229 "LiveFragment" => containers::render_live_fragment(el, spec, data, depth),
230 "Form" => form::render_form(el, spec, data, depth),
232 "Input" => form::render_input(el, spec, data, depth),
233 "Select" => form::render_select(el, spec, data, depth),
234 "Checkbox" => form::render_checkbox(el, spec, data, depth),
235 "Switch" => form::render_switch(el, spec, data, depth),
236 "CheckboxList" => form::render_checkbox_list(el, spec, data, depth),
237 "CheckboxGroup" => form::render_checkbox_list(el, spec, data, depth),
238 "Table" => data::render_table(el, spec, data, depth),
240 "DataTable" => data::render_data_table(el, spec, data, depth),
241 "MediaCardGrid" => data::render_media_card_grid(el, spec, data, depth),
242 other => render_plugin_or_unknown(other, el, data),
244 }
245}
246
247fn render_plugin_or_unknown(type_name: &str, el: &crate::spec::Element, data: &Value) -> String {
248 match with_plugin(type_name, |p| p.render(&el.props, data)) {
249 Some(html) => html,
250 None => format!(
251 "<!-- ferro-json-ui: unknown component type '{}' -->",
252 html_escape(type_name)
253 ),
254 }
255}
256
257fn spec_root_was_hidden(spec: &Spec, data: &Value) -> bool {
261 spec.elements
262 .get(&spec.root)
263 .and_then(|el| el.visible.as_ref())
264 .map(|vis| !vis.evaluate(data))
265 .unwrap_or(false)
266}
267
268pub fn render_subtree(target_id: &str, spec: &Spec, data: &Value) -> String {
277 if target_id == "ferro-json-ui" {
278 let content = render_element(&spec.root, spec, data, 1);
279 format!(r#"<div id="ferro-json-ui">{content}</div>"#)
280 } else if spec.elements.contains_key(target_id) {
281 render_element(target_id, spec, data, 1)
282 } else {
283 format!(
284 "<!-- ferro-json-ui: render_subtree: target '{}' not found -->",
285 html_escape(target_id)
286 )
287 }
288}
289
290pub(crate) fn collect_plugin_types(spec: &Spec) -> HashSet<String> {
294 let mut types = HashSet::new();
295 for el in spec.elements.values() {
296 if !BUILTIN_TYPES.contains(&el.type_name.as_str()) {
297 types.insert(el.type_name.clone());
298 }
299 }
300 types
301}
302
303const FERRO_STREAM_TEXT_INIT: &str = r#"(function(){
309 document.querySelectorAll('[data-ferro-stream-url]').forEach(function(el){
310 var url = el.dataset.ferroStreamUrl;
311 if(!url) return;
312 var src = new EventSource(url);
313 var placeholder = el.querySelector('[data-ferro-stream-placeholder]');
314 var loading = el.querySelector('[data-ferro-stream-loading]');
315 var firstToken = true;
316 src.onmessage = function(e){
317 if(firstToken){ firstToken=false; if(placeholder) placeholder.remove(); }
318 el.appendChild(document.createTextNode(e.data));
319 };
320 src.addEventListener('done', function(){
321 src.close();
322 if(placeholder) placeholder.remove();
323 if(loading) loading.remove();
324 });
325 src.onerror = function(){
326 src.close();
327 if(loading) loading.remove();
328 };
329 });
330})();"#;
331
332fn collect_builtin_init_scripts(spec: &Spec) -> Vec<String> {
338 let has_stream_text = spec
339 .elements
340 .values()
341 .any(|el| el.type_name == "StreamText");
342 if has_stream_text {
343 vec![FERRO_STREAM_TEXT_INIT.to_string()]
344 } else {
345 vec![]
346 }
347}
348
349pub(crate) fn html_escape(s: &str) -> String {
354 s.replace('&', "&")
355 .replace('<', "<")
356 .replace('>', ">")
357 .replace('"', """)
358 .replace('\'', "'")
359}
360
361pub(crate) fn render_css_tags(assets: &[Asset]) -> String {
366 let mut out = String::new();
367 for asset in assets {
368 out.push_str("<link rel=\"stylesheet\" href=\"");
369 out.push_str(&html_escape(&asset.url));
370 out.push('"');
371 if let Some(integrity) = &asset.integrity {
372 out.push_str(" integrity=\"");
373 out.push_str(&html_escape(integrity));
374 out.push('"');
375 }
376 if let Some(co) = &asset.crossorigin {
377 out.push_str(" crossorigin=\"");
378 out.push_str(&html_escape(co));
379 out.push('"');
380 }
381 out.push_str(">\n");
382 }
383 out
384}
385
386pub(crate) fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
391 let mut out = String::new();
392 for asset in assets {
393 out.push_str("<script src=\"");
394 out.push_str(&html_escape(&asset.url));
395 out.push('"');
396 if let Some(integrity) = &asset.integrity {
397 out.push_str(" integrity=\"");
398 out.push_str(&html_escape(integrity));
399 out.push('"');
400 }
401 if let Some(co) = &asset.crossorigin {
402 out.push_str(" crossorigin=\"");
403 out.push_str(&html_escape(co));
404 out.push('"');
405 }
406 out.push_str("></script>\n");
407 }
408 for init in init_scripts {
409 out.push_str("<script>");
410 out.push_str(init);
411 out.push_str("</script>\n");
412 }
413 out
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
421 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
422 use crate::spec::{Element, Spec};
423 use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
424 use serde_json::json;
425
426 fn mk_element(type_name: &str) -> Element {
430 Element {
431 type_name: type_name.to_string(),
432 props: Value::Null,
433 children: Vec::new(),
434 action: None,
435 visible: None,
436 each: None,
437 if_: None,
438 }
439 }
440
441 fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
445 let mut spec = Spec::builder()
448 .element("__tmp__", Element::new("Text"))
449 .build()
450 .expect("builder accepts trivial well-formed spec");
451 spec.root = root.to_string();
452 spec.elements.clear();
453 for (id, el) in elements {
454 spec.elements.insert(id.to_string(), el);
455 }
456 spec
457 }
458
459 #[test]
460 fn walker_unknown_type_emits_diagnostic() {
461 let spec = build_spec_unchecked("root", vec![("root", mk_element("ImaginaryWidget"))]);
462 let html = render_spec_to_html(&spec, &json!({}));
463 assert!(
464 html.contains("<!-- ferro-json-ui: unknown component type 'ImaginaryWidget' -->"),
465 "got: {html}"
466 );
467 }
468
469 #[test]
470 fn walker_missing_child_emits_diagnostic() {
471 let mut spec = Spec::builder()
475 .element("real", Element::new("Text"))
476 .build()
477 .expect("ok");
478 spec.root = "ghost".to_string();
479 let html = render_spec_to_html(&spec, &json!({}));
480 assert!(
481 html.contains("<!-- ferro-json-ui: element references missing id 'ghost' -->"),
482 "got: {html}"
483 );
484 }
485
486 #[test]
487 fn walker_root_hidden_emits_root_hidden_comment() {
488 let mut spec = Spec::builder()
489 .element("root", Element::new("Text"))
490 .build()
491 .expect("ok");
492 let el = spec.elements.get_mut("root").unwrap();
493 el.visible = Some(Visibility::Condition(VisibilityCondition {
494 path: "/show".into(),
495 operator: VisibilityOperator::Eq,
496 value: Some(json!(true)),
497 }));
498 let html = render_spec_to_html(&spec, &json!({"show": false}));
499 assert!(
500 html.contains("<!-- ferro-json-ui: root hidden -->"),
501 "got: {html}"
502 );
503 }
504
505 #[test]
506 fn walker_depth_tripwire_relative() {
507 let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
512 let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
513 assert!(html.contains("depth limit exceeded"), "got: {html}");
514 }
515
516 #[test]
517 fn walker_depth_tripwire() {
518 let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
524 let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
525 assert!(
526 html.contains("depth limit exceeded"),
527 "expected 'depth limit exceeded' in: {html}"
528 );
529 assert!(html.contains("max=16"), "expected 'max=16' in: {html}");
530 assert!(
531 !html.contains("cycle"),
532 "depth tripwire must not mention 'cycle'; got: {html}"
533 );
534 }
535
536 #[test]
537 fn walker_plugin_dispatch_invokes_with_plugin() {
538 struct TestPlugin;
539 impl JsonUiPlugin for TestPlugin {
540 fn component_type(&self) -> &str {
541 "FerroPhase116PluginDispatchTest"
542 }
543 fn props_schema(&self) -> serde_json::Value {
544 serde_json::json!({})
545 }
546 fn render(&self, _props: &Value, _data: &Value) -> String {
547 "<div data-test-plugin>X</div>".to_string()
548 }
549 fn css_assets(&self) -> Vec<Asset> {
550 Vec::new()
551 }
552 fn js_assets(&self) -> Vec<Asset> {
553 Vec::new()
554 }
555 fn init_script(&self) -> Option<String> {
556 None
557 }
558 }
559 register_plugin(TestPlugin);
560
561 let spec = build_spec_unchecked(
562 "root",
563 vec![("root", mk_element("FerroPhase116PluginDispatchTest"))],
564 );
565 let html = render_spec_to_html(&spec, &json!({}));
566 assert!(
567 html.contains("<div data-test-plugin>X</div>"),
568 "got: {html}"
569 );
570 }
571
572 #[test]
573 fn walker_plugin_asset_collection_returns_plugin_types() {
574 struct TestPluginB;
575 impl JsonUiPlugin for TestPluginB {
576 fn component_type(&self) -> &str {
577 "FerroPhase116AssetCollectTestPlugin"
578 }
579 fn props_schema(&self) -> serde_json::Value {
580 serde_json::json!({})
581 }
582 fn render(&self, _props: &Value, _data: &Value) -> String {
583 String::new()
584 }
585 fn css_assets(&self) -> Vec<Asset> {
586 Vec::new()
587 }
588 fn js_assets(&self) -> Vec<Asset> {
589 Vec::new()
590 }
591 fn init_script(&self) -> Option<String> {
592 None
593 }
594 }
595 register_plugin(TestPluginB);
596
597 let spec = build_spec_unchecked(
598 "root",
599 vec![
600 ("root", mk_element("Text")),
601 ("plug", mk_element("FerroPhase116AssetCollectTestPlugin")),
602 ],
603 );
604 let types = collect_plugin_types(&spec);
605 assert!(types.contains("FerroPhase116AssetCollectTestPlugin"));
606 assert!(!types.contains("Text"));
607 }
608
609 #[test]
610 fn walker_plugins_cannot_shadow_builtins() {
611 struct CardShadow;
615 impl JsonUiPlugin for CardShadow {
616 fn component_type(&self) -> &str {
617 "Card"
618 }
619 fn props_schema(&self) -> serde_json::Value {
620 serde_json::json!({})
621 }
622 fn render(&self, _props: &Value, _data: &Value) -> String {
623 "<div data-from-plugin>SHADOW</div>".to_string()
624 }
625 fn css_assets(&self) -> Vec<Asset> {
626 Vec::new()
627 }
628 fn js_assets(&self) -> Vec<Asset> {
629 Vec::new()
630 }
631 fn init_script(&self) -> Option<String> {
632 None
633 }
634 }
635 register_plugin(CardShadow);
636
637 let spec = build_spec_unchecked("root", vec![("root", mk_element("Card"))]);
638 let html = render_spec_to_html(&spec, &json!({}));
639 assert!(
640 !html.contains("data-from-plugin"),
641 "plugin must not shadow built-in Card; got: {html}"
642 );
643 }
644
645 #[test]
646 fn top_level_wrapper_present() {
647 let spec = build_spec_unchecked("root", vec![("root", mk_element("Text"))]);
648 let html = render_spec_to_html(&spec, &json!({}));
649 assert!(
650 html.starts_with("<div class=\"flex flex-wrap gap-4"),
651 "got: {html}"
652 );
653 assert!(html.ends_with("</div>"), "got: {html}");
654 }
655
656 #[test]
657 fn html_escape_basic() {
658 assert_eq!(html_escape("<script>"), "<script>");
659 assert_eq!(html_escape("a&b"), "a&b");
660 assert_eq!(html_escape("\"quoted\""), ""quoted"");
661 }
662
663 #[test]
664 fn builtin_types_have_no_duplicates() {
665 let mut seen = std::collections::HashSet::new();
672 for ty in BUILTIN_TYPES {
673 assert!(seen.insert(ty), "duplicate BUILTIN_TYPES entry: {ty}");
674 }
675 }
676
677 #[test]
678 fn render_spec_with_stream_text_emits_init_script() {
679 let spec = Spec::builder()
680 .element(
681 "root",
682 Element::new("StreamText").prop("sse_url", "/stream"),
683 )
684 .build()
685 .expect("spec builds");
686 let result = render_spec_to_html_with_plugins(&spec, &json!({}));
687 assert!(
688 result.scripts.contains("EventSource"),
689 "init script must be present; got: {}",
690 result.scripts
691 );
692 assert!(
694 result.scripts.contains("createTextNode"),
695 "tokens must append via createTextNode; got: {}",
696 result.scripts
697 );
698 assert!(
699 !result.scripts.contains("innerHTML"),
700 "init script must never use innerHTML; got: {}",
701 result.scripts
702 );
703 assert!(
705 result.scripts.contains("'done'") && result.scripts.contains("close()"),
706 "init script must close on done event; got: {}",
707 result.scripts
708 );
709 }
710
711 #[test]
712 fn render_spec_without_stream_text_emits_no_init_script() {
713 let spec = Spec::builder()
714 .element("root", Element::new("Text").prop("content", "Hello"))
715 .build()
716 .expect("spec builds");
717 let result = render_spec_to_html_with_plugins(&spec, &json!({}));
718 assert!(
719 result.scripts.is_empty(),
720 "no init script when no StreamText; got: {}",
721 result.scripts
722 );
723 }
724
725 #[test]
728 fn live_fragment_end_to_end_first_paint_and_delta_use_one_render_path() {
729 let child = Spec::builder()
731 .element("content", Element::new("Text").prop("content", "count: 7"))
732 .build()
733 .expect("child spec");
734 let template = serde_json::to_value(&child).expect("serialize child");
735
736 let host = Spec::builder()
738 .element(
739 "root",
740 Element::new("LiveFragment")
741 .prop("projection", "inventory.dashboard")
742 .prop("key", "warehouse-a")
743 .prop("template", template.clone()),
744 )
745 .build()
746 .expect("host spec");
747
748 let snapshot = json!({"total": 7});
750 let first_paint = render_spec_to_html(&host, &snapshot);
751 assert!(
752 first_paint.contains("data-live-fragment"),
753 "container marker present; got: {first_paint}"
754 );
755 assert!(
756 first_paint.contains(r#"data-channel="projection.inventory.dashboard.warehouse-a""#),
757 "channel attribute present; got: {first_paint}"
758 );
759 assert!(
760 first_paint.contains("count: 7"),
761 "child rendered on first paint; got: {first_paint}"
762 );
763
764 let child_spec: crate::spec::Spec =
768 serde_json::from_value(template).expect("deserialize child");
769 let delta_snapshot = json!({"total": 8});
770 let delta_html_a = render_spec_to_html(&child_spec, &delta_snapshot);
771 let delta_html_b = render_spec_to_html(&child_spec, &delta_snapshot);
772 assert_eq!(
773 delta_html_a, delta_html_b,
774 "single deterministic render path (D-05)"
775 );
776 }
777
778 #[test]
779 fn live_fragment_ships_one_binding_pattern_no_list_reconciliation() {
780 let src = include_str!("containers.rs");
783 assert!(
785 !src.contains("reconcile"),
786 "no list reconciliation in v17.0"
787 );
788 assert!(
789 !src.contains("keyed_diff"),
790 "no keyed list diffing in v17.0"
791 );
792 }
793}
794
795#[cfg(test)]
796mod render_subtree_tests {
797 use super::*;
798 use crate::spec::Spec;
799 use serde_json::json;
800
801 fn simple_spec() -> Spec {
802 Spec::builder()
803 .title("Test")
804 .element(
805 "root",
806 crate::spec::Element::new("Text").prop("content", "hello"),
807 )
808 .build()
809 .expect("spec valid")
810 }
811
812 #[test]
813 fn render_subtree_root_target_wraps_in_ferro_json_ui_div() {
814 let spec = simple_spec();
815 let html = render_subtree("ferro-json-ui", &spec, &json!({}));
816 assert!(
817 html.starts_with(r#"<div id="ferro-json-ui">"#),
818 "got: {html}"
819 );
820 assert!(html.contains("hello"), "got: {html}");
821 assert!(
823 !html.contains("<html"),
824 "must not contain html tag; got: {html}"
825 );
826 assert!(!html.contains("<nav"), "must not contain nav; got: {html}");
827 }
828
829 #[test]
830 fn render_subtree_unknown_target_returns_comment() {
831 let spec = simple_spec();
832 let html = render_subtree("nonexistent", &spec, &json!({}));
833 assert!(html.contains("<!-- ferro-json-ui:"), "got: {html}");
834 assert!(html.contains("nonexistent"), "got: {html}");
835 }
836
837 #[test]
838 fn render_spec_to_html_unchanged() {
839 let spec = simple_spec();
841 let html = render_spec_to_html(&spec, &json!({}));
842 assert!(html.contains("hello"), "got: {html}");
843 assert!(
845 !html.starts_with(r#"<div id="ferro-json-ui">"#),
846 "got: {html}"
847 );
848 }
849}