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