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