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 42 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];
92
93/// Renders an entire `Spec` to a complete HTML response body. Walks from
94/// `spec.root` outward, escaping text content and substituting data bindings
95/// via JSON Pointer. Top-level output is wrapped in a `flex-wrap` container;
96/// the renderer does not emit `<html>` / `<head>` / `<body>` tags — the
97/// layout system supplies those. Always returns a `String`; never panics and
98/// never returns `Result`.
99pub 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
111/// Plugin-aware variant. Walks `spec.elements` to collect plugin type names,
112/// then asks the registry for their CSS/JS asset URLs.
113pub 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
132/// The one recursive function. All dispatch, visibility, depth-guard, and
133/// diagnostic logic lives here. The per-element pipeline is:
134/// (1) depth guard, (2) ID lookup, (3) visibility check, (4) dispatch.
135pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
136    // (1) Depth tripwire. Parse-time depth is capped at `MAX_NESTING_DEPTH = 16`;
137    // this fires only for hand-mutated Specs that bypassed `Spec::from_json`.
138    // Diagnostic names the limit so future failures are legible; this is a
139    // distinct condition from cycle detection (which lives in the parse-time
140    // validator and emits `SpecError::Cycle`).
141    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    // (2) ID lookup: missing IDs surface as an HTML comment.
148    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    // (3) Visibility check. Invisible → no output, no children walked.
156    if let Some(vis) = &el.visible {
157        if !vis.evaluate(data) {
158            return String::new();
159        }
160    }
161
162    // (4) Dispatch by type_name. Default arm consults plugin registry.
163    match el.type_name.as_str() {
164        // Atoms
165        "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        // Containers
190        "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 controls
201        "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        // Data displays
209        "Table" => data::render_table(el, spec, data, depth),
210        "DataTable" => data::render_data_table(el, spec, data, depth),
211        // Plugin or unknown type name.
212        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
226/// Helper: detect whether `spec.root` exists and has a visibility rule that
227/// evaluates false. Used to choose between empty body and the root-hidden
228/// diagnostic comment.
229fn 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
237/// Walks `spec.elements` and collects every plugin type name encountered
238/// (every `Element.type_name` not present in [`BUILTIN_TYPES`]). Used by the
239/// asset-collection pipeline to determine which plugin CSS/JS to inject.
240pub(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
250/// HTML-escapes interpolated identifiers in diagnostic comments and any prop
251/// content interpolated into emitted markup. Every string that crosses from
252/// a `Spec` or `data` into the HTML output is required to pass through this
253/// function.
254pub(crate) fn html_escape(s: &str) -> String {
255    s.replace('&', "&amp;")
256        .replace('<', "&lt;")
257        .replace('>', "&gt;")
258        .replace('"', "&quot;")
259        .replace('\'', "&#x27;")
260}
261
262/// Emits one `<link rel="stylesheet" href="..." [integrity] [crossorigin]>`
263/// tag per CSS [`Asset`]. URLs and attribute values pass through
264/// [`html_escape`]; `Asset.crossorigin` is an `Option<String>` (e.g.
265/// `Some("anonymous")`) — emitted when present.
266pub(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
287/// Emits one `<script src="..." [integrity] [crossorigin]></script>` tag per
288/// JS [`Asset`], followed by one `<script>{init}</script>` tag per registered
289/// plugin init script (in registration order). URLs and attribute values
290/// pass through [`html_escape`].
291pub(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    // Walker-level tests live in this module. Per-component HTML emission tests
320    // live in atoms/containers/form/data submodules.
321    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    /// Construct an `Element` directly from its public fields. Used when tests
328    /// need to bypass the parse-time structural validator (e.g. for
329    /// dangling-child or cycle scenarios).
330    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    /// Build a `Spec` whose elements map is overwritten post-build to bypass
343    /// the parse-time structural validator. This is ONLY for testing the
344    /// walker's defense-in-depth guards on hand-mutated specs.
345    fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
346        // Build a minimal valid spec through the normal builder so we get a
347        // correctly-initialized Spec shell; then overwrite root + elements.
348        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        // The simplest way to force the missing-child diagnostic without needing
373        // a real container renderer is to point the spec's root at an ID that
374        // isn't in the elements map.
375        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        // A self-cycle is rejected at parse time, so call the walker directly
409        // with a depth exceeding MAX_NESTING_DEPTH + 1 to exercise the
410        // defense-in-depth tripwire. After the diagnostic split (Task 2), the
411        // output must say "depth limit exceeded", not "cycle guard tripped".
412        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        // Direct invocation of render_element at depth MAX_NESTING_DEPTH + 2
420        // fires the walker tripwire. The output must:
421        //   - contain "depth limit exceeded"
422        //   - contain "max=16"
423        //   - NOT contain "cycle"
424        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        // Register a plugin that claims the built-in type name "Card".
513        // The dispatch match must still route to the built-in renderer, not
514        // to the plugin.
515        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>"), "&lt;script&gt;");
560        assert_eq!(html_escape("a&b"), "a&amp;b");
561        assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
562    }
563
564    #[test]
565    fn builtin_types_count_matches_dispatch() {
566        // Defense-in-depth check: BUILTIN_TYPES must be 43 entries.
567        // The dispatch match in `render_element` has one arm per entry plus a
568        // default arm. A compile-time mismatch would be caught by rustc; this
569        // runtime check pins the invariant for future edits.
570        assert_eq!(BUILTIN_TYPES.len(), 43);
571    }
572}