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 "Card",
71 "Modal",
72 "Tabs",
73 "KanbanBoard",
74 "PageHeader",
75 "DetailPage",
76 "Grid",
77 "Collapsible",
78 "FormSection",
79 "ButtonGroup",
80 "Form",
82 "Input",
83 "Select",
84 "Checkbox",
85 "Switch",
86 "CheckboxList",
87 "CheckboxGroup",
88 "Table",
90 "DataTable",
91 "MediaCardGrid",
92];
93
94pub fn render_spec_to_html(spec: &Spec, data: &Value) -> String {
101 let body = render_element(&spec.root, spec, data, 1);
102 let body_or_root_hidden = if body.is_empty() && spec_root_was_hidden(spec, data) {
103 String::from("<!-- ferro-json-ui: root hidden -->")
104 } else {
105 body
106 };
107 format!(
108 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">{body_or_root_hidden}</div>"
109 )
110}
111
112pub fn render_spec_to_html_with_plugins(spec: &Spec, data: &Value) -> RenderResult {
115 let html = render_spec_to_html(spec, data);
116 let plugin_types = collect_plugin_types(spec);
117 if plugin_types.is_empty() {
118 return RenderResult {
119 html,
120 css_head: String::new(),
121 scripts: String::new(),
122 };
123 }
124 let type_names: Vec<String> = plugin_types.into_iter().collect();
125 let assets = collect_plugin_assets(&type_names);
126 RenderResult {
127 html,
128 css_head: render_css_tags(&assets.css),
129 scripts: render_js_tags(&assets.js, &assets.init_scripts),
130 }
131}
132
133pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
137 if depth > MAX_NESTING_DEPTH + 1 {
143 return format!(
144 "<!-- ferro-json-ui: depth limit exceeded at depth {depth} (max={MAX_NESTING_DEPTH}) — spec should have been rejected at parse time -->"
145 );
146 }
147
148 let Some(el) = spec.elements.get(id) else {
150 return format!(
151 "<!-- ferro-json-ui: element references missing id '{}' -->",
152 html_escape(id)
153 );
154 };
155
156 if let Some(vis) = &el.visible {
158 if !vis.evaluate(data) {
159 return String::new();
160 }
161 }
162
163 match el.type_name.as_str() {
165 "Text" => atoms::render_text(el, spec, data, depth),
167 "Button" => atoms::render_button(el, spec, data, depth),
168 "Badge" => atoms::render_badge(el, spec, data, depth),
169 "Alert" => atoms::render_alert(el, spec, data, depth),
170 "Separator" => atoms::render_separator(el, spec, data, depth),
171 "Progress" => atoms::render_progress(el, spec, data, depth),
172 "Avatar" => atoms::render_avatar(el, spec, data, depth),
173 "Image" => atoms::render_image(el, spec, data, depth),
174 "Skeleton" => atoms::render_skeleton(el, spec, data, depth),
175 "Breadcrumb" => atoms::render_breadcrumb(el, spec, data, depth),
176 "Pagination" => atoms::render_pagination(el, spec, data, depth),
177 "DescriptionList" => atoms::render_description_list(el, spec, data, depth),
178 "EmptyState" => atoms::render_empty_state(el, spec, data, depth),
179 "StatCard" => atoms::render_stat_card(el, spec, data, depth),
180 "Checklist" => atoms::render_checklist(el, spec, data, depth),
181 "Toast" => atoms::render_toast(el, spec, data, depth),
182 "NotificationDropdown" => atoms::render_notification_dropdown(el, spec, data, depth),
183 "Sidebar" => atoms::render_sidebar(el, spec, data, depth),
184 "Header" => atoms::render_header(el, spec, data, depth),
185 "DropdownMenu" => atoms::render_dropdown_menu(el, spec, data, depth),
186 "CalendarCell" => atoms::render_calendar_cell(el, spec, data, depth),
187 "ActionCard" => atoms::render_action_card(el, spec, data, depth),
188 "ProductTile" => atoms::render_product_tile(el, spec, data, depth),
189 "RawHtml" => atoms::render_raw_html(el, spec, data, depth),
190 "Card" => containers::render_card(el, spec, data, depth),
192 "Modal" => containers::render_modal(el, spec, data, depth),
193 "Tabs" => containers::render_tabs(el, spec, data, depth),
194 "KanbanBoard" => containers::render_kanban_board(el, spec, data, depth),
195 "PageHeader" => containers::render_page_header(el, spec, data, depth),
196 "DetailPage" => containers::render_detail_page(el, spec, data, depth),
197 "Grid" => containers::render_grid(el, spec, data, depth),
198 "Collapsible" => containers::render_collapsible(el, spec, data, depth),
199 "FormSection" => containers::render_form_section(el, spec, data, depth),
200 "ButtonGroup" => containers::render_button_group(el, spec, data, depth),
201 "Form" => form::render_form(el, spec, data, depth),
203 "Input" => form::render_input(el, spec, data, depth),
204 "Select" => form::render_select(el, spec, data, depth),
205 "Checkbox" => form::render_checkbox(el, spec, data, depth),
206 "Switch" => form::render_switch(el, spec, data, depth),
207 "CheckboxList" => form::render_checkbox_list(el, spec, data, depth),
208 "CheckboxGroup" => form::render_checkbox_list(el, spec, data, depth),
209 "Table" => data::render_table(el, spec, data, depth),
211 "DataTable" => data::render_data_table(el, spec, data, depth),
212 "MediaCardGrid" => data::render_media_card_grid(el, spec, data, depth),
213 other => render_plugin_or_unknown(other, el, data),
215 }
216}
217
218fn render_plugin_or_unknown(type_name: &str, el: &crate::spec::Element, data: &Value) -> String {
219 match with_plugin(type_name, |p| p.render(&el.props, data)) {
220 Some(html) => html,
221 None => format!(
222 "<!-- ferro-json-ui: unknown component type '{}' -->",
223 html_escape(type_name)
224 ),
225 }
226}
227
228fn spec_root_was_hidden(spec: &Spec, data: &Value) -> bool {
232 spec.elements
233 .get(&spec.root)
234 .and_then(|el| el.visible.as_ref())
235 .map(|vis| !vis.evaluate(data))
236 .unwrap_or(false)
237}
238
239pub(crate) fn collect_plugin_types(spec: &Spec) -> HashSet<String> {
243 let mut types = HashSet::new();
244 for el in spec.elements.values() {
245 if !BUILTIN_TYPES.contains(&el.type_name.as_str()) {
246 types.insert(el.type_name.clone());
247 }
248 }
249 types
250}
251
252pub(crate) fn html_escape(s: &str) -> String {
257 s.replace('&', "&")
258 .replace('<', "<")
259 .replace('>', ">")
260 .replace('"', """)
261 .replace('\'', "'")
262}
263
264pub(crate) fn render_css_tags(assets: &[Asset]) -> String {
269 let mut out = String::new();
270 for asset in assets {
271 out.push_str("<link rel=\"stylesheet\" href=\"");
272 out.push_str(&html_escape(&asset.url));
273 out.push('"');
274 if let Some(integrity) = &asset.integrity {
275 out.push_str(" integrity=\"");
276 out.push_str(&html_escape(integrity));
277 out.push('"');
278 }
279 if let Some(co) = &asset.crossorigin {
280 out.push_str(" crossorigin=\"");
281 out.push_str(&html_escape(co));
282 out.push('"');
283 }
284 out.push_str(">\n");
285 }
286 out
287}
288
289pub(crate) fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
294 let mut out = String::new();
295 for asset in assets {
296 out.push_str("<script src=\"");
297 out.push_str(&html_escape(&asset.url));
298 out.push('"');
299 if let Some(integrity) = &asset.integrity {
300 out.push_str(" integrity=\"");
301 out.push_str(&html_escape(integrity));
302 out.push('"');
303 }
304 if let Some(co) = &asset.crossorigin {
305 out.push_str(" crossorigin=\"");
306 out.push_str(&html_escape(co));
307 out.push('"');
308 }
309 out.push_str("></script>\n");
310 }
311 for init in init_scripts {
312 out.push_str("<script>");
313 out.push_str(init);
314 out.push_str("</script>\n");
315 }
316 out
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
324 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
325 use crate::spec::{Element, Spec};
326 use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
327 use serde_json::json;
328
329 fn mk_element(type_name: &str) -> Element {
333 Element {
334 type_name: type_name.to_string(),
335 props: Value::Null,
336 children: Vec::new(),
337 action: None,
338 visible: None,
339 each: None,
340 if_: None,
341 }
342 }
343
344 fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
348 let mut spec = Spec::builder()
351 .element("__tmp__", Element::new("Text"))
352 .build()
353 .expect("builder accepts trivial well-formed spec");
354 spec.root = root.to_string();
355 spec.elements.clear();
356 for (id, el) in elements {
357 spec.elements.insert(id.to_string(), el);
358 }
359 spec
360 }
361
362 #[test]
363 fn walker_unknown_type_emits_diagnostic() {
364 let spec = build_spec_unchecked("root", vec![("root", mk_element("ImaginaryWidget"))]);
365 let html = render_spec_to_html(&spec, &json!({}));
366 assert!(
367 html.contains("<!-- ferro-json-ui: unknown component type 'ImaginaryWidget' -->"),
368 "got: {html}"
369 );
370 }
371
372 #[test]
373 fn walker_missing_child_emits_diagnostic() {
374 let mut spec = Spec::builder()
378 .element("real", Element::new("Text"))
379 .build()
380 .expect("ok");
381 spec.root = "ghost".to_string();
382 let html = render_spec_to_html(&spec, &json!({}));
383 assert!(
384 html.contains("<!-- ferro-json-ui: element references missing id 'ghost' -->"),
385 "got: {html}"
386 );
387 }
388
389 #[test]
390 fn walker_root_hidden_emits_root_hidden_comment() {
391 let mut spec = Spec::builder()
392 .element("root", Element::new("Text"))
393 .build()
394 .expect("ok");
395 let el = spec.elements.get_mut("root").unwrap();
396 el.visible = Some(Visibility::Condition(VisibilityCondition {
397 path: "/show".into(),
398 operator: VisibilityOperator::Eq,
399 value: Some(json!(true)),
400 }));
401 let html = render_spec_to_html(&spec, &json!({"show": false}));
402 assert!(
403 html.contains("<!-- ferro-json-ui: root hidden -->"),
404 "got: {html}"
405 );
406 }
407
408 #[test]
409 fn walker_depth_tripwire_relative() {
410 let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
415 let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
416 assert!(html.contains("depth limit exceeded"), "got: {html}");
417 }
418
419 #[test]
420 fn walker_depth_tripwire() {
421 let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
427 let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
428 assert!(
429 html.contains("depth limit exceeded"),
430 "expected 'depth limit exceeded' in: {html}"
431 );
432 assert!(html.contains("max=16"), "expected 'max=16' in: {html}");
433 assert!(
434 !html.contains("cycle"),
435 "depth tripwire must not mention 'cycle'; got: {html}"
436 );
437 }
438
439 #[test]
440 fn walker_plugin_dispatch_invokes_with_plugin() {
441 struct TestPlugin;
442 impl JsonUiPlugin for TestPlugin {
443 fn component_type(&self) -> &str {
444 "FerroPhase116PluginDispatchTest"
445 }
446 fn props_schema(&self) -> serde_json::Value {
447 serde_json::json!({})
448 }
449 fn render(&self, _props: &Value, _data: &Value) -> String {
450 "<div data-test-plugin>X</div>".to_string()
451 }
452 fn css_assets(&self) -> Vec<Asset> {
453 Vec::new()
454 }
455 fn js_assets(&self) -> Vec<Asset> {
456 Vec::new()
457 }
458 fn init_script(&self) -> Option<String> {
459 None
460 }
461 }
462 register_plugin(TestPlugin);
463
464 let spec = build_spec_unchecked(
465 "root",
466 vec![("root", mk_element("FerroPhase116PluginDispatchTest"))],
467 );
468 let html = render_spec_to_html(&spec, &json!({}));
469 assert!(
470 html.contains("<div data-test-plugin>X</div>"),
471 "got: {html}"
472 );
473 }
474
475 #[test]
476 fn walker_plugin_asset_collection_returns_plugin_types() {
477 struct TestPluginB;
478 impl JsonUiPlugin for TestPluginB {
479 fn component_type(&self) -> &str {
480 "FerroPhase116AssetCollectTestPlugin"
481 }
482 fn props_schema(&self) -> serde_json::Value {
483 serde_json::json!({})
484 }
485 fn render(&self, _props: &Value, _data: &Value) -> String {
486 String::new()
487 }
488 fn css_assets(&self) -> Vec<Asset> {
489 Vec::new()
490 }
491 fn js_assets(&self) -> Vec<Asset> {
492 Vec::new()
493 }
494 fn init_script(&self) -> Option<String> {
495 None
496 }
497 }
498 register_plugin(TestPluginB);
499
500 let spec = build_spec_unchecked(
501 "root",
502 vec![
503 ("root", mk_element("Text")),
504 ("plug", mk_element("FerroPhase116AssetCollectTestPlugin")),
505 ],
506 );
507 let types = collect_plugin_types(&spec);
508 assert!(types.contains("FerroPhase116AssetCollectTestPlugin"));
509 assert!(!types.contains("Text"));
510 }
511
512 #[test]
513 fn walker_plugins_cannot_shadow_builtins() {
514 struct CardShadow;
518 impl JsonUiPlugin for CardShadow {
519 fn component_type(&self) -> &str {
520 "Card"
521 }
522 fn props_schema(&self) -> serde_json::Value {
523 serde_json::json!({})
524 }
525 fn render(&self, _props: &Value, _data: &Value) -> String {
526 "<div data-from-plugin>SHADOW</div>".to_string()
527 }
528 fn css_assets(&self) -> Vec<Asset> {
529 Vec::new()
530 }
531 fn js_assets(&self) -> Vec<Asset> {
532 Vec::new()
533 }
534 fn init_script(&self) -> Option<String> {
535 None
536 }
537 }
538 register_plugin(CardShadow);
539
540 let spec = build_spec_unchecked("root", vec![("root", mk_element("Card"))]);
541 let html = render_spec_to_html(&spec, &json!({}));
542 assert!(
543 !html.contains("data-from-plugin"),
544 "plugin must not shadow built-in Card; got: {html}"
545 );
546 }
547
548 #[test]
549 fn top_level_wrapper_present() {
550 let spec = build_spec_unchecked("root", vec![("root", mk_element("Text"))]);
551 let html = render_spec_to_html(&spec, &json!({}));
552 assert!(
553 html.starts_with("<div class=\"flex flex-wrap gap-4"),
554 "got: {html}"
555 );
556 assert!(html.ends_with("</div>"), "got: {html}");
557 }
558
559 #[test]
560 fn html_escape_basic() {
561 assert_eq!(html_escape("<script>"), "<script>");
562 assert_eq!(html_escape("a&b"), "a&b");
563 assert_eq!(html_escape("\"quoted\""), ""quoted"");
564 }
565
566 #[test]
567 fn builtin_types_count_matches_dispatch() {
568 assert_eq!(BUILTIN_TYPES.len(), 44);
573 }
574}