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 "Form",
91 "Input",
92 "Select",
93 "Checkbox",
94 "Switch",
95 "CheckboxList",
96 "CheckboxGroup",
97 "Table",
99 "DataTable",
100 "MediaCardGrid",
101];
102
103pub fn render_spec_to_html(spec: &Spec, data: &Value) -> String {
110 let body = render_element(&spec.root, spec, data, 1);
111 let body_or_root_hidden = if body.is_empty() && spec_root_was_hidden(spec, data) {
112 String::from("<!-- ferro-json-ui: root hidden -->")
113 } else {
114 body
115 };
116 format!(
117 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">{body_or_root_hidden}</div>"
118 )
119}
120
121pub fn render_spec_to_html_with_plugins(spec: &Spec, data: &Value) -> RenderResult {
126 let html = render_spec_to_html(spec, data);
127 let builtin_scripts = collect_builtin_init_scripts(spec);
128 let plugin_types = collect_plugin_types(spec);
129 if plugin_types.is_empty() && builtin_scripts.is_empty() {
130 return RenderResult {
131 html,
132 css_head: String::new(),
133 scripts: String::new(),
134 };
135 }
136 let type_names: Vec<String> = plugin_types.into_iter().collect();
137 let assets = collect_plugin_assets(&type_names);
138 let all_init_scripts: Vec<String> = assets
139 .init_scripts
140 .iter()
141 .chain(builtin_scripts.iter())
142 .cloned()
143 .collect();
144 RenderResult {
145 html,
146 css_head: render_css_tags(&assets.css),
147 scripts: render_js_tags(&assets.js, &all_init_scripts),
148 }
149}
150
151pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
155 if depth > MAX_NESTING_DEPTH + 1 {
161 return format!(
162 "<!-- ferro-json-ui: depth limit exceeded at depth {depth} (max={MAX_NESTING_DEPTH}) — spec should have been rejected at parse time -->"
163 );
164 }
165
166 let Some(el) = spec.elements.get(id) else {
168 return format!(
169 "<!-- ferro-json-ui: element references missing id '{}' -->",
170 html_escape(id)
171 );
172 };
173
174 if let Some(vis) = &el.visible {
176 if !vis.evaluate(data) {
177 return String::new();
178 }
179 }
180
181 match el.type_name.as_str() {
183 "Text" => atoms::render_text(el, spec, data, depth),
185 "Button" => atoms::render_button(el, spec, data, depth),
186 "Badge" => atoms::render_badge(el, spec, data, depth),
187 "Alert" => atoms::render_alert(el, spec, data, depth),
188 "Separator" => atoms::render_separator(el, spec, data, depth),
189 "Progress" => atoms::render_progress(el, spec, data, depth),
190 "Avatar" => atoms::render_avatar(el, spec, data, depth),
191 "Image" => atoms::render_image(el, spec, data, depth),
192 "Skeleton" => atoms::render_skeleton(el, spec, data, depth),
193 "Breadcrumb" => atoms::render_breadcrumb(el, spec, data, depth),
194 "Pagination" => atoms::render_pagination(el, spec, data, depth),
195 "DescriptionList" => atoms::render_description_list(el, spec, data, depth),
196 "EmptyState" => atoms::render_empty_state(el, spec, data, depth),
197 "StatCard" => atoms::render_stat_card(el, spec, data, depth),
198 "Checklist" => atoms::render_checklist(el, spec, data, depth),
199 "Toast" => atoms::render_toast(el, spec, data, depth),
200 "NotificationDropdown" => atoms::render_notification_dropdown(el, spec, data, depth),
201 "Sidebar" => atoms::render_sidebar(el, spec, data, depth),
202 "Header" => atoms::render_header(el, spec, data, depth),
203 "CalendarCell" => atoms::render_calendar_cell(el, spec, data, depth),
204 "ActionCard" => atoms::render_action_card(el, spec, data, depth),
205 "Tile" => atoms::render_tile(el, spec, data, depth),
206 "FilterTabs" => atoms::render_filter_tabs(el, spec, data, depth),
207 "RawHtml" => atoms::render_raw_html(el, spec, data, depth),
208 "StreamText" => atoms::render_streamtext(el, spec, data, depth),
209 "QuantityStepper" => atoms::render_quantity_stepper(el, spec, data, depth),
210 "Numpad" => atoms::render_numpad(el, spec, data, depth),
211 "Card" => containers::render_card(el, spec, data, depth),
213 "Modal" => containers::render_modal(el, spec, data, depth),
214 "Tabs" => containers::render_tabs(el, spec, data, depth),
215 "KanbanBoard" => containers::render_kanban_board(el, spec, data, depth),
216 "PageHeader" => containers::render_page_header(el, spec, data, depth),
217 "DetailPage" => containers::render_detail_page(el, spec, data, depth),
218 "Grid" => containers::render_grid(el, spec, data, depth),
219 "TileGrid" => containers::render_tile_grid(el, spec, data, depth),
220 "Collapsible" => containers::render_collapsible(el, spec, data, depth),
221 "FormSection" => containers::render_form_section(el, spec, data, depth),
222 "ButtonGroup" => containers::render_button_group(el, spec, data, depth),
223 "SegmentedControl" => containers::render_segmented_control(el, spec, data, depth),
224 "SidebarLayout" => containers::render_sidebar_layout(el, spec, data, depth),
225 "ActionGroup" => containers::render_action_group(el, spec, data, depth),
226 "SelectionPanel" => containers::render_selection_panel(el, spec, data, depth),
227 "Form" => form::render_form(el, spec, data, depth),
229 "Input" => form::render_input(el, spec, data, depth),
230 "Select" => form::render_select(el, spec, data, depth),
231 "Checkbox" => form::render_checkbox(el, spec, data, depth),
232 "Switch" => form::render_switch(el, spec, data, depth),
233 "CheckboxList" => form::render_checkbox_list(el, spec, data, depth),
234 "CheckboxGroup" => form::render_checkbox_list(el, spec, data, depth),
235 "Table" => data::render_table(el, spec, data, depth),
237 "DataTable" => data::render_data_table(el, spec, data, depth),
238 "MediaCardGrid" => data::render_media_card_grid(el, spec, data, depth),
239 other => render_plugin_or_unknown(other, el, data),
241 }
242}
243
244fn render_plugin_or_unknown(type_name: &str, el: &crate::spec::Element, data: &Value) -> String {
245 match with_plugin(type_name, |p| p.render(&el.props, data)) {
246 Some(html) => html,
247 None => format!(
248 "<!-- ferro-json-ui: unknown component type '{}' -->",
249 html_escape(type_name)
250 ),
251 }
252}
253
254fn spec_root_was_hidden(spec: &Spec, data: &Value) -> bool {
258 spec.elements
259 .get(&spec.root)
260 .and_then(|el| el.visible.as_ref())
261 .map(|vis| !vis.evaluate(data))
262 .unwrap_or(false)
263}
264
265pub(crate) fn collect_plugin_types(spec: &Spec) -> HashSet<String> {
269 let mut types = HashSet::new();
270 for el in spec.elements.values() {
271 if !BUILTIN_TYPES.contains(&el.type_name.as_str()) {
272 types.insert(el.type_name.clone());
273 }
274 }
275 types
276}
277
278const FERRO_STREAM_TEXT_INIT: &str = r#"(function(){
284 document.querySelectorAll('[data-ferro-stream-url]').forEach(function(el){
285 var url = el.dataset.ferroStreamUrl;
286 if(!url) return;
287 var src = new EventSource(url);
288 var placeholder = el.querySelector('[data-ferro-stream-placeholder]');
289 var loading = el.querySelector('[data-ferro-stream-loading]');
290 var firstToken = true;
291 src.onmessage = function(e){
292 if(firstToken){ firstToken=false; if(placeholder) placeholder.remove(); }
293 el.appendChild(document.createTextNode(e.data));
294 };
295 src.addEventListener('done', function(){
296 src.close();
297 if(placeholder) placeholder.remove();
298 if(loading) loading.remove();
299 });
300 src.onerror = function(){
301 src.close();
302 if(loading) loading.remove();
303 };
304 });
305})();"#;
306
307fn collect_builtin_init_scripts(spec: &Spec) -> Vec<String> {
313 let has_stream_text = spec
314 .elements
315 .values()
316 .any(|el| el.type_name == "StreamText");
317 if has_stream_text {
318 vec![FERRO_STREAM_TEXT_INIT.to_string()]
319 } else {
320 vec![]
321 }
322}
323
324pub(crate) fn html_escape(s: &str) -> String {
329 s.replace('&', "&")
330 .replace('<', "<")
331 .replace('>', ">")
332 .replace('"', """)
333 .replace('\'', "'")
334}
335
336pub(crate) fn render_css_tags(assets: &[Asset]) -> String {
341 let mut out = String::new();
342 for asset in assets {
343 out.push_str("<link rel=\"stylesheet\" href=\"");
344 out.push_str(&html_escape(&asset.url));
345 out.push('"');
346 if let Some(integrity) = &asset.integrity {
347 out.push_str(" integrity=\"");
348 out.push_str(&html_escape(integrity));
349 out.push('"');
350 }
351 if let Some(co) = &asset.crossorigin {
352 out.push_str(" crossorigin=\"");
353 out.push_str(&html_escape(co));
354 out.push('"');
355 }
356 out.push_str(">\n");
357 }
358 out
359}
360
361pub(crate) fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
366 let mut out = String::new();
367 for asset in assets {
368 out.push_str("<script src=\"");
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("></script>\n");
382 }
383 for init in init_scripts {
384 out.push_str("<script>");
385 out.push_str(init);
386 out.push_str("</script>\n");
387 }
388 out
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
396 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
397 use crate::spec::{Element, Spec};
398 use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
399 use serde_json::json;
400
401 fn mk_element(type_name: &str) -> Element {
405 Element {
406 type_name: type_name.to_string(),
407 props: Value::Null,
408 children: Vec::new(),
409 action: None,
410 visible: None,
411 each: None,
412 if_: None,
413 }
414 }
415
416 fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
420 let mut spec = Spec::builder()
423 .element("__tmp__", Element::new("Text"))
424 .build()
425 .expect("builder accepts trivial well-formed spec");
426 spec.root = root.to_string();
427 spec.elements.clear();
428 for (id, el) in elements {
429 spec.elements.insert(id.to_string(), el);
430 }
431 spec
432 }
433
434 #[test]
435 fn walker_unknown_type_emits_diagnostic() {
436 let spec = build_spec_unchecked("root", vec![("root", mk_element("ImaginaryWidget"))]);
437 let html = render_spec_to_html(&spec, &json!({}));
438 assert!(
439 html.contains("<!-- ferro-json-ui: unknown component type 'ImaginaryWidget' -->"),
440 "got: {html}"
441 );
442 }
443
444 #[test]
445 fn walker_missing_child_emits_diagnostic() {
446 let mut spec = Spec::builder()
450 .element("real", Element::new("Text"))
451 .build()
452 .expect("ok");
453 spec.root = "ghost".to_string();
454 let html = render_spec_to_html(&spec, &json!({}));
455 assert!(
456 html.contains("<!-- ferro-json-ui: element references missing id 'ghost' -->"),
457 "got: {html}"
458 );
459 }
460
461 #[test]
462 fn walker_root_hidden_emits_root_hidden_comment() {
463 let mut spec = Spec::builder()
464 .element("root", Element::new("Text"))
465 .build()
466 .expect("ok");
467 let el = spec.elements.get_mut("root").unwrap();
468 el.visible = Some(Visibility::Condition(VisibilityCondition {
469 path: "/show".into(),
470 operator: VisibilityOperator::Eq,
471 value: Some(json!(true)),
472 }));
473 let html = render_spec_to_html(&spec, &json!({"show": false}));
474 assert!(
475 html.contains("<!-- ferro-json-ui: root hidden -->"),
476 "got: {html}"
477 );
478 }
479
480 #[test]
481 fn walker_depth_tripwire_relative() {
482 let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
487 let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
488 assert!(html.contains("depth limit exceeded"), "got: {html}");
489 }
490
491 #[test]
492 fn walker_depth_tripwire() {
493 let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
499 let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
500 assert!(
501 html.contains("depth limit exceeded"),
502 "expected 'depth limit exceeded' in: {html}"
503 );
504 assert!(html.contains("max=16"), "expected 'max=16' in: {html}");
505 assert!(
506 !html.contains("cycle"),
507 "depth tripwire must not mention 'cycle'; got: {html}"
508 );
509 }
510
511 #[test]
512 fn walker_plugin_dispatch_invokes_with_plugin() {
513 struct TestPlugin;
514 impl JsonUiPlugin for TestPlugin {
515 fn component_type(&self) -> &str {
516 "FerroPhase116PluginDispatchTest"
517 }
518 fn props_schema(&self) -> serde_json::Value {
519 serde_json::json!({})
520 }
521 fn render(&self, _props: &Value, _data: &Value) -> String {
522 "<div data-test-plugin>X</div>".to_string()
523 }
524 fn css_assets(&self) -> Vec<Asset> {
525 Vec::new()
526 }
527 fn js_assets(&self) -> Vec<Asset> {
528 Vec::new()
529 }
530 fn init_script(&self) -> Option<String> {
531 None
532 }
533 }
534 register_plugin(TestPlugin);
535
536 let spec = build_spec_unchecked(
537 "root",
538 vec![("root", mk_element("FerroPhase116PluginDispatchTest"))],
539 );
540 let html = render_spec_to_html(&spec, &json!({}));
541 assert!(
542 html.contains("<div data-test-plugin>X</div>"),
543 "got: {html}"
544 );
545 }
546
547 #[test]
548 fn walker_plugin_asset_collection_returns_plugin_types() {
549 struct TestPluginB;
550 impl JsonUiPlugin for TestPluginB {
551 fn component_type(&self) -> &str {
552 "FerroPhase116AssetCollectTestPlugin"
553 }
554 fn props_schema(&self) -> serde_json::Value {
555 serde_json::json!({})
556 }
557 fn render(&self, _props: &Value, _data: &Value) -> String {
558 String::new()
559 }
560 fn css_assets(&self) -> Vec<Asset> {
561 Vec::new()
562 }
563 fn js_assets(&self) -> Vec<Asset> {
564 Vec::new()
565 }
566 fn init_script(&self) -> Option<String> {
567 None
568 }
569 }
570 register_plugin(TestPluginB);
571
572 let spec = build_spec_unchecked(
573 "root",
574 vec![
575 ("root", mk_element("Text")),
576 ("plug", mk_element("FerroPhase116AssetCollectTestPlugin")),
577 ],
578 );
579 let types = collect_plugin_types(&spec);
580 assert!(types.contains("FerroPhase116AssetCollectTestPlugin"));
581 assert!(!types.contains("Text"));
582 }
583
584 #[test]
585 fn walker_plugins_cannot_shadow_builtins() {
586 struct CardShadow;
590 impl JsonUiPlugin for CardShadow {
591 fn component_type(&self) -> &str {
592 "Card"
593 }
594 fn props_schema(&self) -> serde_json::Value {
595 serde_json::json!({})
596 }
597 fn render(&self, _props: &Value, _data: &Value) -> String {
598 "<div data-from-plugin>SHADOW</div>".to_string()
599 }
600 fn css_assets(&self) -> Vec<Asset> {
601 Vec::new()
602 }
603 fn js_assets(&self) -> Vec<Asset> {
604 Vec::new()
605 }
606 fn init_script(&self) -> Option<String> {
607 None
608 }
609 }
610 register_plugin(CardShadow);
611
612 let spec = build_spec_unchecked("root", vec![("root", mk_element("Card"))]);
613 let html = render_spec_to_html(&spec, &json!({}));
614 assert!(
615 !html.contains("data-from-plugin"),
616 "plugin must not shadow built-in Card; got: {html}"
617 );
618 }
619
620 #[test]
621 fn top_level_wrapper_present() {
622 let spec = build_spec_unchecked("root", vec![("root", mk_element("Text"))]);
623 let html = render_spec_to_html(&spec, &json!({}));
624 assert!(
625 html.starts_with("<div class=\"flex flex-wrap gap-4"),
626 "got: {html}"
627 );
628 assert!(html.ends_with("</div>"), "got: {html}");
629 }
630
631 #[test]
632 fn html_escape_basic() {
633 assert_eq!(html_escape("<script>"), "<script>");
634 assert_eq!(html_escape("a&b"), "a&b");
635 assert_eq!(html_escape("\"quoted\""), ""quoted"");
636 }
637
638 #[test]
639 fn builtin_types_have_no_duplicates() {
640 let mut seen = std::collections::HashSet::new();
647 for ty in BUILTIN_TYPES {
648 assert!(seen.insert(ty), "duplicate BUILTIN_TYPES entry: {ty}");
649 }
650 }
651
652 #[test]
653 fn render_spec_with_stream_text_emits_init_script() {
654 let spec = Spec::builder()
655 .element(
656 "root",
657 Element::new("StreamText").prop("sse_url", "/stream"),
658 )
659 .build()
660 .expect("spec builds");
661 let result = render_spec_to_html_with_plugins(&spec, &json!({}));
662 assert!(
663 result.scripts.contains("EventSource"),
664 "init script must be present; got: {}",
665 result.scripts
666 );
667 assert!(
669 result.scripts.contains("createTextNode"),
670 "tokens must append via createTextNode; got: {}",
671 result.scripts
672 );
673 assert!(
674 !result.scripts.contains("innerHTML"),
675 "init script must never use innerHTML; got: {}",
676 result.scripts
677 );
678 assert!(
680 result.scripts.contains("'done'") && result.scripts.contains("close()"),
681 "init script must close on done event; got: {}",
682 result.scripts
683 );
684 }
685
686 #[test]
687 fn render_spec_without_stream_text_emits_no_init_script() {
688 let spec = Spec::builder()
689 .element("root", Element::new("Text").prop("content", "Hello"))
690 .build()
691 .expect("spec builds");
692 let result = render_spec_to_html_with_plugins(&spec, &json!({}));
693 assert!(
694 result.scripts.is_empty(),
695 "no init script when no StreamText; got: {}",
696 result.scripts
697 );
698 }
699}