Skip to main content

ferro_json_ui/render/
mod.rs

1//! Renders a `Spec` to HTML.
2//!
3//! Walks `spec.elements` by ID starting at `spec.root`, dispatches per-element
4//! by `type_name` against [`BUILTIN_TYPES`] (or the plugin registry for any
5//! type name not in that list), and lets each container recurse via
6//! `render_element` for its child IDs. The renderer is infallible — every
7//! failure path (missing ID, decode error, depth overflow) emits an HTML
8//! comment and returns an empty string for the offending element rather than
9//! panicking.
10//!
11//! Per-component bodies live in:
12//! - `render/atoms.rs` — leaf renderers
13//! - `render/containers.rs` — multi-child layout components
14//! - `render/form.rs` — `Form`, `Input`, `Select`, `Checkbox`, `Switch`,
15//!   `CheckboxList`
16//! - `render/data.rs` — `Table`, `DataTable`
17
18use 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
29/// Plugin-asset bundle returned by `render_spec_to_html_with_plugins`.
30pub struct RenderResult {
31    pub html: String,
32    pub css_head: String,
33    pub scripts: String,
34}
35
36/// Single source of truth for the 44 built-in element type names recognized
37/// by the renderer. Plugins cannot register a type name that shadows an entry
38/// here — if `type_name` matches an entry, the dispatch match arm wins
39/// regardless of plugin registry contents.
40///
41/// Order matches the dispatch match below for reviewability. Adding a new
42/// built-in requires updating BOTH this list AND the dispatch arm.
43pub(crate) const BUILTIN_TYPES: &[&str] = &[
44    // Leaves (atoms.rs)
45    "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    // Containers (containers.rs)
70    "Card",
71    "Modal",
72    "Tabs",
73    "KanbanBoard",
74    "PageHeader",
75    "DetailPage",
76    "Grid",
77    "Collapsible",
78    "FormSection",
79    "ButtonGroup",
80    // Form controls (form.rs)
81    "Form",
82    "Input",
83    "Select",
84    "Checkbox",
85    "Switch",
86    "CheckboxList",
87    "CheckboxGroup",
88    // Data displays (data.rs)
89    "Table",
90    "DataTable",
91    "MediaCardGrid",
92];
93
94/// Renders an entire `Spec` to a complete HTML response body. Walks from
95/// `spec.root` outward, escaping text content and substituting data bindings
96/// via JSON Pointer. Top-level output is wrapped in a `flex-wrap` container;
97/// the renderer does not emit `<html>` / `<head>` / `<body>` tags — the
98/// layout system supplies those. Always returns a `String`; never panics and
99/// never returns `Result`.
100pub 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
112/// Plugin-aware variant. Walks `spec.elements` to collect plugin type names,
113/// then asks the registry for their CSS/JS asset URLs.
114pub 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
133/// The one recursive function. All dispatch, visibility, depth-guard, and
134/// diagnostic logic lives here. The per-element pipeline is:
135/// (1) depth guard, (2) ID lookup, (3) visibility check, (4) dispatch.
136pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
137    // (1) Depth tripwire. Parse-time depth is capped at `MAX_NESTING_DEPTH = 16`;
138    // this fires only for hand-mutated Specs that bypassed `Spec::from_json`.
139    // Diagnostic names the limit so future failures are legible; this is a
140    // distinct condition from cycle detection (which lives in the parse-time
141    // validator and emits `SpecError::Cycle`).
142    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    // (2) ID lookup: missing IDs surface as an HTML comment.
149    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    // (3) Visibility check. Invisible → no output, no children walked.
157    if let Some(vis) = &el.visible {
158        if !vis.evaluate(data) {
159            return String::new();
160        }
161    }
162
163    // (4) Dispatch by type_name. Default arm consults plugin registry.
164    match el.type_name.as_str() {
165        // Atoms
166        "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        // Containers
191        "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 controls
202        "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        // Data displays
210        "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        // Plugin or unknown type name.
214        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
228/// Helper: detect whether `spec.root` exists and has a visibility rule that
229/// evaluates false. Used to choose between empty body and the root-hidden
230/// diagnostic comment.
231fn 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
239/// Walks `spec.elements` and collects every plugin type name encountered
240/// (every `Element.type_name` not present in [`BUILTIN_TYPES`]). Used by the
241/// asset-collection pipeline to determine which plugin CSS/JS to inject.
242pub(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
252/// HTML-escapes interpolated identifiers in diagnostic comments and any prop
253/// content interpolated into emitted markup. Every string that crosses from
254/// a `Spec` or `data` into the HTML output is required to pass through this
255/// function.
256pub(crate) fn html_escape(s: &str) -> String {
257    s.replace('&', "&amp;")
258        .replace('<', "&lt;")
259        .replace('>', "&gt;")
260        .replace('"', "&quot;")
261        .replace('\'', "&#x27;")
262}
263
264/// Emits one `<link rel="stylesheet" href="..." [integrity] [crossorigin]>`
265/// tag per CSS [`Asset`]. URLs and attribute values pass through
266/// [`html_escape`]; `Asset.crossorigin` is an `Option<String>` (e.g.
267/// `Some("anonymous")`) — emitted when present.
268pub(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
289/// Emits one `<script src="..." [integrity] [crossorigin]></script>` tag per
290/// JS [`Asset`], followed by one `<script>{init}</script>` tag per registered
291/// plugin init script (in registration order). URLs and attribute values
292/// pass through [`html_escape`].
293pub(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    // Walker-level tests live in this module. Per-component HTML emission tests
322    // live in atoms/containers/form/data submodules.
323    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    /// Construct an `Element` directly from its public fields. Used when tests
330    /// need to bypass the parse-time structural validator (e.g. for
331    /// dangling-child or cycle scenarios).
332    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    /// Build a `Spec` whose elements map is overwritten post-build to bypass
345    /// the parse-time structural validator. This is ONLY for testing the
346    /// walker's defense-in-depth guards on hand-mutated specs.
347    fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
348        // Build a minimal valid spec through the normal builder so we get a
349        // correctly-initialized Spec shell; then overwrite root + elements.
350        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        // The simplest way to force the missing-child diagnostic without needing
375        // a real container renderer is to point the spec's root at an ID that
376        // isn't in the elements map.
377        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        // A self-cycle is rejected at parse time, so call the walker directly
411        // with a depth exceeding MAX_NESTING_DEPTH + 1 to exercise the
412        // defense-in-depth tripwire. After the diagnostic split (Task 2), the
413        // output must say "depth limit exceeded", not "cycle guard tripped".
414        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        // Direct invocation of render_element at depth MAX_NESTING_DEPTH + 2
422        // fires the walker tripwire. The output must:
423        //   - contain "depth limit exceeded"
424        //   - contain "max=16"
425        //   - NOT contain "cycle"
426        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        // Register a plugin that claims the built-in type name "Card".
515        // The dispatch match must still route to the built-in renderer, not
516        // to the plugin.
517        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>"), "&lt;script&gt;");
562        assert_eq!(html_escape("a&b"), "a&amp;b");
563        assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
564    }
565
566    #[test]
567    fn builtin_types_count_matches_dispatch() {
568        // Defense-in-depth check: BUILTIN_TYPES must be 44 entries.
569        // The dispatch match in `render_element` has one arm per entry plus a
570        // default arm. A compile-time mismatch would be caught by rustc; this
571        // runtime check pins the invariant for future edits.
572        assert_eq!(BUILTIN_TYPES.len(), 44);
573    }
574}