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