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