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 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    "StreamText",
70    // Containers (containers.rs)
71    "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 controls (form.rs)
84    "Form",
85    "Input",
86    "Select",
87    "Checkbox",
88    "Switch",
89    "CheckboxList",
90    "CheckboxGroup",
91    // Data displays (data.rs)
92    "Table",
93    "DataTable",
94    "MediaCardGrid",
95];
96
97/// Renders an entire `Spec` to a complete HTML response body. Walks from
98/// `spec.root` outward, escaping text content and substituting data bindings
99/// via JSON Pointer. Top-level output is wrapped in a `flex-wrap` container;
100/// the renderer does not emit `<html>` / `<head>` / `<body>` tags — the
101/// layout system supplies those. Always returns a `String`; never panics and
102/// never returns `Result`.
103pub 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
115/// Plugin-aware variant. Walks `spec.elements` to collect plugin type names,
116/// then asks the registry for their CSS/JS asset URLs. Also collects built-in
117/// init scripts (e.g. the `StreamText` EventSource wiring) and merges them
118/// into the scripts output even when no plugins are present.
119pub 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
145/// The one recursive function. All dispatch, visibility, depth-guard, and
146/// diagnostic logic lives here. The per-element pipeline is:
147/// (1) depth guard, (2) ID lookup, (3) visibility check, (4) dispatch.
148pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
149    // (1) Depth tripwire. Parse-time depth is capped at `MAX_NESTING_DEPTH = 16`;
150    // this fires only for hand-mutated Specs that bypassed `Spec::from_json`.
151    // Diagnostic names the limit so future failures are legible; this is a
152    // distinct condition from cycle detection (which lives in the parse-time
153    // validator and emits `SpecError::Cycle`).
154    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    // (2) ID lookup: missing IDs surface as an HTML comment.
161    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    // (3) Visibility check. Invisible → no output, no children walked.
169    if let Some(vis) = &el.visible {
170        if !vis.evaluate(data) {
171            return String::new();
172        }
173    }
174
175    // (4) Dispatch by type_name. Default arm consults plugin registry.
176    match el.type_name.as_str() {
177        // Atoms
178        "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        // Containers
204        "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 controls
217        "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        // Data displays
225        "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        // Plugin or unknown type name.
229        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
243/// Helper: detect whether `spec.root` exists and has a visibility rule that
244/// evaluates false. Used to choose between empty body and the root-hidden
245/// diagnostic comment.
246fn 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
254/// Walks `spec.elements` and collects every plugin type name encountered
255/// (every `Element.type_name` not present in [`BUILTIN_TYPES`]). Used by the
256/// asset-collection pipeline to determine which plugin CSS/JS to inject.
257pub(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
267/// Dependency-free inline EventSource wiring for `StreamText` components.
268/// Skips elements with an empty URL, appends streamed tokens as text nodes
269/// (never `innerHTML`), removes the placeholder on the first token (or on
270/// `done` for an empty stream), and closes the source on `event: done` to
271/// prevent `EventSource` auto-reconnect. Emitted at most once per page.
272const 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
296/// Returns the StreamText EventSource init script if the spec contains at least
297/// one `StreamText` element; otherwise an empty `Vec`. Walks `spec.elements`
298/// the same way `collect_plugin_types` does. Returns at most one entry so the
299/// script is emitted exactly once regardless of how many StreamText elements
300/// the spec contains.
301fn 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
313/// HTML-escapes interpolated identifiers in diagnostic comments and any prop
314/// content interpolated into emitted markup. Every string that crosses from
315/// a `Spec` or `data` into the HTML output is required to pass through this
316/// function.
317pub(crate) fn html_escape(s: &str) -> String {
318    s.replace('&', "&amp;")
319        .replace('<', "&lt;")
320        .replace('>', "&gt;")
321        .replace('"', "&quot;")
322        .replace('\'', "&#x27;")
323}
324
325/// Emits one `<link rel="stylesheet" href="..." [integrity] [crossorigin]>`
326/// tag per CSS [`Asset`]. URLs and attribute values pass through
327/// [`html_escape`]; `Asset.crossorigin` is an `Option<String>` (e.g.
328/// `Some("anonymous")`) — emitted when present.
329pub(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
350/// Emits one `<script src="..." [integrity] [crossorigin]></script>` tag per
351/// JS [`Asset`], followed by one `<script>{init}</script>` tag per registered
352/// plugin init script (in registration order). URLs and attribute values
353/// pass through [`html_escape`].
354pub(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    // Walker-level tests live in this module. Per-component HTML emission tests
383    // live in atoms/containers/form/data submodules.
384    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    /// Construct an `Element` directly from its public fields. Used when tests
391    /// need to bypass the parse-time structural validator (e.g. for
392    /// dangling-child or cycle scenarios).
393    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    /// Build a `Spec` whose elements map is overwritten post-build to bypass
406    /// the parse-time structural validator. This is ONLY for testing the
407    /// walker's defense-in-depth guards on hand-mutated specs.
408    fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
409        // Build a minimal valid spec through the normal builder so we get a
410        // correctly-initialized Spec shell; then overwrite root + elements.
411        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        // The simplest way to force the missing-child diagnostic without needing
436        // a real container renderer is to point the spec's root at an ID that
437        // isn't in the elements map.
438        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        // A self-cycle is rejected at parse time, so call the walker directly
472        // with a depth exceeding MAX_NESTING_DEPTH + 1 to exercise the
473        // defense-in-depth tripwire. After the diagnostic split (Task 2), the
474        // output must say "depth limit exceeded", not "cycle guard tripped".
475        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        // Direct invocation of render_element at depth MAX_NESTING_DEPTH + 2
483        // fires the walker tripwire. The output must:
484        //   - contain "depth limit exceeded"
485        //   - contain "max=16"
486        //   - NOT contain "cycle"
487        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        // Register a plugin that claims the built-in type name "Card".
576        // The dispatch match must still route to the built-in renderer, not
577        // to the plugin.
578        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>"), "&lt;script&gt;");
623        assert_eq!(html_escape("a&b"), "a&amp;b");
624        assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
625    }
626
627    #[test]
628    fn builtin_types_have_no_duplicates() {
629        // The dispatch match in `render_element` has one arm per BUILTIN_TYPES
630        // entry (arm coverage is compile-enforced by rustc). The remaining
631        // runtime risk is a DUPLICATE entry — a shadowed dispatch arm or a
632        // double catalog spec — which this guards relationally (no magic count;
633        // the absolute count is pinned once in
634        // catalog::tests::builtin_types_count_drift_guard).
635        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        // T-169-02 / T-169-03: tokens appended as text nodes, never parsed as HTML.
657        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        // D-03: source closes on `done` to prevent reconnect loop.
668        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}