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 classes;
26pub(crate) mod containers;
27pub(crate) mod data;
28pub(crate) mod form;
29
30/// Plugin-asset bundle returned by `render_spec_to_html_with_plugins`.
31pub struct RenderResult {
32    pub html: String,
33    pub css_head: String,
34    pub scripts: String,
35}
36
37/// Single source of truth for the built-in element type names recognized
38/// by the renderer. Plugins cannot register a type name that shadows an entry
39/// here — if `type_name` matches an entry, the dispatch match arm wins
40/// regardless of plugin registry contents.
41///
42/// Order matches the dispatch match below for reviewability. Adding a new
43/// built-in requires updating BOTH this list AND the dispatch arm.
44pub(crate) const BUILTIN_TYPES: &[&str] = &[
45    // Leaves (atoms.rs)
46    "Text",
47    "Button",
48    "Badge",
49    "Alert",
50    "Separator",
51    "Progress",
52    "Avatar",
53    "Image",
54    "Skeleton",
55    "Breadcrumb",
56    "Pagination",
57    "DescriptionList",
58    "EmptyState",
59    "StatCard",
60    "Checklist",
61    "Toast",
62    "NotificationDropdown",
63    "Sidebar",
64    "Header",
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    "ActionGroup",
84    // Form controls (form.rs)
85    "Form",
86    "Input",
87    "Select",
88    "Checkbox",
89    "Switch",
90    "CheckboxList",
91    "CheckboxGroup",
92    // Data displays (data.rs)
93    "Table",
94    "DataTable",
95    "MediaCardGrid",
96];
97
98/// Renders an entire `Spec` to a complete HTML response body. Walks from
99/// `spec.root` outward, escaping text content and substituting data bindings
100/// via JSON Pointer. Top-level output is wrapped in a `flex-wrap` container;
101/// the renderer does not emit `<html>` / `<head>` / `<body>` tags — the
102/// layout system supplies those. Always returns a `String`; never panics and
103/// never returns `Result`.
104pub fn render_spec_to_html(spec: &Spec, data: &Value) -> String {
105    let body = render_element(&spec.root, spec, data, 1);
106    let body_or_root_hidden = if body.is_empty() && spec_root_was_hidden(spec, data) {
107        String::from("<!-- ferro-json-ui: root hidden -->")
108    } else {
109        body
110    };
111    format!(
112        "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">{body_or_root_hidden}</div>"
113    )
114}
115
116/// Plugin-aware variant. Walks `spec.elements` to collect plugin type names,
117/// then asks the registry for their CSS/JS asset URLs. Also collects built-in
118/// init scripts (e.g. the `StreamText` EventSource wiring) and merges them
119/// into the scripts output even when no plugins are present.
120pub fn render_spec_to_html_with_plugins(spec: &Spec, data: &Value) -> RenderResult {
121    let html = render_spec_to_html(spec, data);
122    let builtin_scripts = collect_builtin_init_scripts(spec);
123    let plugin_types = collect_plugin_types(spec);
124    if plugin_types.is_empty() && builtin_scripts.is_empty() {
125        return RenderResult {
126            html,
127            css_head: String::new(),
128            scripts: String::new(),
129        };
130    }
131    let type_names: Vec<String> = plugin_types.into_iter().collect();
132    let assets = collect_plugin_assets(&type_names);
133    let all_init_scripts: Vec<String> = assets
134        .init_scripts
135        .iter()
136        .chain(builtin_scripts.iter())
137        .cloned()
138        .collect();
139    RenderResult {
140        html,
141        css_head: render_css_tags(&assets.css),
142        scripts: render_js_tags(&assets.js, &all_init_scripts),
143    }
144}
145
146/// The one recursive function. All dispatch, visibility, depth-guard, and
147/// diagnostic logic lives here. The per-element pipeline is:
148/// (1) depth guard, (2) ID lookup, (3) visibility check, (4) dispatch.
149pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
150    // (1) Depth tripwire. Parse-time depth is capped at `MAX_NESTING_DEPTH = 16`;
151    // this fires only for hand-mutated Specs that bypassed `Spec::from_json`.
152    // Diagnostic names the limit so future failures are legible; this is a
153    // distinct condition from cycle detection (which lives in the parse-time
154    // validator and emits `SpecError::Cycle`).
155    if depth > MAX_NESTING_DEPTH + 1 {
156        return format!(
157            "<!-- ferro-json-ui: depth limit exceeded at depth {depth} (max={MAX_NESTING_DEPTH}) — spec should have been rejected at parse time -->"
158        );
159    }
160
161    // (2) ID lookup: missing IDs surface as an HTML comment.
162    let Some(el) = spec.elements.get(id) else {
163        return format!(
164            "<!-- ferro-json-ui: element references missing id '{}' -->",
165            html_escape(id)
166        );
167    };
168
169    // (3) Visibility check. Invisible → no output, no children walked.
170    if let Some(vis) = &el.visible {
171        if !vis.evaluate(data) {
172            return String::new();
173        }
174    }
175
176    // (4) Dispatch by type_name. Default arm consults plugin registry.
177    match el.type_name.as_str() {
178        // Atoms
179        "Text" => atoms::render_text(el, spec, data, depth),
180        "Button" => atoms::render_button(el, spec, data, depth),
181        "Badge" => atoms::render_badge(el, spec, data, depth),
182        "Alert" => atoms::render_alert(el, spec, data, depth),
183        "Separator" => atoms::render_separator(el, spec, data, depth),
184        "Progress" => atoms::render_progress(el, spec, data, depth),
185        "Avatar" => atoms::render_avatar(el, spec, data, depth),
186        "Image" => atoms::render_image(el, spec, data, depth),
187        "Skeleton" => atoms::render_skeleton(el, spec, data, depth),
188        "Breadcrumb" => atoms::render_breadcrumb(el, spec, data, depth),
189        "Pagination" => atoms::render_pagination(el, spec, data, depth),
190        "DescriptionList" => atoms::render_description_list(el, spec, data, depth),
191        "EmptyState" => atoms::render_empty_state(el, spec, data, depth),
192        "StatCard" => atoms::render_stat_card(el, spec, data, depth),
193        "Checklist" => atoms::render_checklist(el, spec, data, depth),
194        "Toast" => atoms::render_toast(el, spec, data, depth),
195        "NotificationDropdown" => atoms::render_notification_dropdown(el, spec, data, depth),
196        "Sidebar" => atoms::render_sidebar(el, spec, data, depth),
197        "Header" => atoms::render_header(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        "ActionGroup" => containers::render_action_group(el, spec, data, depth),
217        // Form controls
218        "Form" => form::render_form(el, spec, data, depth),
219        "Input" => form::render_input(el, spec, data, depth),
220        "Select" => form::render_select(el, spec, data, depth),
221        "Checkbox" => form::render_checkbox(el, spec, data, depth),
222        "Switch" => form::render_switch(el, spec, data, depth),
223        "CheckboxList" => form::render_checkbox_list(el, spec, data, depth),
224        "CheckboxGroup" => form::render_checkbox_list(el, spec, data, depth),
225        // Data displays
226        "Table" => data::render_table(el, spec, data, depth),
227        "DataTable" => data::render_data_table(el, spec, data, depth),
228        "MediaCardGrid" => data::render_media_card_grid(el, spec, data, depth),
229        // Plugin or unknown type name.
230        other => render_plugin_or_unknown(other, el, data),
231    }
232}
233
234fn render_plugin_or_unknown(type_name: &str, el: &crate::spec::Element, data: &Value) -> String {
235    match with_plugin(type_name, |p| p.render(&el.props, data)) {
236        Some(html) => html,
237        None => format!(
238            "<!-- ferro-json-ui: unknown component type '{}' -->",
239            html_escape(type_name)
240        ),
241    }
242}
243
244/// Helper: detect whether `spec.root` exists and has a visibility rule that
245/// evaluates false. Used to choose between empty body and the root-hidden
246/// diagnostic comment.
247fn spec_root_was_hidden(spec: &Spec, data: &Value) -> bool {
248    spec.elements
249        .get(&spec.root)
250        .and_then(|el| el.visible.as_ref())
251        .map(|vis| !vis.evaluate(data))
252        .unwrap_or(false)
253}
254
255/// Walks `spec.elements` and collects every plugin type name encountered
256/// (every `Element.type_name` not present in [`BUILTIN_TYPES`]). Used by the
257/// asset-collection pipeline to determine which plugin CSS/JS to inject.
258pub(crate) fn collect_plugin_types(spec: &Spec) -> HashSet<String> {
259    let mut types = HashSet::new();
260    for el in spec.elements.values() {
261        if !BUILTIN_TYPES.contains(&el.type_name.as_str()) {
262            types.insert(el.type_name.clone());
263        }
264    }
265    types
266}
267
268/// Dependency-free inline EventSource wiring for `StreamText` components.
269/// Skips elements with an empty URL, appends streamed tokens as text nodes
270/// (never `innerHTML`), removes the placeholder on the first token (or on
271/// `done` for an empty stream), and closes the source on `event: done` to
272/// prevent `EventSource` auto-reconnect. Emitted at most once per page.
273const FERRO_STREAM_TEXT_INIT: &str = r#"(function(){
274  document.querySelectorAll('[data-ferro-stream-url]').forEach(function(el){
275    var url = el.dataset.ferroStreamUrl;
276    if(!url) return;
277    var src = new EventSource(url);
278    var placeholder = el.querySelector('[data-ferro-stream-placeholder]');
279    var loading = el.querySelector('[data-ferro-stream-loading]');
280    var firstToken = true;
281    src.onmessage = function(e){
282      if(firstToken){ firstToken=false; if(placeholder) placeholder.remove(); }
283      el.appendChild(document.createTextNode(e.data));
284    };
285    src.addEventListener('done', function(){
286      src.close();
287      if(placeholder) placeholder.remove();
288      if(loading) loading.remove();
289    });
290    src.onerror = function(){
291      src.close();
292      if(loading) loading.remove();
293    };
294  });
295})();"#;
296
297/// Returns the StreamText EventSource init script if the spec contains at least
298/// one `StreamText` element; otherwise an empty `Vec`. Walks `spec.elements`
299/// the same way `collect_plugin_types` does. Returns at most one entry so the
300/// script is emitted exactly once regardless of how many StreamText elements
301/// the spec contains.
302fn collect_builtin_init_scripts(spec: &Spec) -> Vec<String> {
303    let has_stream_text = spec
304        .elements
305        .values()
306        .any(|el| el.type_name == "StreamText");
307    if has_stream_text {
308        vec![FERRO_STREAM_TEXT_INIT.to_string()]
309    } else {
310        vec![]
311    }
312}
313
314/// HTML-escapes interpolated identifiers in diagnostic comments and any prop
315/// content interpolated into emitted markup. Every string that crosses from
316/// a `Spec` or `data` into the HTML output is required to pass through this
317/// function.
318pub(crate) fn html_escape(s: &str) -> String {
319    s.replace('&', "&amp;")
320        .replace('<', "&lt;")
321        .replace('>', "&gt;")
322        .replace('"', "&quot;")
323        .replace('\'', "&#x27;")
324}
325
326/// Emits one `<link rel="stylesheet" href="..." [integrity] [crossorigin]>`
327/// tag per CSS [`Asset`]. URLs and attribute values pass through
328/// [`html_escape`]; `Asset.crossorigin` is an `Option<String>` (e.g.
329/// `Some("anonymous")`) — emitted when present.
330pub(crate) fn render_css_tags(assets: &[Asset]) -> String {
331    let mut out = String::new();
332    for asset in assets {
333        out.push_str("<link rel=\"stylesheet\" href=\"");
334        out.push_str(&html_escape(&asset.url));
335        out.push('"');
336        if let Some(integrity) = &asset.integrity {
337            out.push_str(" integrity=\"");
338            out.push_str(&html_escape(integrity));
339            out.push('"');
340        }
341        if let Some(co) = &asset.crossorigin {
342            out.push_str(" crossorigin=\"");
343            out.push_str(&html_escape(co));
344            out.push('"');
345        }
346        out.push_str(">\n");
347    }
348    out
349}
350
351/// Emits one `<script src="..." [integrity] [crossorigin]></script>` tag per
352/// JS [`Asset`], followed by one `<script>{init}</script>` tag per registered
353/// plugin init script (in registration order). URLs and attribute values
354/// pass through [`html_escape`].
355pub(crate) fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
356    let mut out = String::new();
357    for asset in assets {
358        out.push_str("<script src=\"");
359        out.push_str(&html_escape(&asset.url));
360        out.push('"');
361        if let Some(integrity) = &asset.integrity {
362            out.push_str(" integrity=\"");
363            out.push_str(&html_escape(integrity));
364            out.push('"');
365        }
366        if let Some(co) = &asset.crossorigin {
367            out.push_str(" crossorigin=\"");
368            out.push_str(&html_escape(co));
369            out.push('"');
370        }
371        out.push_str("></script>\n");
372    }
373    for init in init_scripts {
374        out.push_str("<script>");
375        out.push_str(init);
376        out.push_str("</script>\n");
377    }
378    out
379}
380
381#[cfg(test)]
382mod tests {
383    // Walker-level tests live in this module. Per-component HTML emission tests
384    // live in atoms/containers/form/data submodules.
385    use super::*;
386    use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
387    use crate::spec::{Element, Spec};
388    use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
389    use serde_json::json;
390
391    /// Construct an `Element` directly from its public fields. Used when tests
392    /// need to bypass the parse-time structural validator (e.g. for
393    /// dangling-child or cycle scenarios).
394    fn mk_element(type_name: &str) -> Element {
395        Element {
396            type_name: type_name.to_string(),
397            props: Value::Null,
398            children: Vec::new(),
399            action: None,
400            visible: None,
401            each: None,
402            if_: None,
403        }
404    }
405
406    /// Build a `Spec` whose elements map is overwritten post-build to bypass
407    /// the parse-time structural validator. This is ONLY for testing the
408    /// walker's defense-in-depth guards on hand-mutated specs.
409    fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
410        // Build a minimal valid spec through the normal builder so we get a
411        // correctly-initialized Spec shell; then overwrite root + elements.
412        let mut spec = Spec::builder()
413            .element("__tmp__", Element::new("Text"))
414            .build()
415            .expect("builder accepts trivial well-formed spec");
416        spec.root = root.to_string();
417        spec.elements.clear();
418        for (id, el) in elements {
419            spec.elements.insert(id.to_string(), el);
420        }
421        spec
422    }
423
424    #[test]
425    fn walker_unknown_type_emits_diagnostic() {
426        let spec = build_spec_unchecked("root", vec![("root", mk_element("ImaginaryWidget"))]);
427        let html = render_spec_to_html(&spec, &json!({}));
428        assert!(
429            html.contains("<!-- ferro-json-ui: unknown component type 'ImaginaryWidget' -->"),
430            "got: {html}"
431        );
432    }
433
434    #[test]
435    fn walker_missing_child_emits_diagnostic() {
436        // The simplest way to force the missing-child diagnostic without needing
437        // a real container renderer is to point the spec's root at an ID that
438        // isn't in the elements map.
439        let mut spec = Spec::builder()
440            .element("real", Element::new("Text"))
441            .build()
442            .expect("ok");
443        spec.root = "ghost".to_string();
444        let html = render_spec_to_html(&spec, &json!({}));
445        assert!(
446            html.contains("<!-- ferro-json-ui: element references missing id 'ghost' -->"),
447            "got: {html}"
448        );
449    }
450
451    #[test]
452    fn walker_root_hidden_emits_root_hidden_comment() {
453        let mut spec = Spec::builder()
454            .element("root", Element::new("Text"))
455            .build()
456            .expect("ok");
457        let el = spec.elements.get_mut("root").unwrap();
458        el.visible = Some(Visibility::Condition(VisibilityCondition {
459            path: "/show".into(),
460            operator: VisibilityOperator::Eq,
461            value: Some(json!(true)),
462        }));
463        let html = render_spec_to_html(&spec, &json!({"show": false}));
464        assert!(
465            html.contains("<!-- ferro-json-ui: root hidden -->"),
466            "got: {html}"
467        );
468    }
469
470    #[test]
471    fn walker_depth_tripwire_relative() {
472        // A self-cycle is rejected at parse time, so call the walker directly
473        // with a depth exceeding MAX_NESTING_DEPTH + 1 to exercise the
474        // defense-in-depth tripwire. After the diagnostic split (Task 2), the
475        // output must say "depth limit exceeded", not "cycle guard tripped".
476        let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
477        let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
478        assert!(html.contains("depth limit exceeded"), "got: {html}");
479    }
480
481    #[test]
482    fn walker_depth_tripwire() {
483        // Direct invocation of render_element at depth MAX_NESTING_DEPTH + 2
484        // fires the walker tripwire. The output must:
485        //   - contain "depth limit exceeded"
486        //   - contain "max=16"
487        //   - NOT contain "cycle"
488        let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
489        let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
490        assert!(
491            html.contains("depth limit exceeded"),
492            "expected 'depth limit exceeded' in: {html}"
493        );
494        assert!(html.contains("max=16"), "expected 'max=16' in: {html}");
495        assert!(
496            !html.contains("cycle"),
497            "depth tripwire must not mention 'cycle'; got: {html}"
498        );
499    }
500
501    #[test]
502    fn walker_plugin_dispatch_invokes_with_plugin() {
503        struct TestPlugin;
504        impl JsonUiPlugin for TestPlugin {
505            fn component_type(&self) -> &str {
506                "FerroPhase116PluginDispatchTest"
507            }
508            fn props_schema(&self) -> serde_json::Value {
509                serde_json::json!({})
510            }
511            fn render(&self, _props: &Value, _data: &Value) -> String {
512                "<div data-test-plugin>X</div>".to_string()
513            }
514            fn css_assets(&self) -> Vec<Asset> {
515                Vec::new()
516            }
517            fn js_assets(&self) -> Vec<Asset> {
518                Vec::new()
519            }
520            fn init_script(&self) -> Option<String> {
521                None
522            }
523        }
524        register_plugin(TestPlugin);
525
526        let spec = build_spec_unchecked(
527            "root",
528            vec![("root", mk_element("FerroPhase116PluginDispatchTest"))],
529        );
530        let html = render_spec_to_html(&spec, &json!({}));
531        assert!(
532            html.contains("<div data-test-plugin>X</div>"),
533            "got: {html}"
534        );
535    }
536
537    #[test]
538    fn walker_plugin_asset_collection_returns_plugin_types() {
539        struct TestPluginB;
540        impl JsonUiPlugin for TestPluginB {
541            fn component_type(&self) -> &str {
542                "FerroPhase116AssetCollectTestPlugin"
543            }
544            fn props_schema(&self) -> serde_json::Value {
545                serde_json::json!({})
546            }
547            fn render(&self, _props: &Value, _data: &Value) -> String {
548                String::new()
549            }
550            fn css_assets(&self) -> Vec<Asset> {
551                Vec::new()
552            }
553            fn js_assets(&self) -> Vec<Asset> {
554                Vec::new()
555            }
556            fn init_script(&self) -> Option<String> {
557                None
558            }
559        }
560        register_plugin(TestPluginB);
561
562        let spec = build_spec_unchecked(
563            "root",
564            vec![
565                ("root", mk_element("Text")),
566                ("plug", mk_element("FerroPhase116AssetCollectTestPlugin")),
567            ],
568        );
569        let types = collect_plugin_types(&spec);
570        assert!(types.contains("FerroPhase116AssetCollectTestPlugin"));
571        assert!(!types.contains("Text"));
572    }
573
574    #[test]
575    fn walker_plugins_cannot_shadow_builtins() {
576        // Register a plugin that claims the built-in type name "Card".
577        // The dispatch match must still route to the built-in renderer, not
578        // to the plugin.
579        struct CardShadow;
580        impl JsonUiPlugin for CardShadow {
581            fn component_type(&self) -> &str {
582                "Card"
583            }
584            fn props_schema(&self) -> serde_json::Value {
585                serde_json::json!({})
586            }
587            fn render(&self, _props: &Value, _data: &Value) -> String {
588                "<div data-from-plugin>SHADOW</div>".to_string()
589            }
590            fn css_assets(&self) -> Vec<Asset> {
591                Vec::new()
592            }
593            fn js_assets(&self) -> Vec<Asset> {
594                Vec::new()
595            }
596            fn init_script(&self) -> Option<String> {
597                None
598            }
599        }
600        register_plugin(CardShadow);
601
602        let spec = build_spec_unchecked("root", vec![("root", mk_element("Card"))]);
603        let html = render_spec_to_html(&spec, &json!({}));
604        assert!(
605            !html.contains("data-from-plugin"),
606            "plugin must not shadow built-in Card; got: {html}"
607        );
608    }
609
610    #[test]
611    fn top_level_wrapper_present() {
612        let spec = build_spec_unchecked("root", vec![("root", mk_element("Text"))]);
613        let html = render_spec_to_html(&spec, &json!({}));
614        assert!(
615            html.starts_with("<div class=\"flex flex-wrap gap-4"),
616            "got: {html}"
617        );
618        assert!(html.ends_with("</div>"), "got: {html}");
619    }
620
621    #[test]
622    fn html_escape_basic() {
623        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
624        assert_eq!(html_escape("a&b"), "a&amp;b");
625        assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
626    }
627
628    #[test]
629    fn builtin_types_have_no_duplicates() {
630        // The dispatch match in `render_element` has one arm per BUILTIN_TYPES
631        // entry (arm coverage is compile-enforced by rustc). The remaining
632        // runtime risk is a DUPLICATE entry — a shadowed dispatch arm or a
633        // double catalog spec — which this guards relationally (no magic count;
634        // the absolute count is pinned once in
635        // catalog::tests::builtin_types_count_drift_guard).
636        let mut seen = std::collections::HashSet::new();
637        for ty in BUILTIN_TYPES {
638            assert!(seen.insert(ty), "duplicate BUILTIN_TYPES entry: {ty}");
639        }
640    }
641
642    #[test]
643    fn render_spec_with_stream_text_emits_init_script() {
644        let spec = Spec::builder()
645            .element(
646                "root",
647                Element::new("StreamText").prop("sse_url", "/stream"),
648            )
649            .build()
650            .expect("spec builds");
651        let result = render_spec_to_html_with_plugins(&spec, &json!({}));
652        assert!(
653            result.scripts.contains("EventSource"),
654            "init script must be present; got: {}",
655            result.scripts
656        );
657        // T-169-02 / T-169-03: tokens appended as text nodes, never parsed as HTML.
658        assert!(
659            result.scripts.contains("createTextNode"),
660            "tokens must append via createTextNode; got: {}",
661            result.scripts
662        );
663        assert!(
664            !result.scripts.contains("innerHTML"),
665            "init script must never use innerHTML; got: {}",
666            result.scripts
667        );
668        // D-03: source closes on `done` to prevent reconnect loop.
669        assert!(
670            result.scripts.contains("'done'") && result.scripts.contains("close()"),
671            "init script must close on done event; got: {}",
672            result.scripts
673        );
674    }
675
676    #[test]
677    fn render_spec_without_stream_text_emits_no_init_script() {
678        let spec = Spec::builder()
679            .element("root", Element::new("Text").prop("content", "Hello"))
680            .build()
681            .expect("spec builds");
682        let result = render_spec_to_html_with_plugins(&spec, &json!({}));
683        assert!(
684            result.scripts.is_empty(),
685            "no init script when no StreamText; got: {}",
686            result.scripts
687        );
688    }
689}