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(crate) mod containers;
26pub(crate) mod data;
27pub(crate) mod form;
28
29pub struct RenderResult {
31 pub html: String,
32 pub css_head: String,
33 pub scripts: String,
34}
35
36pub(crate) const BUILTIN_TYPES: &[&str] = &[
44 "Text",
46 "Button",
47 "Badge",
48 "Alert",
49 "Separator",
50 "Progress",
51 "Avatar",
52 "Image",
53 "Skeleton",
54 "Breadcrumb",
55 "Pagination",
56 "DescriptionList",
57 "EmptyState",
58 "StatCard",
59 "Checklist",
60 "Toast",
61 "NotificationDropdown",
62 "Sidebar",
63 "Header",
64 "DropdownMenu",
65 "CalendarCell",
66 "ActionCard",
67 "ProductTile",
68 "RawHtml",
69 "StreamText",
70 "Card",
72 "Modal",
73 "Tabs",
74 "KanbanBoard",
75 "PageHeader",
76 "DetailPage",
77 "Grid",
78 "Collapsible",
79 "FormSection",
80 "ButtonGroup",
81 "Form",
83 "Input",
84 "Select",
85 "Checkbox",
86 "Switch",
87 "CheckboxList",
88 "CheckboxGroup",
89 "Table",
91 "DataTable",
92 "MediaCardGrid",
93];
94
95pub fn render_spec_to_html(spec: &Spec, data: &Value) -> String {
102 let body = render_element(&spec.root, spec, data, 1);
103 let body_or_root_hidden = if body.is_empty() && spec_root_was_hidden(spec, data) {
104 String::from("<!-- ferro-json-ui: root hidden -->")
105 } else {
106 body
107 };
108 format!(
109 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">{body_or_root_hidden}</div>"
110 )
111}
112
113pub fn render_spec_to_html_with_plugins(spec: &Spec, data: &Value) -> RenderResult {
118 let html = render_spec_to_html(spec, data);
119 let builtin_scripts = collect_builtin_init_scripts(spec);
120 let plugin_types = collect_plugin_types(spec);
121 if plugin_types.is_empty() && builtin_scripts.is_empty() {
122 return RenderResult {
123 html,
124 css_head: String::new(),
125 scripts: String::new(),
126 };
127 }
128 let type_names: Vec<String> = plugin_types.into_iter().collect();
129 let assets = collect_plugin_assets(&type_names);
130 let all_init_scripts: Vec<String> = assets
131 .init_scripts
132 .iter()
133 .chain(builtin_scripts.iter())
134 .cloned()
135 .collect();
136 RenderResult {
137 html,
138 css_head: render_css_tags(&assets.css),
139 scripts: render_js_tags(&assets.js, &all_init_scripts),
140 }
141}
142
143pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
147 if depth > MAX_NESTING_DEPTH + 1 {
153 return format!(
154 "<!-- ferro-json-ui: depth limit exceeded at depth {depth} (max={MAX_NESTING_DEPTH}) — spec should have been rejected at parse time -->"
155 );
156 }
157
158 let Some(el) = spec.elements.get(id) else {
160 return format!(
161 "<!-- ferro-json-ui: element references missing id '{}' -->",
162 html_escape(id)
163 );
164 };
165
166 if let Some(vis) = &el.visible {
168 if !vis.evaluate(data) {
169 return String::new();
170 }
171 }
172
173 match el.type_name.as_str() {
175 "Text" => atoms::render_text(el, spec, data, depth),
177 "Button" => atoms::render_button(el, spec, data, depth),
178 "Badge" => atoms::render_badge(el, spec, data, depth),
179 "Alert" => atoms::render_alert(el, spec, data, depth),
180 "Separator" => atoms::render_separator(el, spec, data, depth),
181 "Progress" => atoms::render_progress(el, spec, data, depth),
182 "Avatar" => atoms::render_avatar(el, spec, data, depth),
183 "Image" => atoms::render_image(el, spec, data, depth),
184 "Skeleton" => atoms::render_skeleton(el, spec, data, depth),
185 "Breadcrumb" => atoms::render_breadcrumb(el, spec, data, depth),
186 "Pagination" => atoms::render_pagination(el, spec, data, depth),
187 "DescriptionList" => atoms::render_description_list(el, spec, data, depth),
188 "EmptyState" => atoms::render_empty_state(el, spec, data, depth),
189 "StatCard" => atoms::render_stat_card(el, spec, data, depth),
190 "Checklist" => atoms::render_checklist(el, spec, data, depth),
191 "Toast" => atoms::render_toast(el, spec, data, depth),
192 "NotificationDropdown" => atoms::render_notification_dropdown(el, spec, data, depth),
193 "Sidebar" => atoms::render_sidebar(el, spec, data, depth),
194 "Header" => atoms::render_header(el, spec, data, depth),
195 "DropdownMenu" => atoms::render_dropdown_menu(el, spec, data, depth),
196 "CalendarCell" => atoms::render_calendar_cell(el, spec, data, depth),
197 "ActionCard" => atoms::render_action_card(el, spec, data, depth),
198 "ProductTile" => atoms::render_product_tile(el, spec, data, depth),
199 "RawHtml" => atoms::render_raw_html(el, spec, data, depth),
200 "StreamText" => atoms::render_streamtext(el, spec, data, depth),
201 "Card" => containers::render_card(el, spec, data, depth),
203 "Modal" => containers::render_modal(el, spec, data, depth),
204 "Tabs" => containers::render_tabs(el, spec, data, depth),
205 "KanbanBoard" => containers::render_kanban_board(el, spec, data, depth),
206 "PageHeader" => containers::render_page_header(el, spec, data, depth),
207 "DetailPage" => containers::render_detail_page(el, spec, data, depth),
208 "Grid" => containers::render_grid(el, spec, data, depth),
209 "Collapsible" => containers::render_collapsible(el, spec, data, depth),
210 "FormSection" => containers::render_form_section(el, spec, data, depth),
211 "ButtonGroup" => containers::render_button_group(el, spec, data, depth),
212 "Form" => form::render_form(el, spec, data, depth),
214 "Input" => form::render_input(el, spec, data, depth),
215 "Select" => form::render_select(el, spec, data, depth),
216 "Checkbox" => form::render_checkbox(el, spec, data, depth),
217 "Switch" => form::render_switch(el, spec, data, depth),
218 "CheckboxList" => form::render_checkbox_list(el, spec, data, depth),
219 "CheckboxGroup" => form::render_checkbox_list(el, spec, data, depth),
220 "Table" => data::render_table(el, spec, data, depth),
222 "DataTable" => data::render_data_table(el, spec, data, depth),
223 "MediaCardGrid" => data::render_media_card_grid(el, spec, data, depth),
224 other => render_plugin_or_unknown(other, el, data),
226 }
227}
228
229fn render_plugin_or_unknown(type_name: &str, el: &crate::spec::Element, data: &Value) -> String {
230 match with_plugin(type_name, |p| p.render(&el.props, data)) {
231 Some(html) => html,
232 None => format!(
233 "<!-- ferro-json-ui: unknown component type '{}' -->",
234 html_escape(type_name)
235 ),
236 }
237}
238
239fn spec_root_was_hidden(spec: &Spec, data: &Value) -> bool {
243 spec.elements
244 .get(&spec.root)
245 .and_then(|el| el.visible.as_ref())
246 .map(|vis| !vis.evaluate(data))
247 .unwrap_or(false)
248}
249
250pub(crate) fn collect_plugin_types(spec: &Spec) -> HashSet<String> {
254 let mut types = HashSet::new();
255 for el in spec.elements.values() {
256 if !BUILTIN_TYPES.contains(&el.type_name.as_str()) {
257 types.insert(el.type_name.clone());
258 }
259 }
260 types
261}
262
263const FERRO_STREAM_TEXT_INIT: &str = r#"(function(){
269 document.querySelectorAll('[data-ferro-stream-url]').forEach(function(el){
270 var url = el.dataset.ferroStreamUrl;
271 if(!url) return;
272 var src = new EventSource(url);
273 var placeholder = el.querySelector('[data-ferro-stream-placeholder]');
274 var loading = el.querySelector('[data-ferro-stream-loading]');
275 var firstToken = true;
276 src.onmessage = function(e){
277 if(firstToken){ firstToken=false; if(placeholder) placeholder.remove(); }
278 el.appendChild(document.createTextNode(e.data));
279 };
280 src.addEventListener('done', function(){
281 src.close();
282 if(placeholder) placeholder.remove();
283 if(loading) loading.remove();
284 });
285 src.onerror = function(){
286 src.close();
287 if(loading) loading.remove();
288 };
289 });
290})();"#;
291
292fn collect_builtin_init_scripts(spec: &Spec) -> Vec<String> {
298 let has_stream_text = spec
299 .elements
300 .values()
301 .any(|el| el.type_name == "StreamText");
302 if has_stream_text {
303 vec![FERRO_STREAM_TEXT_INIT.to_string()]
304 } else {
305 vec![]
306 }
307}
308
309pub(crate) fn html_escape(s: &str) -> String {
314 s.replace('&', "&")
315 .replace('<', "<")
316 .replace('>', ">")
317 .replace('"', """)
318 .replace('\'', "'")
319}
320
321pub(crate) fn render_css_tags(assets: &[Asset]) -> String {
326 let mut out = String::new();
327 for asset in assets {
328 out.push_str("<link rel=\"stylesheet\" href=\"");
329 out.push_str(&html_escape(&asset.url));
330 out.push('"');
331 if let Some(integrity) = &asset.integrity {
332 out.push_str(" integrity=\"");
333 out.push_str(&html_escape(integrity));
334 out.push('"');
335 }
336 if let Some(co) = &asset.crossorigin {
337 out.push_str(" crossorigin=\"");
338 out.push_str(&html_escape(co));
339 out.push('"');
340 }
341 out.push_str(">\n");
342 }
343 out
344}
345
346pub(crate) fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
351 let mut out = String::new();
352 for asset in assets {
353 out.push_str("<script src=\"");
354 out.push_str(&html_escape(&asset.url));
355 out.push('"');
356 if let Some(integrity) = &asset.integrity {
357 out.push_str(" integrity=\"");
358 out.push_str(&html_escape(integrity));
359 out.push('"');
360 }
361 if let Some(co) = &asset.crossorigin {
362 out.push_str(" crossorigin=\"");
363 out.push_str(&html_escape(co));
364 out.push('"');
365 }
366 out.push_str("></script>\n");
367 }
368 for init in init_scripts {
369 out.push_str("<script>");
370 out.push_str(init);
371 out.push_str("</script>\n");
372 }
373 out
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
381 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
382 use crate::spec::{Element, Spec};
383 use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
384 use serde_json::json;
385
386 fn mk_element(type_name: &str) -> Element {
390 Element {
391 type_name: type_name.to_string(),
392 props: Value::Null,
393 children: Vec::new(),
394 action: None,
395 visible: None,
396 each: None,
397 if_: None,
398 }
399 }
400
401 fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
405 let mut spec = Spec::builder()
408 .element("__tmp__", Element::new("Text"))
409 .build()
410 .expect("builder accepts trivial well-formed spec");
411 spec.root = root.to_string();
412 spec.elements.clear();
413 for (id, el) in elements {
414 spec.elements.insert(id.to_string(), el);
415 }
416 spec
417 }
418
419 #[test]
420 fn walker_unknown_type_emits_diagnostic() {
421 let spec = build_spec_unchecked("root", vec![("root", mk_element("ImaginaryWidget"))]);
422 let html = render_spec_to_html(&spec, &json!({}));
423 assert!(
424 html.contains("<!-- ferro-json-ui: unknown component type 'ImaginaryWidget' -->"),
425 "got: {html}"
426 );
427 }
428
429 #[test]
430 fn walker_missing_child_emits_diagnostic() {
431 let mut spec = Spec::builder()
435 .element("real", Element::new("Text"))
436 .build()
437 .expect("ok");
438 spec.root = "ghost".to_string();
439 let html = render_spec_to_html(&spec, &json!({}));
440 assert!(
441 html.contains("<!-- ferro-json-ui: element references missing id 'ghost' -->"),
442 "got: {html}"
443 );
444 }
445
446 #[test]
447 fn walker_root_hidden_emits_root_hidden_comment() {
448 let mut spec = Spec::builder()
449 .element("root", Element::new("Text"))
450 .build()
451 .expect("ok");
452 let el = spec.elements.get_mut("root").unwrap();
453 el.visible = Some(Visibility::Condition(VisibilityCondition {
454 path: "/show".into(),
455 operator: VisibilityOperator::Eq,
456 value: Some(json!(true)),
457 }));
458 let html = render_spec_to_html(&spec, &json!({"show": false}));
459 assert!(
460 html.contains("<!-- ferro-json-ui: root hidden -->"),
461 "got: {html}"
462 );
463 }
464
465 #[test]
466 fn walker_depth_tripwire_relative() {
467 let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
472 let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
473 assert!(html.contains("depth limit exceeded"), "got: {html}");
474 }
475
476 #[test]
477 fn walker_depth_tripwire() {
478 let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
484 let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
485 assert!(
486 html.contains("depth limit exceeded"),
487 "expected 'depth limit exceeded' in: {html}"
488 );
489 assert!(html.contains("max=16"), "expected 'max=16' in: {html}");
490 assert!(
491 !html.contains("cycle"),
492 "depth tripwire must not mention 'cycle'; got: {html}"
493 );
494 }
495
496 #[test]
497 fn walker_plugin_dispatch_invokes_with_plugin() {
498 struct TestPlugin;
499 impl JsonUiPlugin for TestPlugin {
500 fn component_type(&self) -> &str {
501 "FerroPhase116PluginDispatchTest"
502 }
503 fn props_schema(&self) -> serde_json::Value {
504 serde_json::json!({})
505 }
506 fn render(&self, _props: &Value, _data: &Value) -> String {
507 "<div data-test-plugin>X</div>".to_string()
508 }
509 fn css_assets(&self) -> Vec<Asset> {
510 Vec::new()
511 }
512 fn js_assets(&self) -> Vec<Asset> {
513 Vec::new()
514 }
515 fn init_script(&self) -> Option<String> {
516 None
517 }
518 }
519 register_plugin(TestPlugin);
520
521 let spec = build_spec_unchecked(
522 "root",
523 vec![("root", mk_element("FerroPhase116PluginDispatchTest"))],
524 );
525 let html = render_spec_to_html(&spec, &json!({}));
526 assert!(
527 html.contains("<div data-test-plugin>X</div>"),
528 "got: {html}"
529 );
530 }
531
532 #[test]
533 fn walker_plugin_asset_collection_returns_plugin_types() {
534 struct TestPluginB;
535 impl JsonUiPlugin for TestPluginB {
536 fn component_type(&self) -> &str {
537 "FerroPhase116AssetCollectTestPlugin"
538 }
539 fn props_schema(&self) -> serde_json::Value {
540 serde_json::json!({})
541 }
542 fn render(&self, _props: &Value, _data: &Value) -> String {
543 String::new()
544 }
545 fn css_assets(&self) -> Vec<Asset> {
546 Vec::new()
547 }
548 fn js_assets(&self) -> Vec<Asset> {
549 Vec::new()
550 }
551 fn init_script(&self) -> Option<String> {
552 None
553 }
554 }
555 register_plugin(TestPluginB);
556
557 let spec = build_spec_unchecked(
558 "root",
559 vec![
560 ("root", mk_element("Text")),
561 ("plug", mk_element("FerroPhase116AssetCollectTestPlugin")),
562 ],
563 );
564 let types = collect_plugin_types(&spec);
565 assert!(types.contains("FerroPhase116AssetCollectTestPlugin"));
566 assert!(!types.contains("Text"));
567 }
568
569 #[test]
570 fn walker_plugins_cannot_shadow_builtins() {
571 struct CardShadow;
575 impl JsonUiPlugin for CardShadow {
576 fn component_type(&self) -> &str {
577 "Card"
578 }
579 fn props_schema(&self) -> serde_json::Value {
580 serde_json::json!({})
581 }
582 fn render(&self, _props: &Value, _data: &Value) -> String {
583 "<div data-from-plugin>SHADOW</div>".to_string()
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(CardShadow);
596
597 let spec = build_spec_unchecked("root", vec![("root", mk_element("Card"))]);
598 let html = render_spec_to_html(&spec, &json!({}));
599 assert!(
600 !html.contains("data-from-plugin"),
601 "plugin must not shadow built-in Card; got: {html}"
602 );
603 }
604
605 #[test]
606 fn top_level_wrapper_present() {
607 let spec = build_spec_unchecked("root", vec![("root", mk_element("Text"))]);
608 let html = render_spec_to_html(&spec, &json!({}));
609 assert!(
610 html.starts_with("<div class=\"flex flex-wrap gap-4"),
611 "got: {html}"
612 );
613 assert!(html.ends_with("</div>"), "got: {html}");
614 }
615
616 #[test]
617 fn html_escape_basic() {
618 assert_eq!(html_escape("<script>"), "<script>");
619 assert_eq!(html_escape("a&b"), "a&b");
620 assert_eq!(html_escape("\"quoted\""), ""quoted"");
621 }
622
623 #[test]
624 fn builtin_types_count_matches_dispatch() {
625 assert_eq!(BUILTIN_TYPES.len(), 45);
630 }
631
632 #[test]
633 fn render_spec_with_stream_text_emits_init_script() {
634 let spec = Spec::builder()
635 .element(
636 "root",
637 Element::new("StreamText").prop("sse_url", "/stream"),
638 )
639 .build()
640 .expect("spec builds");
641 let result = render_spec_to_html_with_plugins(&spec, &json!({}));
642 assert!(
643 result.scripts.contains("EventSource"),
644 "init script must be present; got: {}",
645 result.scripts
646 );
647 assert!(
649 result.scripts.contains("createTextNode"),
650 "tokens must append via createTextNode; got: {}",
651 result.scripts
652 );
653 assert!(
654 !result.scripts.contains("innerHTML"),
655 "init script must never use innerHTML; got: {}",
656 result.scripts
657 );
658 assert!(
660 result.scripts.contains("'done'") && result.scripts.contains("close()"),
661 "init script must close on done event; got: {}",
662 result.scripts
663 );
664 }
665
666 #[test]
667 fn render_spec_without_stream_text_emits_no_init_script() {
668 let spec = Spec::builder()
669 .element("root", Element::new("Text").prop("content", "Hello"))
670 .build()
671 .expect("spec builds");
672 let result = render_spec_to_html_with_plugins(&spec, &json!({}));
673 assert!(
674 result.scripts.is_empty(),
675 "no init script when no StreamText; got: {}",
676 result.scripts
677 );
678 }
679}