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(crate) fn collect_plugin_types(spec: &Spec) -> HashSet<String> {
272 let mut types = HashSet::new();
273 for el in spec.elements.values() {
274 if !BUILTIN_TYPES.contains(&el.type_name.as_str()) {
275 types.insert(el.type_name.clone());
276 }
277 }
278 types
279}
280
281const FERRO_STREAM_TEXT_INIT: &str = r#"(function(){
287 document.querySelectorAll('[data-ferro-stream-url]').forEach(function(el){
288 var url = el.dataset.ferroStreamUrl;
289 if(!url) return;
290 var src = new EventSource(url);
291 var placeholder = el.querySelector('[data-ferro-stream-placeholder]');
292 var loading = el.querySelector('[data-ferro-stream-loading]');
293 var firstToken = true;
294 src.onmessage = function(e){
295 if(firstToken){ firstToken=false; if(placeholder) placeholder.remove(); }
296 el.appendChild(document.createTextNode(e.data));
297 };
298 src.addEventListener('done', function(){
299 src.close();
300 if(placeholder) placeholder.remove();
301 if(loading) loading.remove();
302 });
303 src.onerror = function(){
304 src.close();
305 if(loading) loading.remove();
306 };
307 });
308})();"#;
309
310fn collect_builtin_init_scripts(spec: &Spec) -> Vec<String> {
316 let has_stream_text = spec
317 .elements
318 .values()
319 .any(|el| el.type_name == "StreamText");
320 if has_stream_text {
321 vec![FERRO_STREAM_TEXT_INIT.to_string()]
322 } else {
323 vec![]
324 }
325}
326
327pub(crate) fn html_escape(s: &str) -> String {
332 s.replace('&', "&")
333 .replace('<', "<")
334 .replace('>', ">")
335 .replace('"', """)
336 .replace('\'', "'")
337}
338
339pub(crate) fn render_css_tags(assets: &[Asset]) -> String {
344 let mut out = String::new();
345 for asset in assets {
346 out.push_str("<link rel=\"stylesheet\" href=\"");
347 out.push_str(&html_escape(&asset.url));
348 out.push('"');
349 if let Some(integrity) = &asset.integrity {
350 out.push_str(" integrity=\"");
351 out.push_str(&html_escape(integrity));
352 out.push('"');
353 }
354 if let Some(co) = &asset.crossorigin {
355 out.push_str(" crossorigin=\"");
356 out.push_str(&html_escape(co));
357 out.push('"');
358 }
359 out.push_str(">\n");
360 }
361 out
362}
363
364pub(crate) fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
369 let mut out = String::new();
370 for asset in assets {
371 out.push_str("<script src=\"");
372 out.push_str(&html_escape(&asset.url));
373 out.push('"');
374 if let Some(integrity) = &asset.integrity {
375 out.push_str(" integrity=\"");
376 out.push_str(&html_escape(integrity));
377 out.push('"');
378 }
379 if let Some(co) = &asset.crossorigin {
380 out.push_str(" crossorigin=\"");
381 out.push_str(&html_escape(co));
382 out.push('"');
383 }
384 out.push_str("></script>\n");
385 }
386 for init in init_scripts {
387 out.push_str("<script>");
388 out.push_str(init);
389 out.push_str("</script>\n");
390 }
391 out
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
399 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
400 use crate::spec::{Element, Spec};
401 use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
402 use serde_json::json;
403
404 fn mk_element(type_name: &str) -> Element {
408 Element {
409 type_name: type_name.to_string(),
410 props: Value::Null,
411 children: Vec::new(),
412 action: None,
413 visible: None,
414 each: None,
415 if_: None,
416 }
417 }
418
419 fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
423 let mut spec = Spec::builder()
426 .element("__tmp__", Element::new("Text"))
427 .build()
428 .expect("builder accepts trivial well-formed spec");
429 spec.root = root.to_string();
430 spec.elements.clear();
431 for (id, el) in elements {
432 spec.elements.insert(id.to_string(), el);
433 }
434 spec
435 }
436
437 #[test]
438 fn walker_unknown_type_emits_diagnostic() {
439 let spec = build_spec_unchecked("root", vec![("root", mk_element("ImaginaryWidget"))]);
440 let html = render_spec_to_html(&spec, &json!({}));
441 assert!(
442 html.contains("<!-- ferro-json-ui: unknown component type 'ImaginaryWidget' -->"),
443 "got: {html}"
444 );
445 }
446
447 #[test]
448 fn walker_missing_child_emits_diagnostic() {
449 let mut spec = Spec::builder()
453 .element("real", Element::new("Text"))
454 .build()
455 .expect("ok");
456 spec.root = "ghost".to_string();
457 let html = render_spec_to_html(&spec, &json!({}));
458 assert!(
459 html.contains("<!-- ferro-json-ui: element references missing id 'ghost' -->"),
460 "got: {html}"
461 );
462 }
463
464 #[test]
465 fn walker_root_hidden_emits_root_hidden_comment() {
466 let mut spec = Spec::builder()
467 .element("root", Element::new("Text"))
468 .build()
469 .expect("ok");
470 let el = spec.elements.get_mut("root").unwrap();
471 el.visible = Some(Visibility::Condition(VisibilityCondition {
472 path: "/show".into(),
473 operator: VisibilityOperator::Eq,
474 value: Some(json!(true)),
475 }));
476 let html = render_spec_to_html(&spec, &json!({"show": false}));
477 assert!(
478 html.contains("<!-- ferro-json-ui: root hidden -->"),
479 "got: {html}"
480 );
481 }
482
483 #[test]
484 fn walker_depth_tripwire_relative() {
485 let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
490 let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
491 assert!(html.contains("depth limit exceeded"), "got: {html}");
492 }
493
494 #[test]
495 fn walker_depth_tripwire() {
496 let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
502 let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
503 assert!(
504 html.contains("depth limit exceeded"),
505 "expected 'depth limit exceeded' in: {html}"
506 );
507 assert!(html.contains("max=16"), "expected 'max=16' in: {html}");
508 assert!(
509 !html.contains("cycle"),
510 "depth tripwire must not mention 'cycle'; got: {html}"
511 );
512 }
513
514 #[test]
515 fn walker_plugin_dispatch_invokes_with_plugin() {
516 struct TestPlugin;
517 impl JsonUiPlugin for TestPlugin {
518 fn component_type(&self) -> &str {
519 "FerroPhase116PluginDispatchTest"
520 }
521 fn props_schema(&self) -> serde_json::Value {
522 serde_json::json!({})
523 }
524 fn render(&self, _props: &Value, _data: &Value) -> String {
525 "<div data-test-plugin>X</div>".to_string()
526 }
527 fn css_assets(&self) -> Vec<Asset> {
528 Vec::new()
529 }
530 fn js_assets(&self) -> Vec<Asset> {
531 Vec::new()
532 }
533 fn init_script(&self) -> Option<String> {
534 None
535 }
536 }
537 register_plugin(TestPlugin);
538
539 let spec = build_spec_unchecked(
540 "root",
541 vec![("root", mk_element("FerroPhase116PluginDispatchTest"))],
542 );
543 let html = render_spec_to_html(&spec, &json!({}));
544 assert!(
545 html.contains("<div data-test-plugin>X</div>"),
546 "got: {html}"
547 );
548 }
549
550 #[test]
551 fn walker_plugin_asset_collection_returns_plugin_types() {
552 struct TestPluginB;
553 impl JsonUiPlugin for TestPluginB {
554 fn component_type(&self) -> &str {
555 "FerroPhase116AssetCollectTestPlugin"
556 }
557 fn props_schema(&self) -> serde_json::Value {
558 serde_json::json!({})
559 }
560 fn render(&self, _props: &Value, _data: &Value) -> String {
561 String::new()
562 }
563 fn css_assets(&self) -> Vec<Asset> {
564 Vec::new()
565 }
566 fn js_assets(&self) -> Vec<Asset> {
567 Vec::new()
568 }
569 fn init_script(&self) -> Option<String> {
570 None
571 }
572 }
573 register_plugin(TestPluginB);
574
575 let spec = build_spec_unchecked(
576 "root",
577 vec![
578 ("root", mk_element("Text")),
579 ("plug", mk_element("FerroPhase116AssetCollectTestPlugin")),
580 ],
581 );
582 let types = collect_plugin_types(&spec);
583 assert!(types.contains("FerroPhase116AssetCollectTestPlugin"));
584 assert!(!types.contains("Text"));
585 }
586
587 #[test]
588 fn walker_plugins_cannot_shadow_builtins() {
589 struct CardShadow;
593 impl JsonUiPlugin for CardShadow {
594 fn component_type(&self) -> &str {
595 "Card"
596 }
597 fn props_schema(&self) -> serde_json::Value {
598 serde_json::json!({})
599 }
600 fn render(&self, _props: &Value, _data: &Value) -> String {
601 "<div data-from-plugin>SHADOW</div>".to_string()
602 }
603 fn css_assets(&self) -> Vec<Asset> {
604 Vec::new()
605 }
606 fn js_assets(&self) -> Vec<Asset> {
607 Vec::new()
608 }
609 fn init_script(&self) -> Option<String> {
610 None
611 }
612 }
613 register_plugin(CardShadow);
614
615 let spec = build_spec_unchecked("root", vec![("root", mk_element("Card"))]);
616 let html = render_spec_to_html(&spec, &json!({}));
617 assert!(
618 !html.contains("data-from-plugin"),
619 "plugin must not shadow built-in Card; got: {html}"
620 );
621 }
622
623 #[test]
624 fn top_level_wrapper_present() {
625 let spec = build_spec_unchecked("root", vec![("root", mk_element("Text"))]);
626 let html = render_spec_to_html(&spec, &json!({}));
627 assert!(
628 html.starts_with("<div class=\"flex flex-wrap gap-4"),
629 "got: {html}"
630 );
631 assert!(html.ends_with("</div>"), "got: {html}");
632 }
633
634 #[test]
635 fn html_escape_basic() {
636 assert_eq!(html_escape("<script>"), "<script>");
637 assert_eq!(html_escape("a&b"), "a&b");
638 assert_eq!(html_escape("\"quoted\""), ""quoted"");
639 }
640
641 #[test]
642 fn builtin_types_have_no_duplicates() {
643 let mut seen = std::collections::HashSet::new();
650 for ty in BUILTIN_TYPES {
651 assert!(seen.insert(ty), "duplicate BUILTIN_TYPES entry: {ty}");
652 }
653 }
654
655 #[test]
656 fn render_spec_with_stream_text_emits_init_script() {
657 let spec = Spec::builder()
658 .element(
659 "root",
660 Element::new("StreamText").prop("sse_url", "/stream"),
661 )
662 .build()
663 .expect("spec builds");
664 let result = render_spec_to_html_with_plugins(&spec, &json!({}));
665 assert!(
666 result.scripts.contains("EventSource"),
667 "init script must be present; got: {}",
668 result.scripts
669 );
670 assert!(
672 result.scripts.contains("createTextNode"),
673 "tokens must append via createTextNode; got: {}",
674 result.scripts
675 );
676 assert!(
677 !result.scripts.contains("innerHTML"),
678 "init script must never use innerHTML; got: {}",
679 result.scripts
680 );
681 assert!(
683 result.scripts.contains("'done'") && result.scripts.contains("close()"),
684 "init script must close on done event; got: {}",
685 result.scripts
686 );
687 }
688
689 #[test]
690 fn render_spec_without_stream_text_emits_no_init_script() {
691 let spec = Spec::builder()
692 .element("root", Element::new("Text").prop("content", "Hello"))
693 .build()
694 .expect("spec builds");
695 let result = render_spec_to_html_with_plugins(&spec, &json!({}));
696 assert!(
697 result.scripts.is_empty(),
698 "no init script when no StreamText; got: {}",
699 result.scripts
700 );
701 }
702
703 #[test]
706 fn live_fragment_end_to_end_first_paint_and_delta_use_one_render_path() {
707 let child = Spec::builder()
709 .element("content", Element::new("Text").prop("content", "count: 7"))
710 .build()
711 .expect("child spec");
712 let template = serde_json::to_value(&child).expect("serialize child");
713
714 let host = Spec::builder()
716 .element(
717 "root",
718 Element::new("LiveFragment")
719 .prop("projection", "inventory.dashboard")
720 .prop("key", "warehouse-a")
721 .prop("template", template.clone()),
722 )
723 .build()
724 .expect("host spec");
725
726 let snapshot = json!({"total": 7});
728 let first_paint = render_spec_to_html(&host, &snapshot);
729 assert!(
730 first_paint.contains("data-live-fragment"),
731 "container marker present; got: {first_paint}"
732 );
733 assert!(
734 first_paint.contains(r#"data-channel="projection.inventory.dashboard.warehouse-a""#),
735 "channel attribute present; got: {first_paint}"
736 );
737 assert!(
738 first_paint.contains("count: 7"),
739 "child rendered on first paint; got: {first_paint}"
740 );
741
742 let child_spec: crate::spec::Spec =
746 serde_json::from_value(template).expect("deserialize child");
747 let delta_snapshot = json!({"total": 8});
748 let delta_html_a = render_spec_to_html(&child_spec, &delta_snapshot);
749 let delta_html_b = render_spec_to_html(&child_spec, &delta_snapshot);
750 assert_eq!(
751 delta_html_a, delta_html_b,
752 "single deterministic render path (D-05)"
753 );
754 }
755
756 #[test]
757 fn live_fragment_ships_one_binding_pattern_no_list_reconciliation() {
758 let src = include_str!("containers.rs");
761 assert!(
763 !src.contains("reconcile"),
764 "no list reconciliation in v17.0"
765 );
766 assert!(
767 !src.contains("keyed_diff"),
768 "no keyed list diffing in v17.0"
769 );
770 }
771}