Skip to main content

webui_parser/plugin/
webui.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI Framework parser plugin.
5//!
6//! # Overview
7//!
8//! Compiles HTML templates into structured metadata objects stored as JSON
9//! data plus component-local condition closure arrays in the protocol.
10//! During SSR the handler emits metadata in an `application/json` data block
11//! and only emits the closures as JavaScript. During SPA navigation the
12//! router registers the JSON data directly and evaluates only the closures.
13//! Each metadata object contains
14//! **marker-free static HTML** plus locator arrays for client-created DOM
15//! (`tx`, `ag`, `c`/`r` slots) and semantic arrays (`a`, `c`, `r`, `eg`,
16//! `re`, `b`). The client runtime resolves those locators once and then
17//! patches direct node references — **no template string parsing, no regex,
18//! no DOM scanning** on the client-created path.
19//!
20//! # Metadata object format
21//!
22//! ```js
23//! {
24//!   "h": "<button class=\"item\"><span></span></button>",
25//!   "tx": [[[[0, 0], 0], [["title"]]]],
26//!   "a": [["title", 0, "title"]],
27//!   "ag": [[[0], 0, 1]],
28//!   "c": [[[0, ["state"]], 0, [[0], 1]]],
29//!   "eg": [["click", [["onClick", [], [0]]]]],
30//!   "b": [{ "h": "<span class=\"check\">✓</span>" }],
31//!   "sa": "my-component",
32//!   "re": [["submit", "onSubmit", [["e"]]]]
33//! }
34//! ```
35//!
36//! # Plugin data protocol
37//!
38//! Each element with attribute bindings or events produces a 12-byte
39//! `Plugin` fragment via [`finish_element`](ParserPlugin::finish_element):
40//!
41//! | Bytes  | Field              | Description                               |
42//! |--------|--------------------|-------------------------------------------|
43//! | 0–3    | `binding_count`    | Number of dynamic attribute bindings      |
44//! | 4–7    | `event_start_idx`  | Starting index in the global event list   |
45//! | 8–11   | `event_count`      | Number of `@event` attrs on this element  |
46//!
47//! The handler plugin decodes this to emit `data-w-*` binding markers
48//! and single `data-ev="COUNT"` event markers on **SSR HTML only**. Compiled templates
49//! keep the same binding/event indices but encode client targets via locator
50//! arrays instead of embedding client-only marker attrs/comments in `h`.
51//!
52//! # Lifecycle
53//!
54//! 1. **`classify_attribute`** — intercepts `@event` attributes,
55//!    counts them per-element, and keeps them out of the parsed protocol.
56//! 2. **`finish_element`** — encodes the accumulated event count
57//!    plus attribute binding count into 12 bytes.
58//! 3. **`register_component_template`** — caches the component's plugin-facing
59//!    template HTML for later compilation (deduplicates by tag name).
60//! 4. **`take_component_templates`** — called after parsing is complete;
61//!    compiles each tracked component into JSON metadata plus condition closures.
62
63use super::{AttributeAction, ComponentTemplateArtifact, ParserPlugin, ParserPluginArtifacts};
64use crate::comment_policy;
65use crate::component_registry::Component;
66use crate::diagnostic::{codes, Diagnostic};
67use crate::html_parser::{find_tag_close, style_element_bounds};
68use crate::{ConditionParser, DomStrategy, ParserOptions, Result};
69use std::cell::Cell;
70use std::fmt::Write;
71use webui_protocol::{condition_expr, ConditionExpr, WebUIElementData};
72
73/// A component whose plugin-facing template HTML has been captured for compilation.
74/// Repeated tracking for the same tag updates the stored template so the plugin
75/// can prefer processed HTML over raw component source.
76struct TrackedComponent {
77    tag_name: String,
78    template_html: String,
79    root_event_source: String,
80    /// Source fact from the component registry. Static-host metadata is derived
81    /// as `!has_script` only when the final template payload is emitted.
82    has_script: bool,
83}
84
85/// WebUI Framework parser plugin.
86///
87/// Intercepts `@event` attributes and component registrations during parsing,
88/// then compiles each component's HTML template into JSON metadata and JS closures.
89///
90/// # Event tracking
91///
92/// Event indices are assigned **globally** across all elements in a component.
93/// `element_events` counts events on the current element (reset per element),
94/// while `next_event_idx` is a monotonically increasing global counter.
95/// This preserves event order in the emitted metadata while letting SSR HTML
96/// mark one element with a single `data-ev="COUNT"` attribute.
97pub struct WebUIParserPlugin {
98    components: Vec<TrackedComponent>,
99    /// Per-element event count, reset on each `finish_element` call.
100    element_events: Cell<u32>,
101    /// Global event index, incremented across all elements in a component.
102    next_event_idx: Cell<u32>,
103    /// DOM strategy — shadow or light.
104    dom_strategy: DomStrategy,
105}
106
107impl WebUIParserPlugin {
108    #[must_use]
109    pub fn new() -> Self {
110        Self {
111            components: Vec::new(),
112            element_events: Cell::new(0),
113            next_event_idx: Cell::new(0),
114            dom_strategy: DomStrategy::default(),
115        }
116    }
117
118    /// Compile all tracked components and return split template payloads.
119    ///
120    /// Each payload contains JSON-safe metadata plus a component-local closure
121    /// array for condition evaluation. Call this after all HTML parsing is complete.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`crate::ParserError::Template`] if any tracked component
126    /// contains an invalid `@event` handler or a non-braced `w-ref` binding.
127    pub fn take_component_templates(&self) -> Result<Vec<ComponentTemplateArtifact>> {
128        let use_shadow = matches!(self.dom_strategy, DomStrategy::Shadow);
129        let mut out = Vec::with_capacity(self.components.len());
130        for c in &self.components {
131            let payload = generate_compiled_template_with_root_source(
132                &c.tag_name,
133                &c.template_html,
134                &c.root_event_source,
135                use_shadow,
136                !c.has_script,
137            )?;
138            out.push(ComponentTemplateArtifact::webui(
139                c.tag_name.clone(),
140                payload.template_json,
141                payload.template_functions,
142            ));
143        }
144        Ok(out)
145    }
146
147    fn store_component_template(
148        &mut self,
149        tag_name: &str,
150        template_html: &str,
151        root_event_source: &str,
152        has_script: bool,
153    ) {
154        if let Some(component) = self.components.iter_mut().find(|c| c.tag_name == tag_name) {
155            component.template_html.clear();
156            component.template_html.push_str(template_html);
157            component.root_event_source.clear();
158            component.root_event_source.push_str(root_event_source);
159            component.has_script = has_script;
160            return;
161        }
162        self.components.push(TrackedComponent {
163            tag_name: tag_name.to_string(),
164            template_html: template_html.to_string(),
165            root_event_source: root_event_source.to_string(),
166            has_script,
167        });
168    }
169}
170
171impl Default for WebUIParserPlugin {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177impl ParserPlugin for WebUIParserPlugin {
178    fn start_fragment(&mut self, _fragment_id: &str) {
179        self.element_events.set(0);
180        self.next_event_idx.set(0);
181    }
182
183    fn configure(&mut self, options: &ParserOptions) {
184        self.dom_strategy = options.dom_strategy;
185    }
186
187    fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
188        if attr_name.starts_with('@') {
189            self.element_events.set(self.element_events.get() + 1);
190            return AttributeAction::Skip;
191        }
192        AttributeAction::Keep
193    }
194
195    fn register_component_template(
196        &mut self,
197        tag_name: &str,
198        component: &Component,
199        processed_template: &str,
200    ) -> Result<()> {
201        self.store_component_template(
202            tag_name,
203            processed_template,
204            &component.html_content,
205            component.has_script,
206        );
207        Ok(())
208    }
209
210    fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
211        let ev_count = self.element_events.get();
212        let ev_start = self.next_event_idx.get();
213
214        // Advance the global event index
215        self.next_event_idx.set(ev_start + ev_count);
216        // Reset per-element event counter
217        self.element_events.set(0);
218
219        if binding_attribute_count == 0 && ev_count == 0 {
220            return None;
221        }
222
223        Some(
224            WebUIElementData {
225                binding_count: binding_attribute_count,
226                event_start: ev_start,
227                event_count: ev_count,
228            }
229            .encode()
230            .to_vec(),
231        )
232    }
233
234    fn into_artifacts(self: Box<Self>) -> Result<ParserPluginArtifacts> {
235        Ok(ParserPluginArtifacts::ComponentTemplates(
236            self.take_component_templates()?,
237        ))
238    }
239}
240
241// ── Compiled template generation ───────────────────────────────────
242
243const TEXT_MARKER_PREFIX: &str = "<!--t:";
244const TEXT_MARKER_SUFFIX: &str = "-->";
245const MIN_TEXT_MARKER_DIGITS: usize = 1;
246const MIN_TEXT_MARKER_LEN: usize =
247    TEXT_MARKER_PREFIX.len() + MIN_TEXT_MARKER_DIGITS + TEXT_MARKER_SUFFIX.len();
248const TEXT_MARKER_INDEX_RADIX: usize = 10;
249
250/// A compiled template section.
251///
252/// Used for the root template and for nested block-table entries.
253/// `conditionals` and `repeats` point at block-table indices instead of
254/// inlining raw body HTML so the client never reparses nested template syntax.
255/// The final `html` payload is marker-free; client-created DOM uses the
256/// locator tables to connect bindings without scanning comments or attrs.
257#[derive(Default)]
258struct TemplateSectionMeta {
259    /// Marker-free static HTML for client-created DOM.
260    html: String,
261    /// Intermediate text binding paths collected during compilation.
262    /// These are resolved into `text_runs` during finalization.
263    /// Each entry is `(path, raw)` where raw indicates triple-brace `{{{...}}}`.
264    text_bindings: Vec<(String, bool)>,
265    /// Byte offsets in `html` where compiler-generated text markers start.
266    /// Authored CSS may contain marker-like text; only these offsets are metadata.
267    text_marker_offsets: Vec<usize>,
268    /// Client text-run metadata: `(slot, parts, raw)`.
269    /// `raw` is true when the binding uses triple-brace `{{{...}}}` syntax.
270    text_runs: Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
271    /// Attribute bindings in source order, shared by SSR markers and client `ag[]` locators.
272    attr_bindings: Vec<CompiledAttrBinding>,
273    /// Client attribute groups: `(element_path, start, count)`.
274    attr_groups: Vec<(Vec<usize>, usize, usize)>,
275    /// Conditional blocks: `(condition_expression_ast, block_index)`.
276    conditionals: Vec<(ConditionExpr, usize)>,
277    /// Client conditional anchor slots aligned to `conditionals`.
278    condition_slots: Vec<SlotLocator>,
279    /// For-loop blocks: `(collection_path, item_variable, block_index)`.
280    repeats: Vec<(String, String, usize)>,
281    /// Client repeat anchor slots aligned to `repeats`.
282    repeat_slots: Vec<SlotLocator>,
283    /// Body-level events: `(event_name, handler_method, argument_specs)`.
284    events: Vec<EventBinding>,
285    /// Client event target element paths aligned to `events`.
286    event_targets: Vec<Vec<usize>>,
287}
288
289#[derive(Clone)]
290struct SlotLocator {
291    parent_path: Vec<usize>,
292    before_index: usize,
293    order: usize,
294}
295
296/// Collected metadata produced by [`compile_to_metadata`].
297///
298/// The root template emits directly into the top-level metadata object.
299/// Nested blocks live in `blocks` and are referenced by index from `c` / `r`.
300struct TemplateMeta {
301    root: TemplateSectionMeta,
302    blocks: Vec<TemplateSectionMeta>,
303    /// Root-level events from the `<template>` wrapper tag.
304    /// Attached to the host element (shadow root host) by the client.
305    root_events: Vec<EventBinding>,
306}
307
308type EventBinding = (String, String, Vec<EventArg>);
309
310/// Result of parsing one `@event` attribute: the event binding
311/// (`event_name`, `handler_name`, `args`) plus the number of bytes consumed.
312type ParsedEventAttr = (String, String, Vec<EventArg>, usize);
313
314#[derive(Clone, Debug, PartialEq)]
315enum EventArg {
316    Event,
317    Path(String),
318    String(String),
319    Number(String),
320    Bool(bool),
321    Null,
322}
323
324enum CompiledAttrBinding {
325    Simple {
326        name: String,
327        value: String,
328    },
329    Complex {
330        name: String,
331        value: String,
332    },
333    Boolean {
334        name: String,
335        condition: ConditionExpr,
336    },
337    Template {
338        name: String,
339        parts: Vec<CompiledAttrPart>,
340    },
341}
342
343enum CompiledAttrPart {
344    Static(String),
345    Dynamic(String),
346}
347
348struct CompiledTemplatePayload {
349    template_json: String,
350    template_functions: String,
351}
352
353struct TemplateBuildMetadata {
354    roots: Vec<String>,
355    has_events: bool,
356}
357
358struct TemplatePayloadOptions<'a> {
359    adopted_stylesheet: Option<&'a str>,
360    shadow_dom: bool,
361    emit_static_host: bool,
362}
363
364struct RootScope<'a> {
365    name: &'a str,
366    parent: Option<usize>,
367}
368
369struct RootVisit<'a> {
370    block: &'a TemplateSectionMeta,
371    scope: Option<usize>,
372}
373
374struct ConditionFunctionEmitter {
375    functions: String,
376    count: usize,
377}
378
379impl ConditionFunctionEmitter {
380    fn new(capacity: usize) -> Self {
381        Self {
382            functions: String::with_capacity(capacity),
383            count: 0,
384        }
385    }
386
387    fn push(&mut self, condition: &ConditionExpr) -> usize {
388        if self.count == 0 {
389            self.functions.push('[');
390        } else {
391            self.functions.push(',');
392        }
393        emit_js_condition_function(condition, &mut self.functions);
394        let index = self.count;
395        self.count += 1;
396        index
397    }
398
399    fn finish(mut self) -> String {
400        if self.count == 0 {
401            String::new()
402        } else {
403            self.functions.push(']');
404            self.functions
405        }
406    }
407}
408
409/// Generate a compiled template as JSON-safe metadata.
410///
411/// The returned string is the metadata data payload. Condition closures are
412/// emitted separately by [`generate_compiled_template_with_root_source`].
413///
414/// # Flow
415///
416/// 1. Extract `@event` bindings from the `<template>` wrapper (→ `root_events`).
417/// 2. Strip the `<template shadowrootmode="…">` wrapper if present.
418/// 3. Compile the inner body via [`compile_to_metadata`].
419/// 4. Serialize into JSON metadata and component-local condition closures.
420///
421/// # Compilation rules
422///
423/// | Source syntax                          | Metadata field(s)          | Client `h` result                 |
424/// |---------------------------------------|----------------------------|-----------------------------------|
425/// | `{{expr}}`, `{{{expr}}}`, mixed text  | `tx[]`                     | dynamic text run removed          |
426/// | `href="{{url}}"`, `class="x {{y}}"`   | `a[]` + `ag[]`             | element kept marker-free          |
427/// | `?disabled="{{expr}}"`                | `a[]` + `ag[]`             | element kept marker-free          |
428/// | `:config="{{settings}}"`              | `a[]` + `ag[]`             | element kept marker-free          |
429/// | `<if condition="…">body</if>`         | `c[]` + `cl[]` + `b[]`     | block removed; anchor slot stored |
430/// | `<for each="v in coll">body</for>`    | `r[]` + `rl[]` + `b[]`     | block removed; anchor slot stored |
431/// | `<link>` / `<style>` child nodes      | `h`                        | preserved in static HTML          |
432/// | module adopted stylesheet specifier   | `sa`                       | stored from `<template>` wrapper  |
433/// | `@event="{handler(e)}"`               | `eg[]`                     | element kept marker-free          |
434/// | `w-ref="name"` / `w-ref={name}`       | *(stays in HTML)*          | *(unchanged)*                     |
435/// | `<outlet />` / `<outlet>`             | *(stays in HTML)*          | `<outlet></outlet>`               |
436///
437/// # Errors
438///
439/// Returns [`crate::ParserError::Template`] if the template contains an invalid
440/// `@event` handler or a non-braced `w-ref` binding.
441pub fn generate_compiled_template(tag_name: &str, html_content: &str) -> Result<String> {
442    Ok(generate_compiled_template_with_root_source(
443        tag_name,
444        html_content,
445        html_content,
446        false,
447        false,
448    )?
449    .template_json)
450}
451
452fn generate_compiled_template_with_root_source(
453    tag_name: &str,
454    html_content: &str,
455    root_event_source: &str,
456    shadow_dom: bool,
457    emit_static_host: bool,
458) -> Result<CompiledTemplatePayload> {
459    let trimmed = html_content.trim();
460    let root_events = extract_root_events(tag_name, root_event_source.trim())?;
461    let adopted_stylesheet = extract_adopted_stylesheet_specifier(trimmed);
462    let body = strip_template_wrapper(trimmed);
463    let meta = compile_to_metadata(tag_name, body, root_events)?;
464    let build_meta = collect_template_build_metadata(&meta);
465    if emit_static_host && build_meta.has_events {
466        return Err(scriptless_component_events(tag_name).into());
467    }
468    Ok(emit_compiled_template_payload(
469        html_content,
470        &meta,
471        &build_meta,
472        TemplatePayloadOptions {
473            adopted_stylesheet: adopted_stylesheet.as_deref(),
474            shadow_dom,
475            emit_static_host,
476        },
477    ))
478}
479
480fn emit_compiled_template_payload(
481    html_content: &str,
482    meta: &TemplateMeta,
483    build_meta: &TemplateBuildMetadata,
484    options: TemplatePayloadOptions<'_>,
485) -> CompiledTemplatePayload {
486    let mut conditions = ConditionFunctionEmitter::new(128);
487    let mut out = String::with_capacity(512 + html_content.len());
488    out.push('{');
489
490    emit_json_template_section(&meta.root, &mut out, &mut conditions);
491
492    if let Some(adopted_stylesheet) = options.adopted_stylesheet {
493        out.push_str(",\"sa\":");
494        emit_js_string(adopted_stylesheet, &mut out);
495    }
496
497    // sd: shadow DOM flag — tells the client runtime to use shadow root
498    if options.shadow_dom {
499        out.push_str(",\"sd\":1");
500    }
501
502    if options.emit_static_host && !build_meta.roots.is_empty() {
503        out.push_str(",\"th\":1");
504    }
505
506    // re: root events
507    if !meta.root_events.is_empty() {
508        out.push_str(",\"re\":[");
509        for (i, (event, handler, args)) in meta.root_events.iter().enumerate() {
510            if i > 0 {
511                out.push(',');
512            }
513            out.push('[');
514            emit_js_string(event, &mut out);
515            out.push(',');
516            emit_js_string(handler, &mut out);
517            out.push(',');
518            emit_js_event_args(args, &mut out);
519            out.push(']');
520        }
521        out.push(']');
522    }
523
524    if !build_meta.roots.is_empty() {
525        out.push_str(",\"tr\":[");
526        for (i, root) in build_meta.roots.iter().enumerate() {
527            if i > 0 {
528                out.push(',');
529            }
530            emit_js_string(root, &mut out);
531        }
532        out.push_str("],\"ta\":[");
533        for (i, root) in build_meta.roots.iter().enumerate() {
534            if i > 0 {
535                out.push(',');
536            }
537            let attr = webui_protocol::attrs::camel_to_kebab(root);
538            emit_js_string(&attr, &mut out);
539        }
540        out.push(']');
541    }
542
543    // b: nested block table
544    if !meta.blocks.is_empty() {
545        out.push_str(",\"b\":[");
546        for (i, block) in meta.blocks.iter().enumerate() {
547            if i > 0 {
548                out.push(',');
549            }
550            out.push('{');
551            emit_json_template_section(block, &mut out, &mut conditions);
552            out.push('}');
553        }
554        out.push(']');
555    }
556
557    out.push('}');
558    CompiledTemplatePayload {
559        template_json: out,
560        template_functions: conditions.finish(),
561    }
562}
563
564fn emit_js_string(s: &str, out: &mut String) {
565    out.push('"');
566    let mut index = 0usize;
567    const HEX: &[u8; 16] = b"0123456789abcdef";
568    while index < s.len() {
569        let remaining = &s[index..];
570        if remaining.as_bytes().starts_with(b"</")
571            && remaining
572                .as_bytes()
573                .get(2..8)
574                .map(|bytes| bytes.eq_ignore_ascii_case(b"script"))
575                .unwrap_or(false)
576        {
577            out.push_str("\\u003C");
578            index += 1;
579            continue;
580        }
581        let Some(ch) = remaining.chars().next() else {
582            break;
583        };
584        match ch {
585            '\0' => out.push_str("\\u0000"),
586            '"' => out.push_str("\\\""),
587            '\\' => out.push_str("\\\\"),
588            '\n' => out.push_str("\\n"),
589            '\r' => out.push_str("\\r"),
590            '\t' => out.push_str("\\t"),
591            '\u{2028}' => out.push_str("\\u2028"),
592            '\u{2029}' => out.push_str("\\u2029"),
593            '\u{0001}'..='\u{001f}' => {
594                let code = ch as usize;
595                out.push_str("\\u00");
596                out.push(char::from(HEX[(code >> 4) & 0x0f]));
597                out.push(char::from(HEX[code & 0x0f]));
598            }
599            _ => out.push(ch),
600        }
601        index += ch.len_utf8();
602    }
603    out.push('"');
604}
605
606fn emit_js_event_args(args: &[EventArg], out: &mut String) {
607    out.push('[');
608    for (i, arg) in args.iter().enumerate() {
609        if i > 0 {
610            out.push(',');
611        }
612        match arg {
613            EventArg::Event => out.push_str(r#"["e"]"#),
614            EventArg::Path(path) => {
615                out.push_str(r#"["p","#);
616                emit_js_string(path, out);
617                out.push(']');
618            }
619            EventArg::String(value) => {
620                out.push_str(r#"["s","#);
621                emit_js_string(value, out);
622                out.push(']');
623            }
624            EventArg::Number(value) => {
625                out.push_str(r#"["n","#);
626                out.push_str(value);
627                out.push(']');
628            }
629            EventArg::Bool(value) => out.push_str(if *value { r#"["b",1]"# } else { r#"["b",0]"# }),
630            EventArg::Null => out.push_str(r#"["z"]"#),
631        }
632    }
633    out.push(']');
634}
635
636fn event_args_use_event(args: &[EventArg]) -> bool {
637    for arg in args {
638        if matches!(arg, EventArg::Event) {
639            return true;
640        }
641    }
642    false
643}
644
645struct EventGroup<'a> {
646    event: &'a str,
647    bindings: Vec<usize>,
648}
649
650fn emit_js_event_groups(events: &[EventBinding], targets: &[Vec<usize>], out: &mut String) {
651    let groups = collect_event_groups(events);
652    out.push_str(",\"eg\":[");
653    for (group_index, group) in groups.iter().enumerate() {
654        if group_index > 0 {
655            out.push(',');
656        }
657        out.push('[');
658        emit_js_string(group.event, out);
659        out.push_str(",[");
660        for (binding_index, event_index) in group.bindings.iter().enumerate() {
661            if binding_index > 0 {
662                out.push(',');
663            }
664            let (_, handler, args) = &events[*event_index];
665            let target = &targets[*event_index];
666            out.push('[');
667            emit_js_string(handler, out);
668            out.push(',');
669            emit_js_event_args(args, out);
670            out.push(',');
671            emit_js_node_path(target, out);
672            if event_args_use_event(args) {
673                out.push_str(",1");
674            }
675            out.push(']');
676        }
677        out.push_str("]]");
678    }
679    out.push(']');
680}
681
682fn collect_event_groups(events: &[EventBinding]) -> Vec<EventGroup<'_>> {
683    let mut groups: Vec<EventGroup<'_>> = Vec::with_capacity(events.len());
684    let mut group_indices = std::collections::HashMap::<&str, usize>::with_capacity(events.len());
685    for (index, (event, _, _)) in events.iter().enumerate() {
686        let event_name = event.as_str();
687        if let Some(group_index) = group_indices.get(event_name).copied() {
688            groups[group_index].bindings.push(index);
689            continue;
690        }
691        group_indices.insert(event_name, groups.len());
692        groups.push(EventGroup {
693            event: event_name,
694            bindings: vec![index],
695        });
696    }
697    groups
698}
699
700fn emit_json_attr_binding(
701    binding: &CompiledAttrBinding,
702    out: &mut String,
703    functions: &mut ConditionFunctionEmitter,
704) {
705    out.push('[');
706    match binding {
707        CompiledAttrBinding::Simple { name, value } => {
708            emit_js_string(name, out);
709            out.push_str(",0,");
710            emit_js_string(value, out);
711        }
712        CompiledAttrBinding::Complex { name, value } => {
713            emit_js_string(name, out);
714            out.push_str(",1,");
715            emit_js_string(value, out);
716        }
717        CompiledAttrBinding::Boolean { name, condition } => {
718            emit_js_string(name, out);
719            out.push_str(",2,");
720            emit_json_condition_ref(condition, out, functions);
721        }
722        CompiledAttrBinding::Template { name, parts } => {
723            emit_js_string(name, out);
724            out.push_str(",3,[");
725            for (i, part) in parts.iter().enumerate() {
726                if i > 0 {
727                    out.push(',');
728                }
729                match part {
730                    CompiledAttrPart::Static(value) => emit_js_string(value, out),
731                    CompiledAttrPart::Dynamic(path) => {
732                        out.push('[');
733                        emit_js_string(path, out);
734                        out.push(']');
735                    }
736                }
737            }
738            out.push(']');
739        }
740    }
741    out.push(']');
742}
743
744fn collect_template_build_metadata(meta: &TemplateMeta) -> TemplateBuildMetadata {
745    let mut roots = Vec::new();
746    let mut scopes = Vec::<RootScope<'_>>::new();
747    let mut stack = Vec::with_capacity(1 + meta.blocks.len());
748    let mut has_events = !meta.root_events.is_empty();
749    stack.push(RootVisit {
750        block: &meta.root,
751        scope: None,
752    });
753
754    while let Some(visit) = stack.pop() {
755        let block = visit.block;
756        if !block.events.is_empty() {
757            has_events = true;
758        }
759
760        for (_, parts, _) in &block.text_runs {
761            add_part_roots(&mut roots, parts, &scopes, visit.scope);
762        }
763
764        for binding in &block.attr_bindings {
765            match binding {
766                CompiledAttrBinding::Simple { value, .. }
767                | CompiledAttrBinding::Complex { value, .. } => {
768                    add_root(&mut roots, value, &scopes, visit.scope);
769                }
770                CompiledAttrBinding::Boolean { condition, .. } => {
771                    add_condition_roots(&mut roots, condition, &scopes, visit.scope);
772                }
773                CompiledAttrBinding::Template { parts, .. } => {
774                    add_part_roots(&mut roots, parts, &scopes, visit.scope);
775                }
776            }
777        }
778
779        for (condition, block_index) in &block.conditionals {
780            add_condition_roots(&mut roots, condition, &scopes, visit.scope);
781            if let Some(child) = meta.blocks.get(*block_index) {
782                stack.push(RootVisit {
783                    block: child,
784                    scope: visit.scope,
785                });
786            }
787        }
788
789        for (collection, item_var, block_index) in &block.repeats {
790            add_root(&mut roots, collection, &scopes, visit.scope);
791            if let Some(child) = meta.blocks.get(*block_index) {
792                let scope = scopes.len();
793                scopes.push(RootScope {
794                    name: item_var,
795                    parent: visit.scope,
796                });
797                stack.push(RootVisit {
798                    block: child,
799                    scope: Some(scope),
800                });
801            }
802        }
803    }
804
805    TemplateBuildMetadata { roots, has_events }
806}
807
808fn path_root(path: &str) -> &str {
809    match path.find('.') {
810        Some(dot) => &path[..dot],
811        None => path,
812    }
813}
814
815fn is_scoped_path(path: &str, scopes: &[RootScope<'_>], scope: Option<usize>) -> bool {
816    let root = path_root(path);
817    let mut current = scope;
818    while let Some(index) = current {
819        let Some(frame) = scopes.get(index) else {
820            return false;
821        };
822        if frame.name == root {
823            return true;
824        }
825        current = frame.parent;
826    }
827    false
828}
829
830fn add_root(roots: &mut Vec<String>, path: &str, scopes: &[RootScope<'_>], scope: Option<usize>) {
831    if path.is_empty() || is_scoped_path(path, scopes, scope) {
832        return;
833    }
834    let root = path_root(path);
835    if roots.iter().any(|existing| existing == root) {
836        return;
837    }
838    roots.push(root.to_string());
839}
840
841fn add_part_roots(
842    roots: &mut Vec<String>,
843    parts: &[CompiledAttrPart],
844    scopes: &[RootScope<'_>],
845    scope: Option<usize>,
846) {
847    for part in parts {
848        if let CompiledAttrPart::Dynamic(path) = part {
849            add_root(roots, path, scopes, scope);
850        }
851    }
852}
853
854fn is_condition_literal(value: &str) -> bool {
855    (value.starts_with('"') && value.ends_with('"'))
856        || (value.starts_with('\'') && value.ends_with('\''))
857        || value == "true"
858        || value == "false"
859        || (!value.is_empty()
860            && value
861                .bytes()
862                .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-'))
863}
864
865fn add_condition_roots(
866    roots: &mut Vec<String>,
867    condition: &ConditionExpr,
868    scopes: &[RootScope<'_>],
869    scope: Option<usize>,
870) {
871    let mut stack = Vec::with_capacity(1);
872    stack.push(condition);
873    while let Some(current) = stack.pop() {
874        match &current.expr {
875            Some(condition_expr::Expr::Identifier(id)) => {
876                add_root(roots, &id.value, scopes, scope);
877            }
878            Some(condition_expr::Expr::Predicate(pred)) => {
879                add_root(roots, &pred.left, scopes, scope);
880                if !is_condition_literal(&pred.right) {
881                    add_root(roots, &pred.right, scopes, scope);
882                }
883            }
884            Some(condition_expr::Expr::Not(not_cond)) => {
885                if let Some(inner) = not_cond.condition.as_ref() {
886                    stack.push(inner);
887                }
888            }
889            Some(condition_expr::Expr::Compound(compound)) => {
890                if let Some(left) = compound.left.as_ref() {
891                    stack.push(left);
892                }
893                if let Some(right) = compound.right.as_ref() {
894                    stack.push(right);
895                }
896            }
897            None => {}
898        }
899    }
900}
901
902/// Emit a compiled condition function as `function(v,s){return EXPR}`.
903/// The function evaluates the condition using `v(path,s)` for value resolution.
904fn emit_js_condition_function(condition: &ConditionExpr, out: &mut String) {
905    out.push_str("function(v,s){return ");
906    emit_js_condition_expr(condition, out);
907    out.push('}');
908}
909
910/// Emit a JSON condition reference as `[functionIndex,["path1",...]]`.
911/// The paths array lists all referenced identifiers for the reactive path index.
912fn emit_json_condition_ref(
913    condition: &ConditionExpr,
914    out: &mut String,
915    functions: &mut ConditionFunctionEmitter,
916) {
917    let function_index = functions.push(condition);
918    out.push('[');
919    let _ = write!(out, "{}", function_index);
920    out.push_str(",[");
921    let mut paths = Vec::new();
922    collect_condition_paths(condition, &mut paths);
923    for (i, path) in paths.iter().enumerate() {
924        if i > 0 {
925            out.push(',');
926        }
927        emit_js_string(path, out);
928    }
929    out.push_str("]]");
930}
931
932/// Emit the JS expression body for a condition (no wrapping).
933fn emit_js_condition_expr(condition: &ConditionExpr, out: &mut String) {
934    match &condition.expr {
935        Some(condition_expr::Expr::Identifier(id)) => {
936            // Truthiness check: !!v("path",s)
937            out.push_str("!!v(");
938            emit_js_string(&id.value, out);
939            out.push_str(",s)");
940        }
941        Some(condition_expr::Expr::Predicate(pred)) => {
942            // Comparison: resolve left, compare with right
943            out.push_str("v(");
944            emit_js_string(&pred.left, out);
945            out.push_str(",s)");
946            match pred.operator {
947                1 => out.push('>'),      // GT
948                2 => out.push('<'),      // LT
949                3 => out.push_str("=="), // EQ (use == for loose comparison like Object.is)
950                4 => out.push_str("!="), // NEQ
951                5 => out.push_str(">="), // GTE
952                6 => out.push_str("<="), // LTE
953                _ => out.push_str("=="),
954            }
955            // Right side: resolve as value or emit literal
956            emit_js_predicate_right(&pred.right, out);
957        }
958        Some(condition_expr::Expr::Not(not_cond)) => {
959            out.push('!');
960            if let Some(inner) = not_cond.condition.as_ref() {
961                out.push('(');
962                emit_js_condition_expr(inner, out);
963                out.push(')');
964            } else {
965                out.push_str("false");
966            }
967        }
968        Some(condition_expr::Expr::Compound(compound)) => {
969            out.push('(');
970            if let Some(left) = compound.left.as_ref() {
971                emit_js_condition_expr(left, out);
972            } else {
973                out.push_str("false");
974            }
975            match compound.op {
976                1 => out.push_str("&&"), // AND
977                2 => out.push_str("||"), // OR
978                _ => out.push_str("&&"),
979            }
980            if let Some(right) = compound.right.as_ref() {
981                emit_js_condition_expr(right, out);
982            } else {
983                out.push_str("false");
984            }
985            out.push(')');
986        }
987        None => out.push_str("false"),
988    }
989}
990
991/// Emit the right-hand side of a predicate comparison.
992/// Literals are emitted directly; identifiers are resolved via v().
993fn emit_js_predicate_right(value: &str, out: &mut String) {
994    // Check for string literals: "..." or '...'
995    if (value.starts_with('"') && value.ends_with('"'))
996        || (value.starts_with('\'') && value.ends_with('\''))
997    {
998        // Emit as JS string (strip outer quotes, re-emit as double-quoted)
999        emit_js_string(&value[1..value.len() - 1], out);
1000        return;
1001    }
1002    // Boolean literals
1003    if value == "true" || value == "false" {
1004        out.push_str(value);
1005        return;
1006    }
1007    // Numeric literals
1008    if value
1009        .bytes()
1010        .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-')
1011        && !value.is_empty()
1012    {
1013        out.push_str(value);
1014        return;
1015    }
1016    // Otherwise it's an identifier — resolve it
1017    out.push_str("v(");
1018    emit_js_string(value, out);
1019    out.push_str(",s)");
1020}
1021
1022/// Collect all identifier paths referenced by a condition.
1023fn collect_condition_paths(condition: &ConditionExpr, paths: &mut Vec<String>) {
1024    match &condition.expr {
1025        Some(condition_expr::Expr::Identifier(id)) => {
1026            paths.push(id.value.clone());
1027        }
1028        Some(condition_expr::Expr::Predicate(pred)) => {
1029            paths.push(pred.left.clone());
1030            // Right side might also be an identifier (not a literal)
1031            let r = &pred.right;
1032            if !((r.starts_with('"') && r.ends_with('"'))
1033                || (r.starts_with('\'') && r.ends_with('\''))
1034                || r == "true"
1035                || r == "false"
1036                || r.bytes()
1037                    .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-'))
1038            {
1039                paths.push(r.clone());
1040            }
1041        }
1042        Some(condition_expr::Expr::Not(not_cond)) => {
1043            if let Some(inner) = not_cond.condition.as_ref() {
1044                collect_condition_paths(inner, paths);
1045            }
1046        }
1047        Some(condition_expr::Expr::Compound(compound)) => {
1048            if let Some(left) = compound.left.as_ref() {
1049                collect_condition_paths(left, paths);
1050            }
1051            if let Some(right) = compound.right.as_ref() {
1052                collect_condition_paths(right, paths);
1053            }
1054        }
1055        None => {}
1056    }
1057}
1058
1059fn emit_js_node_path(path: &[usize], out: &mut String) {
1060    out.push('[');
1061    for (i, index) in path.iter().enumerate() {
1062        if i > 0 {
1063            out.push(',');
1064        }
1065        let _ = write!(out, "{}", index);
1066    }
1067    out.push(']');
1068}
1069
1070fn emit_js_slot(slot: &SlotLocator, out: &mut String) {
1071    out.push('[');
1072    emit_js_node_path(&slot.parent_path, out);
1073    out.push(',');
1074    let _ = write!(out, "{}", slot.before_index);
1075    if slot.order > 0 {
1076        out.push(',');
1077        let _ = write!(out, "{}", slot.order);
1078    }
1079    out.push(']');
1080}
1081
1082fn emit_js_text_parts(parts: &[CompiledAttrPart], out: &mut String) {
1083    out.push('[');
1084    for (i, part) in parts.iter().enumerate() {
1085        if i > 0 {
1086            out.push(',');
1087        }
1088        match part {
1089            CompiledAttrPart::Static(value) => emit_js_string(value, out),
1090            CompiledAttrPart::Dynamic(path) => {
1091                out.push('[');
1092                emit_js_string(path, out);
1093                out.push(']');
1094            }
1095        }
1096    }
1097    out.push(']');
1098}
1099
1100fn emit_json_template_section(
1101    meta: &TemplateSectionMeta,
1102    out: &mut String,
1103    functions: &mut ConditionFunctionEmitter,
1104) {
1105    out.push_str("\"h\":");
1106    emit_js_string(&meta.html, out);
1107
1108    if !meta.text_runs.is_empty() {
1109        out.push_str(",\"tx\":[");
1110        for (i, (slot, parts, raw)) in meta.text_runs.iter().enumerate() {
1111            if i > 0 {
1112                out.push(',');
1113            }
1114            out.push('[');
1115            emit_js_slot(slot, out);
1116            out.push(',');
1117            emit_js_text_parts(parts, out);
1118            if *raw {
1119                out.push_str(",1");
1120            }
1121            out.push(']');
1122        }
1123        out.push(']');
1124    }
1125
1126    if !meta.attr_bindings.is_empty() {
1127        out.push_str(",\"a\":[");
1128        for (i, binding) in meta.attr_bindings.iter().enumerate() {
1129            if i > 0 {
1130                out.push(',');
1131            }
1132            emit_json_attr_binding(binding, out, functions);
1133        }
1134        out.push(']');
1135    }
1136
1137    if !meta.attr_groups.is_empty() {
1138        out.push_str(",\"ag\":[");
1139        for (i, (path, start, count)) in meta.attr_groups.iter().enumerate() {
1140            if i > 0 {
1141                out.push(',');
1142            }
1143            out.push('[');
1144            emit_js_node_path(path, out);
1145            out.push(',');
1146            let _ = write!(out, "{}", start);
1147            out.push(',');
1148            let _ = write!(out, "{}", count);
1149            out.push(']');
1150        }
1151        out.push(']');
1152    }
1153
1154    if !meta.conditionals.is_empty() {
1155        out.push_str(",\"c\":[");
1156        for (i, ((cond, block_index), slot)) in meta
1157            .conditionals
1158            .iter()
1159            .zip(meta.condition_slots.iter())
1160            .enumerate()
1161        {
1162            if i > 0 {
1163                out.push(',');
1164            }
1165            out.push('[');
1166            emit_json_condition_ref(cond, out, functions);
1167            out.push(',');
1168            let _ = write!(out, "{}", block_index);
1169            out.push(',');
1170            emit_js_slot(slot, out);
1171            out.push(']');
1172        }
1173        out.push(']');
1174    }
1175
1176    if !meta.repeats.is_empty() {
1177        out.push_str(",\"r\":[");
1178        for (i, ((collection, item_var, block_index), slot)) in meta
1179            .repeats
1180            .iter()
1181            .zip(meta.repeat_slots.iter())
1182            .enumerate()
1183        {
1184            if i > 0 {
1185                out.push(',');
1186            }
1187            out.push('[');
1188            emit_js_string(collection, out);
1189            out.push(',');
1190            emit_js_string(item_var, out);
1191            out.push(',');
1192            let _ = write!(out, "{}", block_index);
1193            out.push(',');
1194            emit_js_slot(slot, out);
1195            out.push(']');
1196        }
1197        out.push(']');
1198    }
1199
1200    if !meta.events.is_empty() {
1201        emit_js_event_groups(&meta.events, &meta.event_targets, out);
1202    }
1203}
1204
1205/// Compile a template body into a [`TemplateMeta`] struct.
1206///
1207/// Performs a single forward pass over the input bytes to build an intermediate
1208/// section, then finalizes that section into marker-free client HTML plus
1209/// locator metadata. During the forward pass:
1210///
1211/// - **Multi-byte char boundary check** — ensures we don't split UTF-8 (e.g. emoji).
1212/// - **`{{{expr}}}`** — triple-brace raw binding → text run metadata.
1213/// - **`{{expr}}`** — double-brace escaped binding → text run metadata.
1214/// - **regular start tags** — attrs are compiled into explicit `a[]` metadata plus
1215///   SSR binding markers that are later stripped from client `h`.
1216/// - **`<if condition="…">`** — parsed via [`parse_if_block`] → conditional slot.
1217/// - **`<for each="v in coll">`** — parsed via [`parse_for_block`] → repeat slot.
1218/// - **`<outlet …>`** — normalized to `<outlet></outlet>`.
1219/// - **`@event="…"`** (inside a tag) — parsed into grouped `eg[]` entries plus
1220///   SSR `data-ev="COUNT"` markers that are later replaced by client locators.
1221/// - **Everything else** — copied verbatim to the intermediate static HTML.
1222///
1223fn compile_to_metadata(
1224    component: &str,
1225    input: &str,
1226    root_events: Vec<EventBinding>,
1227) -> Result<TemplateMeta> {
1228    let mut blocks = Vec::new();
1229    let mut root = compile_section(component, input, &mut blocks)?;
1230    finalize_template_section(&mut root);
1231    for block in &mut blocks {
1232        finalize_template_section(block);
1233    }
1234    Ok(TemplateMeta {
1235        root,
1236        blocks,
1237        root_events,
1238    })
1239}
1240
1241fn compile_section(
1242    component: &str,
1243    input: &str,
1244    blocks: &mut Vec<TemplateSectionMeta>,
1245) -> Result<TemplateSectionMeta> {
1246    let mut meta = TemplateSectionMeta {
1247        html: String::with_capacity(input.len()),
1248        text_bindings: Vec::new(),
1249        text_marker_offsets: Vec::new(),
1250        text_runs: Vec::new(),
1251        attr_bindings: Vec::new(),
1252        attr_groups: Vec::new(),
1253        conditionals: Vec::new(),
1254        condition_slots: Vec::new(),
1255        repeats: Vec::new(),
1256        repeat_slots: Vec::new(),
1257        events: Vec::new(),
1258        event_targets: Vec::new(),
1259    };
1260
1261    let bytes = input.as_bytes();
1262    let len = bytes.len();
1263    let mut i = 0;
1264
1265    while i < len {
1266        if !input.is_char_boundary(i) {
1267            meta.html.push(bytes[i] as char);
1268            i += 1;
1269            continue;
1270        }
1271
1272        if let Some(next) = compile_text_binding_at(input, i, &mut meta) {
1273            i = next;
1274            continue;
1275        }
1276
1277        // <if condition="...">...</if> → marker + conditional
1278        if bytes[i] == b'<' {
1279            let remaining = &input[i..];
1280            if remaining.starts_with("<!--") {
1281                if let Some(close) = remaining.find("-->") {
1282                    i += close + 3;
1283                    continue;
1284                }
1285            }
1286
1287            if remaining.starts_with("<if ") || remaining.starts_with("<if\n") {
1288                if let Some((cond, body, consumed)) = parse_if_block(remaining) {
1289                    let block_index = blocks.len();
1290                    blocks.push(TemplateSectionMeta::default());
1291                    let block = compile_section(component, &body, blocks)?;
1292                    blocks[block_index] = block;
1293                    let idx = meta.conditionals.len();
1294                    meta.conditionals.push((cond, block_index));
1295                    meta.html.push_str(&format!("<!--c:{idx}-->"));
1296                    i += consumed;
1297                    continue;
1298                }
1299            }
1300
1301            // <for each="item in collection">...</for> → marker + repeat
1302            if remaining.starts_with("<for ") || remaining.starts_with("<for\n") {
1303                if let Some((collection, item_var, body, consumed)) = parse_for_block(remaining) {
1304                    let block_index = blocks.len();
1305                    blocks.push(TemplateSectionMeta::default());
1306                    let block = compile_section(component, &body, blocks)?;
1307                    blocks[block_index] = block;
1308                    let idx = meta.repeats.len();
1309                    meta.repeats.push((collection, item_var, block_index));
1310                    meta.html.push_str(&format!("<!--r:{idx}-->"));
1311                    i += consumed;
1312                    continue;
1313                }
1314            }
1315
1316            if let Some((open_end, close_start, close_end)) = find_style_element_bounds(input, i) {
1317                meta.html.push_str(&input[i..open_end]);
1318                compile_style_content(&input[open_end..close_start], &mut meta);
1319                meta.html.push_str(&input[close_start..close_end]);
1320                i = close_end;
1321                continue;
1322            }
1323
1324            // <outlet ... /> → keep as <outlet></outlet>
1325            if remaining.starts_with("<outlet") {
1326                if let Some(close) = find_tag_close(remaining) {
1327                    meta.html.push_str("<outlet></outlet>");
1328                    i += close + 1;
1329                    continue;
1330                }
1331            }
1332
1333            if let Some((tag_html, consumed)) = parse_regular_tag(component, remaining, &mut meta)?
1334            {
1335                meta.html.push_str(&tag_html);
1336                i += consumed;
1337                continue;
1338            }
1339        }
1340
1341        // @event attributes → replace with a per-element event-count marker
1342        if bytes[i] == b'@' && is_inside_tag(input, i) {
1343            if let Some((event_name, handler, args, consumed)) =
1344                parse_event_attr(component, input, i)?
1345            {
1346                meta.events.push((event_name, handler, args));
1347                meta.html.push_str("data-ev=\"1\"");
1348                i += consumed;
1349                continue;
1350            }
1351        }
1352
1353        // w-ref stays in static HTML as-is — the runtime binds from the DOM directly.
1354        // No metadata entry needed; the attribute value IS the property name.
1355
1356        // Copy character
1357        let ch = &input[i..];
1358        if let Some(c) = ch.chars().next() {
1359            meta.html.push(c);
1360            i += c.len_utf8();
1361        } else {
1362            i += 1;
1363        }
1364    }
1365
1366    Ok(meta)
1367}
1368
1369fn compile_text_binding_at(
1370    input: &str,
1371    index: usize,
1372    meta: &mut TemplateSectionMeta,
1373) -> Option<usize> {
1374    let bytes = input.as_bytes();
1375
1376    if index + 4 < bytes.len()
1377        && bytes[index] == b'{'
1378        && bytes[index + 1] == b'{'
1379        && bytes[index + 2] == b'{'
1380    {
1381        if let Some(end) = find_brace_end(input, index + 3, 3) {
1382            let expr = input[index + 3..end].trim();
1383            let idx = meta.text_bindings.len();
1384            meta.text_bindings.push((expr.to_string(), true));
1385            emit_text_marker(meta, idx);
1386            return Some(end + 3);
1387        }
1388    }
1389
1390    if index + 3 < bytes.len() && bytes[index] == b'{' && bytes[index + 1] == b'{' {
1391        if let Some(end) = find_brace_end(input, index + 2, 2) {
1392            let expr = input[index + 2..end].trim();
1393            let idx = meta.text_bindings.len();
1394            meta.text_bindings.push((expr.to_string(), false));
1395            emit_text_marker(meta, idx);
1396            return Some(end + 2);
1397        }
1398    }
1399
1400    None
1401}
1402
1403fn emit_text_marker(meta: &mut TemplateSectionMeta, index: usize) {
1404    meta.text_marker_offsets.push(meta.html.len());
1405    meta.html.push_str(TEXT_MARKER_PREFIX);
1406    let _ = write!(meta.html, "{index}");
1407    meta.html.push_str(TEXT_MARKER_SUFFIX);
1408}
1409
1410fn compile_style_content(input: &str, meta: &mut TemplateSectionMeta) {
1411    let bytes = input.as_bytes();
1412    let len = bytes.len();
1413    let mut index = 0usize;
1414
1415    while index < len {
1416        if index + 1 < len && bytes[index] == b'/' && bytes[index + 1] == b'*' {
1417            if let Some(close) = input[index + 2..].find("*/") {
1418                let end = index + 2 + close + 2;
1419                let comment = &input[index..end];
1420                if compile_css_signal_comment(comment, meta) {
1421                    index = end;
1422                    continue;
1423                }
1424                if comment_policy::is_legal_css_comment(comment) {
1425                    meta.html.push_str(comment);
1426                }
1427                index = end;
1428                continue;
1429            }
1430        }
1431
1432        if comment_policy::is_css_line_comment_start(input, index) {
1433            let end = comment_policy::find_css_line_comment_end(input, index + 2);
1434            let comment = &input[index..end];
1435            if comment_policy::is_legal_css_comment(comment) {
1436                meta.html.push_str(comment);
1437            }
1438            index = end;
1439            continue;
1440        }
1441
1442        if let Some(next) = compile_text_binding_at(input, index, meta) {
1443            index = next;
1444            continue;
1445        }
1446
1447        let Some(ch) = input[index..].chars().next() else {
1448            break;
1449        };
1450        meta.html.push(ch);
1451        index += ch.len_utf8();
1452    }
1453}
1454
1455fn compile_css_signal_comment(comment: &str, meta: &mut TemplateSectionMeta) -> bool {
1456    if let Some(signal) = comment_policy::parse_css_signal_comment(comment) {
1457        let idx = meta.text_bindings.len();
1458        meta.text_bindings.push((signal.path, signal.raw));
1459        emit_text_marker(meta, idx);
1460        return true;
1461    }
1462
1463    false
1464}
1465
1466fn find_style_element_bounds(input: &str, index: usize) -> Option<(usize, usize, usize)> {
1467    let (content_start, close_start, close_end) = style_element_bounds(&input[index..])?;
1468    Some((
1469        index + content_start,
1470        index + close_start,
1471        index + close_end,
1472    ))
1473}
1474
1475#[derive(Clone)]
1476enum FragmentNode {
1477    Element(FragmentElement),
1478    Text(String),
1479    TextMarker(usize),
1480    Comment(String),
1481}
1482
1483#[derive(Clone)]
1484struct FragmentElement {
1485    tag_name: String,
1486    attrs: Vec<FragmentAttr>,
1487    children: Vec<FragmentNode>,
1488    self_closing: bool,
1489}
1490
1491#[derive(Clone)]
1492struct FragmentAttr {
1493    name: String,
1494    value: Option<String>,
1495}
1496
1497fn finalize_template_section(meta: &mut TemplateSectionMeta) {
1498    let raw_html = std::mem::take(&mut meta.html);
1499    let text_marker_offsets = std::mem::take(&mut meta.text_marker_offsets);
1500    let nodes = parse_fragment_nodes(&raw_html, &text_marker_offsets);
1501    let text_bindings = meta.text_bindings.clone();
1502    let mut finalized_html = String::with_capacity(raw_html.len());
1503    let mut text_runs = Vec::new();
1504    let mut attr_groups = Vec::new();
1505    let mut condition_slots = vec![None; meta.conditionals.len()];
1506    let mut repeat_slots = vec![None; meta.repeats.len()];
1507    let mut event_targets = vec![None; meta.events.len()];
1508    let mut event_cursor = 0usize;
1509
1510    process_fragment_children(
1511        &nodes,
1512        &[],
1513        &text_bindings,
1514        &mut finalized_html,
1515        &mut text_runs,
1516        &mut attr_groups,
1517        &mut condition_slots,
1518        &mut repeat_slots,
1519        &mut event_targets,
1520        &mut event_cursor,
1521    );
1522    debug_assert_eq!(event_cursor, meta.events.len());
1523
1524    meta.html = finalized_html;
1525    meta.text_runs = text_runs;
1526    meta.attr_groups = attr_groups;
1527    meta.condition_slots = condition_slots.into_iter().flatten().collect();
1528    meta.repeat_slots = repeat_slots.into_iter().flatten().collect();
1529    meta.event_targets = event_targets.into_iter().flatten().collect();
1530}
1531
1532#[allow(clippy::too_many_arguments)]
1533fn process_fragment_children(
1534    nodes: &[FragmentNode],
1535    parent_path: &[usize],
1536    text_bindings: &[(String, bool)],
1537    out: &mut String,
1538    text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
1539    attr_groups: &mut Vec<(Vec<usize>, usize, usize)>,
1540    condition_slots: &mut [Option<SlotLocator>],
1541    repeat_slots: &mut [Option<SlotLocator>],
1542    event_targets: &mut [Option<Vec<usize>>],
1543    event_cursor: &mut usize,
1544) {
1545    let mut child_index = 0usize;
1546    let mut index = 0usize;
1547    // Browser HTML parsing merges adjacent emitted text into a single Text node.
1548    // Track that here so marker-free locator paths match the real client DOM.
1549    let mut previous_emitted_text = false;
1550    let mut slot_orders = std::collections::BTreeMap::<usize, usize>::new();
1551
1552    while index < nodes.len() {
1553        if let Some((parts, consumed, has_dynamic, is_raw)) =
1554            collect_text_run(&nodes[index..], text_bindings)
1555        {
1556            if has_dynamic {
1557                // Decode HTML entities in static parts so that runtime
1558                // textContent assignment renders decoded characters
1559                // (e.g. `&gt;` → `>`).  Static-only text runs are
1560                // baked into `meta.h` and parsed via innerHTML which
1561                // handles entity decoding natively.
1562                let decoded_parts = parts
1563                    .into_iter()
1564                    .map(|part| match part {
1565                        CompiledAttrPart::Static(s) => {
1566                            let decoded = html_escape::decode_html_entities(&s);
1567                            CompiledAttrPart::Static(decoded.into_owned())
1568                        }
1569                        other => other,
1570                    })
1571                    .collect();
1572                let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1573                slot_orders.insert(child_index, order + 1);
1574                text_runs.push((
1575                    SlotLocator {
1576                        parent_path: parent_path.to_vec(),
1577                        before_index: child_index,
1578                        order,
1579                    },
1580                    decoded_parts,
1581                    is_raw,
1582                ));
1583            } else {
1584                let static_text = collect_static_text(&parts);
1585                if !static_text.is_empty() {
1586                    out.push_str(&static_text);
1587                    if !previous_emitted_text {
1588                        child_index += 1;
1589                    }
1590                    previous_emitted_text = true;
1591                }
1592            }
1593            index += consumed;
1594            continue;
1595        }
1596
1597        match &nodes[index] {
1598            FragmentNode::TextMarker(_) => {}
1599            FragmentNode::Comment(data) => {
1600                if let Some(idx) = parse_marker_index(data, "c:") {
1601                    if let Some(slot) = condition_slots.get_mut(idx) {
1602                        let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1603                        slot_orders.insert(child_index, order + 1);
1604                        *slot = Some(SlotLocator {
1605                            parent_path: parent_path.to_vec(),
1606                            before_index: child_index,
1607                            order,
1608                        });
1609                    }
1610                } else if let Some(idx) = parse_marker_index(data, "r:") {
1611                    if let Some(slot) = repeat_slots.get_mut(idx) {
1612                        let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1613                        slot_orders.insert(child_index, order + 1);
1614                        *slot = Some(SlotLocator {
1615                            parent_path: parent_path.to_vec(),
1616                            before_index: child_index,
1617                            order,
1618                        });
1619                    }
1620                } else {
1621                    out.push_str("<!--");
1622                    out.push_str(data);
1623                    out.push_str("-->");
1624                    child_index += 1;
1625                    previous_emitted_text = false;
1626                }
1627            }
1628            FragmentNode::Text(text) => {
1629                out.push_str(text);
1630                if !text.is_empty() {
1631                    if !previous_emitted_text {
1632                        child_index += 1;
1633                    }
1634                    previous_emitted_text = true;
1635                }
1636            }
1637            FragmentNode::Element(element) => {
1638                let mut element_path = parent_path.to_vec();
1639                element_path.push(child_index);
1640                serialize_fragment_element(
1641                    element,
1642                    &element_path,
1643                    text_bindings,
1644                    out,
1645                    text_runs,
1646                    attr_groups,
1647                    condition_slots,
1648                    repeat_slots,
1649                    event_targets,
1650                    event_cursor,
1651                );
1652                child_index += 1;
1653                previous_emitted_text = false;
1654            }
1655        }
1656
1657        index += 1;
1658    }
1659}
1660
1661#[allow(clippy::too_many_arguments)]
1662fn serialize_fragment_element(
1663    element: &FragmentElement,
1664    element_path: &[usize],
1665    text_bindings: &[(String, bool)],
1666    out: &mut String,
1667    text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
1668    attr_groups: &mut Vec<(Vec<usize>, usize, usize)>,
1669    condition_slots: &mut [Option<SlotLocator>],
1670    repeat_slots: &mut [Option<SlotLocator>],
1671    event_targets: &mut [Option<Vec<usize>>],
1672    event_cursor: &mut usize,
1673) {
1674    out.push('<');
1675    out.push_str(&element.tag_name);
1676
1677    for attr in &element.attrs {
1678        if let Some((start, count)) = parse_attr_group_marker(&attr.name) {
1679            attr_groups.push((element_path.to_vec(), start, count));
1680            continue;
1681        }
1682
1683        if attr.name == "data-ev" {
1684            if let Some(count) = attr.value.as_deref().and_then(parse_event_marker_count) {
1685                let target_path = element_path.to_vec();
1686                for slot in event_targets.iter_mut().skip(*event_cursor).take(count) {
1687                    *slot = Some(target_path.clone());
1688                }
1689                *event_cursor += count;
1690            }
1691            continue;
1692        }
1693
1694        out.push(' ');
1695        out.push_str(&attr.name);
1696        if let Some(value) = &attr.value {
1697            out.push_str("=\"");
1698            emit_html_attr_value(value, out);
1699            out.push('"');
1700        }
1701    }
1702
1703    if element.self_closing {
1704        out.push_str(" />");
1705        return;
1706    }
1707
1708    out.push('>');
1709    process_fragment_children(
1710        &element.children,
1711        element_path,
1712        text_bindings,
1713        out,
1714        text_runs,
1715        attr_groups,
1716        condition_slots,
1717        repeat_slots,
1718        event_targets,
1719        event_cursor,
1720    );
1721    out.push_str("</");
1722    out.push_str(&element.tag_name);
1723    out.push('>');
1724}
1725
1726fn collect_text_run(
1727    nodes: &[FragmentNode],
1728    text_bindings: &[(String, bool)],
1729) -> Option<(Vec<CompiledAttrPart>, usize, bool, bool)> {
1730    let mut parts = Vec::new();
1731    let mut consumed = 0usize;
1732    let mut has_dynamic = false;
1733    let mut is_raw = false;
1734
1735    for node in nodes {
1736        match node {
1737            FragmentNode::Text(text) => {
1738                if !text.is_empty() {
1739                    parts.push(CompiledAttrPart::Static(text.clone()));
1740                }
1741                consumed += 1;
1742            }
1743            FragmentNode::Comment(_) => break,
1744            FragmentNode::TextMarker(index) => {
1745                if let Some((path, raw)) = text_bindings.get(*index) {
1746                    parts.push(CompiledAttrPart::Dynamic(path.clone()));
1747                    has_dynamic = true;
1748                    if *raw {
1749                        is_raw = true;
1750                    }
1751                    consumed += 1;
1752                } else {
1753                    break;
1754                }
1755            }
1756            FragmentNode::Element(_) => break,
1757        }
1758    }
1759
1760    if consumed == 0 {
1761        return None;
1762    }
1763
1764    Some((parts, consumed, has_dynamic, is_raw))
1765}
1766
1767fn collect_static_text(parts: &[CompiledAttrPart]) -> String {
1768    let mut text = String::new();
1769    for part in parts {
1770        if let CompiledAttrPart::Static(value) = part {
1771            text.push_str(value);
1772        }
1773    }
1774    text
1775}
1776
1777fn parse_marker_index(data: &str, prefix: &str) -> Option<usize> {
1778    data.strip_prefix(prefix)?.parse().ok()
1779}
1780
1781fn parse_attr_group_marker(name: &str) -> Option<(usize, usize)> {
1782    if let Some(start) = name.strip_prefix("data-w-b-") {
1783        return start.parse::<usize>().ok().map(|index| (index, 1));
1784    }
1785
1786    let rest = name.strip_prefix("data-w-c-")?;
1787    let mut parts = rest.split('-');
1788    let start = parts.next()?.parse::<usize>().ok()?;
1789    let count = parts.next()?.parse::<usize>().ok()?;
1790    Some((start, count))
1791}
1792
1793fn parse_event_marker_count(value: &str) -> Option<usize> {
1794    let count = value.parse::<usize>().ok()?;
1795    (count > 0).then_some(count)
1796}
1797
1798fn emit_html_attr_value(value: &str, out: &mut String) {
1799    for ch in value.chars() {
1800        match ch {
1801            '"' => out.push_str("&quot;"),
1802            _ => out.push(ch),
1803        }
1804    }
1805}
1806
1807fn parse_fragment_nodes(input: &str, text_marker_offsets: &[usize]) -> Vec<FragmentNode> {
1808    let mut roots = Vec::new();
1809    let mut stack: Vec<FragmentElement> = Vec::new();
1810    let bytes = input.as_bytes();
1811    let len = bytes.len();
1812    let mut index = 0usize;
1813
1814    while index < len {
1815        let remaining = &input[index..];
1816        if let Some((marker_index, marker_end)) =
1817            find_text_marker_comment(input, index, 0, text_marker_offsets)
1818        {
1819            push_fragment_node(
1820                &mut roots,
1821                &mut stack,
1822                FragmentNode::TextMarker(marker_index),
1823            );
1824            index = marker_end;
1825            continue;
1826        }
1827
1828        if remaining.starts_with("<!--") {
1829            if let Some(close) = remaining.find("-->") {
1830                push_fragment_node(
1831                    &mut roots,
1832                    &mut stack,
1833                    FragmentNode::Comment(remaining[4..close].to_string()),
1834                );
1835                index += close + 3;
1836                continue;
1837            }
1838        }
1839
1840        if remaining.starts_with("</") {
1841            if let Some(close) = find_tag_close(remaining) {
1842                if let Some(element) = stack.pop() {
1843                    push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1844                }
1845                index += close + 1;
1846                continue;
1847            }
1848        }
1849
1850        if remaining.starts_with('<') {
1851            if let Some((mut element, consumed)) = parse_fragment_start_tag(remaining) {
1852                if element.self_closing {
1853                    push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1854                } else if element.tag_name.eq_ignore_ascii_case("style") {
1855                    if let Some((content_offset, close_offset, close_end)) =
1856                        style_element_bounds(remaining)
1857                    {
1858                        let content_start = index + content_offset;
1859                        let close_start = index + close_offset;
1860                        push_style_raw_text_nodes(
1861                            &mut element.children,
1862                            &input[content_start..close_start],
1863                            content_start,
1864                            text_marker_offsets,
1865                        );
1866                        push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1867                        index += close_end;
1868                        continue;
1869                    }
1870
1871                    let content_start = index + consumed;
1872                    push_style_raw_text_nodes(
1873                        &mut element.children,
1874                        &input[content_start..],
1875                        content_start,
1876                        text_marker_offsets,
1877                    );
1878                    push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1879                    index = len;
1880                    continue;
1881                } else {
1882                    stack.push(element);
1883                }
1884                index += consumed;
1885                continue;
1886            }
1887        }
1888
1889        let next = remaining.find('<').unwrap_or(remaining.len());
1890        let text = &remaining[..next];
1891        if !text.is_empty() {
1892            push_fragment_node(&mut roots, &mut stack, FragmentNode::Text(text.to_string()));
1893        }
1894        index += next.max(1);
1895    }
1896
1897    while let Some(element) = stack.pop() {
1898        push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1899    }
1900
1901    roots
1902}
1903
1904fn push_style_raw_text_nodes(
1905    children: &mut Vec<FragmentNode>,
1906    input: &str,
1907    base_offset: usize,
1908    text_marker_offsets: &[usize],
1909) {
1910    let bytes = input.as_bytes();
1911    let len = bytes.len();
1912    let mut index = 0usize;
1913    let mut text_start = 0usize;
1914
1915    while index + MIN_TEXT_MARKER_LEN <= len {
1916        if let Some((marker_index, marker_end)) =
1917            find_text_marker_comment(input, index, base_offset, text_marker_offsets)
1918        {
1919            if text_start < index {
1920                children.push(FragmentNode::Text(input[text_start..index].to_string()));
1921            }
1922            children.push(FragmentNode::TextMarker(marker_index));
1923            index = marker_end;
1924            text_start = marker_end;
1925            continue;
1926        }
1927        index += 1;
1928    }
1929
1930    if text_start < len {
1931        children.push(FragmentNode::Text(input[text_start..].to_string()));
1932    }
1933}
1934
1935fn find_text_marker_comment(
1936    input: &str,
1937    index: usize,
1938    base_offset: usize,
1939    text_marker_offsets: &[usize],
1940) -> Option<(usize, usize)> {
1941    let bytes = input.as_bytes();
1942    if bytes.get(index..index + TEXT_MARKER_PREFIX.len()) != Some(TEXT_MARKER_PREFIX.as_bytes()) {
1943        return None;
1944    }
1945    if text_marker_offsets
1946        .binary_search(&(base_offset + index))
1947        .is_err()
1948    {
1949        return None;
1950    }
1951
1952    let mut cursor = index + TEXT_MARKER_PREFIX.len();
1953    let digit_start = cursor;
1954    let mut marker_index = 0usize;
1955    while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
1956        marker_index = marker_index
1957            .checked_mul(TEXT_MARKER_INDEX_RADIX)?
1958            .checked_add((bytes[cursor] - b'0') as usize)?;
1959        cursor += 1;
1960    }
1961    if cursor == digit_start
1962        || bytes.get(cursor..cursor + TEXT_MARKER_SUFFIX.len())
1963            != Some(TEXT_MARKER_SUFFIX.as_bytes())
1964    {
1965        return None;
1966    }
1967
1968    Some((marker_index, cursor + TEXT_MARKER_SUFFIX.len()))
1969}
1970
1971fn push_fragment_node(
1972    roots: &mut Vec<FragmentNode>,
1973    stack: &mut [FragmentElement],
1974    node: FragmentNode,
1975) {
1976    if let Some(parent) = stack.last_mut() {
1977        parent.children.push(node);
1978    } else {
1979        roots.push(node);
1980    }
1981}
1982
1983fn parse_fragment_start_tag(input: &str) -> Option<(FragmentElement, usize)> {
1984    if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!--") {
1985        return None;
1986    }
1987
1988    let end = find_tag_close(input)?;
1989    let tag_content = &input[1..end];
1990    let tag_body = tag_content.trim_end();
1991    let self_closing = tag_body.ends_with('/');
1992    let tag_body = if self_closing {
1993        tag_body[..tag_body.len().saturating_sub(1)].trim_end()
1994    } else {
1995        tag_body
1996    };
1997
1998    let body_bytes = tag_body.as_bytes();
1999    let mut cursor = 0usize;
2000    while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
2001        cursor += 1;
2002    }
2003
2004    let tag_name = &tag_body[..cursor];
2005    if tag_name.is_empty() {
2006        return None;
2007    }
2008
2009    let mut attrs = Vec::new();
2010    while cursor < body_bytes.len() {
2011        while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2012            cursor += 1;
2013        }
2014        if cursor >= body_bytes.len() {
2015            break;
2016        }
2017
2018        let attr_start = cursor;
2019        while cursor < body_bytes.len()
2020            && !body_bytes[cursor].is_ascii_whitespace()
2021            && body_bytes[cursor] != b'='
2022        {
2023            cursor += 1;
2024        }
2025        if attr_start == cursor {
2026            cursor += 1;
2027            continue;
2028        }
2029
2030        let name = tag_body[attr_start..cursor].to_string();
2031        while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2032            cursor += 1;
2033        }
2034
2035        let mut value: Option<String> = None;
2036        if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
2037            cursor += 1;
2038            while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2039                cursor += 1;
2040            }
2041
2042            if cursor < body_bytes.len()
2043                && (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
2044            {
2045                let quote = body_bytes[cursor];
2046                cursor += 1;
2047                let value_start = cursor;
2048                while cursor < body_bytes.len() && body_bytes[cursor] != quote {
2049                    cursor += 1;
2050                }
2051                value = Some(tag_body[value_start..cursor].to_string());
2052                if cursor < body_bytes.len() {
2053                    cursor += 1;
2054                }
2055            } else {
2056                let value_start = cursor;
2057                while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
2058                    cursor += 1;
2059                }
2060                value = Some(tag_body[value_start..cursor].to_string());
2061            }
2062        }
2063
2064        attrs.push(FragmentAttr { name, value });
2065    }
2066
2067    Some((
2068        FragmentElement {
2069            tag_name: tag_name.to_string(),
2070            attrs,
2071            children: Vec::new(),
2072            self_closing: self_closing || is_void_element(tag_name),
2073        },
2074        end + 1,
2075    ))
2076}
2077
2078fn is_void_element(tag_name: &str) -> bool {
2079    matches!(
2080        tag_name,
2081        "area"
2082            | "base"
2083            | "br"
2084            | "col"
2085            | "embed"
2086            | "hr"
2087            | "img"
2088            | "input"
2089            | "link"
2090            | "meta"
2091            | "param"
2092            | "source"
2093            | "track"
2094            | "wbr"
2095    )
2096}
2097
2098// ── Parsing helpers ────────────────────────────────────────────────
2099
2100/// Find the position of `count` consecutive closing braces (`}`).
2101/// Returns `Some(start_of_closing_braces)` or `None` if not found.
2102fn find_brace_end(input: &str, start: usize, count: usize) -> Option<usize> {
2103    let bytes = input.as_bytes();
2104    let mut i = start;
2105    while i + count - 1 < bytes.len() {
2106        let all_close = (0..count).all(|j| bytes[i + j] == b'}');
2107        if all_close {
2108            return Some(i);
2109        }
2110        i += 1;
2111    }
2112    None
2113}
2114
2115/// Check whether byte position `pos` is inside an HTML tag (between `<` and `>`).
2116/// Scans backward from `pos` — returns `true` if we hit `<` before `>`.
2117fn is_inside_tag(input: &str, pos: usize) -> bool {
2118    let bytes = input.as_bytes();
2119    let mut i = pos;
2120    while i > 0 {
2121        i -= 1;
2122        if bytes[i] == b'>' {
2123            return false;
2124        }
2125        if bytes[i] == b'<' {
2126            return true;
2127        }
2128    }
2129    false
2130}
2131
2132/// Strip `<template shadowrootmode="…">…</template>` wrapper, returning inner content.
2133/// Returns the input unchanged if no wrapper is present.
2134fn strip_template_wrapper(html: &str) -> &str {
2135    if !html.starts_with("<template") {
2136        return html;
2137    }
2138    let Some(open_end) = find_tag_close(html) else {
2139        return html;
2140    };
2141    let inner_start = open_end + 1;
2142    let inner_end = html.rfind("</template>").unwrap_or(html.len());
2143    if inner_start >= inner_end {
2144        return "";
2145    }
2146    &html[inner_start..inner_end]
2147}
2148
2149fn find_next_block_token(input: &str, cursor: usize, token: &str) -> Option<usize> {
2150    let bytes = input.as_bytes();
2151    let token_bytes = token.as_bytes();
2152    let mut index = cursor;
2153    let mut in_tag = false;
2154    let mut in_comment = false;
2155    let mut quote: Option<u8> = None;
2156
2157    while index + token_bytes.len() <= bytes.len() {
2158        if in_comment {
2159            if bytes[index..].starts_with(b"-->") {
2160                in_comment = false;
2161                index += 3;
2162                continue;
2163            }
2164            index += 1;
2165            continue;
2166        }
2167
2168        if let Some(active) = quote {
2169            if bytes[index] == active {
2170                quote = None;
2171            }
2172            index += 1;
2173            continue;
2174        }
2175
2176        if in_tag {
2177            match bytes[index] {
2178                b'"' | b'\'' => quote = Some(bytes[index]),
2179                b'>' => in_tag = false,
2180                _ => {}
2181            }
2182            index += 1;
2183            continue;
2184        }
2185
2186        if &bytes[index..index + token_bytes.len()] == token_bytes {
2187            return Some(index);
2188        }
2189
2190        if bytes[index] == b'<' {
2191            if bytes[index..].starts_with(b"<!--") {
2192                in_comment = true;
2193                index += 4;
2194                continue;
2195            }
2196            in_tag = true;
2197        }
2198
2199        index += 1;
2200    }
2201
2202    None
2203}
2204
2205fn find_next_block_open(input: &str, cursor: usize, name: &str) -> Option<usize> {
2206    let token = format!("<{name}");
2207    let bytes = input.as_bytes();
2208    let token_bytes = token.as_bytes();
2209    let mut search = cursor;
2210
2211    while let Some(index) = find_next_block_token(input, search, &token) {
2212        let next = bytes.get(index + token_bytes.len()).copied();
2213        if match next {
2214            None => true,
2215            Some(byte) => byte == b'>' || byte.is_ascii_whitespace(),
2216        } {
2217            return Some(index);
2218        }
2219        search = index + 1;
2220    }
2221
2222    None
2223}
2224
2225fn find_next_block_close(input: &str, cursor: usize, name: &str) -> Option<usize> {
2226    let token = format!("</{name}>");
2227    find_next_block_token(input, cursor, &token)
2228}
2229
2230fn find_matching_block_end(input: &str, name: &str) -> Option<usize> {
2231    let open_token = format!("<{name}");
2232    let close_token = format!("</{name}>");
2233    if !input.starts_with(&open_token) {
2234        return None;
2235    }
2236
2237    let mut depth = 1usize;
2238    let mut cursor = open_token.len();
2239
2240    while cursor < input.len() {
2241        let next_open = find_next_block_open(input, cursor, name);
2242        let next_close = find_next_block_close(input, cursor, name);
2243
2244        match (next_open, next_close) {
2245            (_, None) => return None,
2246            (Some(open_pos), Some(close_pos)) if open_pos < close_pos => {
2247                depth += 1;
2248                cursor = open_pos + open_token.len();
2249            }
2250            (_, Some(close_pos)) => {
2251                depth = depth.saturating_sub(1);
2252                if depth == 0 {
2253                    return Some(close_pos);
2254                }
2255                cursor = close_pos + close_token.len();
2256            }
2257        }
2258    }
2259
2260    None
2261}
2262
2263/// Parse `<if condition="EXPR">BODY</if>` → `(condition, body, bytes_consumed)`.
2264///
2265/// Only handles the outermost `<if>` — nested `<if>` blocks inside the body
2266/// are left as raw HTML for the client runtime to process.
2267fn parse_if_block(input: &str) -> Option<(ConditionExpr, String, usize)> {
2268    let close_tag = "</if>";
2269    let end_pos = find_matching_block_end(input, "if")?;
2270    let tag_content = &input[..end_pos];
2271
2272    // Extract condition
2273    let cond_start = tag_content.find("condition=\"")? + "condition=\"".len();
2274    let cond_end = tag_content[cond_start..].find('"')? + cond_start;
2275    let condition = compile_condition_expr(&tag_content[cond_start..cond_end]);
2276
2277    // Extract body (after the closing >)
2278    let body_start = find_tag_close(tag_content)? + 1;
2279    let body = tag_content[body_start..].trim().to_string();
2280
2281    Some((condition, body, end_pos + close_tag.len()))
2282}
2283
2284fn compile_condition_expr(input: &str) -> ConditionExpr {
2285    let parser = ConditionParser::new();
2286    match parser.parse(input) {
2287        Ok(condition) => condition,
2288        // Component HTML is compiled after the main parser has already validated
2289        // condition syntax. Keep the emitted metadata structurally valid if that
2290        // earlier guarantee regresses.
2291        Err(_) => ConditionExpr::identifier(input),
2292    }
2293}
2294
2295/// Parse `<for each="item in collection">BODY</for>` → `(collection, item_var, body, consumed)`.
2296///
2297/// The `each` attribute must follow the `"item in collection"` pattern.
2298/// The body template retains `{{expr}}` mustaches — they are resolved by the
2299/// client runtime during reconciliation.
2300fn parse_for_block(input: &str) -> Option<(String, String, String, usize)> {
2301    let close_tag = "</for>";
2302    let end_pos = find_matching_block_end(input, "for")?;
2303    let tag_content = &input[..end_pos];
2304
2305    // Extract each="item in collection"
2306    let each_start = tag_content.find("each=\"")? + "each=\"".len();
2307    let each_end = tag_content[each_start..].find('"')? + each_start;
2308    let each_val = &tag_content[each_start..each_end];
2309
2310    let parts: Vec<&str> = each_val.splitn(3, ' ').collect();
2311    if parts.len() != 3 || parts[1] != "in" {
2312        return None;
2313    }
2314    let item_var = parts[0].to_string();
2315    let collection = parts[2].to_string();
2316
2317    // Extract body
2318    let body_start = find_tag_close(tag_content)? + 1;
2319    let body = tag_content[body_start..].trim().to_string();
2320
2321    Some((collection, item_var, body, end_pos + close_tag.len()))
2322}
2323
2324/// Build a [`Diagnostic`] for an invalid `@event` handler.
2325///
2326/// Names the owning component (and element, when known) so the build error
2327/// points at the exact template to fix instead of only echoing the expression.
2328fn invalid_event_handler(
2329    component: &str,
2330    element: Option<&str>,
2331    event_name: &str,
2332    raw: &str,
2333) -> Diagnostic {
2334    let mut diag = Diagnostic::error(format!("invalid @{event_name} handler"))
2335        .code(crate::diagnostic::codes::INVALID_EVENT_HANDLER)
2336        .component(component)
2337        .snippet(format!("@{event_name}=\"{raw}\""))
2338        .help(format!(
2339            "use @{event_name}=\"{{handler()}}\" or @{event_name}=\"{{handler(e)}}\" \
2340             to pass the event"
2341        ));
2342    if let Some(tag) = element {
2343        diag = diag.element(tag);
2344    }
2345    diag
2346}
2347
2348#[cold]
2349#[inline(never)]
2350fn scriptless_component_events(component: &str) -> Diagnostic {
2351    Diagnostic::error("scriptless component contains event bindings")
2352        .code(codes::SCRIPTLESS_EVENT_HANDLER)
2353        .component(component)
2354        .help(
2355            "add a .ts or .js file that defines a WebUIElement for this tag, or remove the @event binding",
2356        )
2357}
2358
2359/// Parse `@event="{handler()}"` or `@event={handler()}` into event metadata.
2360///
2361/// Supports three value quoting styles:
2362/// - `@click="{onClick()}"` — quoted with braces inside
2363/// - `@click={onClick()}` — unquoted braces
2364/// - `@click='onClick()'` — single-quoted
2365///
2366/// The handler name and full argument list are extracted from the function call syntax.
2367/// `component` is the owning component tag, used only for error messages.
2368fn parse_event_attr(component: &str, input: &str, pos: usize) -> Result<Option<ParsedEventAttr>> {
2369    let remaining = &input[pos..];
2370    let Some(eq_pos) = remaining.find('=') else {
2371        return Ok(None);
2372    };
2373    let event_name = remaining[1..eq_pos].to_string(); // skip @
2374
2375    let after_eq = &remaining[eq_pos + 1..];
2376    if after_eq.is_empty() {
2377        return Ok(None);
2378    }
2379
2380    let first = after_eq.as_bytes()[0];
2381    let (raw_value, total_consumed) = if first == b'"' || first == b'\'' {
2382        let value_start = eq_pos + 2;
2383        let Some(close) = remaining[value_start..].find(first as char) else {
2384            return Ok(None);
2385        };
2386        (
2387            &remaining[value_start..value_start + close],
2388            value_start + close + 1,
2389        )
2390    } else if first == b'{' {
2391        let value_start = eq_pos + 2;
2392        let Some(close) = remaining[value_start..].find('}') else {
2393            return Ok(None);
2394        };
2395        (
2396            &remaining[value_start..value_start + close],
2397            value_start + close + 1,
2398        )
2399    } else {
2400        return Ok(None);
2401    };
2402
2403    let inner = raw_value
2404        .trim()
2405        .strip_prefix('{')
2406        .and_then(|s| s.strip_suffix('}'))
2407        .unwrap_or(raw_value)
2408        .trim();
2409
2410    // Extract method name and argument specs from "handler(...)".
2411    if inner.is_empty() {
2412        return Ok(None);
2413    }
2414    match parse_event_handler(inner) {
2415        EventHandler::Valid(handler_name, args) => {
2416            Ok(Some((event_name, handler_name, args, total_consumed)))
2417        }
2418        EventHandler::Empty => Ok(None),
2419        EventHandler::Invalid(_raw) => {
2420            Err(invalid_event_handler(component, None, &event_name, inner).into())
2421        }
2422    }
2423}
2424
2425fn parse_regular_tag(
2426    component: &str,
2427    input: &str,
2428    meta: &mut TemplateSectionMeta,
2429) -> Result<Option<(String, usize)>> {
2430    if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!") {
2431        return Ok(None);
2432    }
2433
2434    let bytes = input.as_bytes();
2435    if bytes.len() < 2 || !bytes[1].is_ascii_alphabetic() {
2436        return Ok(None);
2437    }
2438
2439    let Some(end) = find_tag_close(input) else {
2440        return Ok(None);
2441    };
2442    let tag_content = &input[1..end];
2443    let tag_body = tag_content.trim_end();
2444    let self_closing = tag_body.ends_with('/');
2445    let tag_body = if self_closing {
2446        tag_body[..tag_body.len().saturating_sub(1)].trim_end()
2447    } else {
2448        tag_body
2449    };
2450
2451    let body_bytes = tag_body.as_bytes();
2452    let mut cursor = 0;
2453    while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
2454        cursor += 1;
2455    }
2456
2457    let tag_name = &tag_body[..cursor];
2458    if tag_name.is_empty() {
2459        return Ok(None);
2460    }
2461
2462    let mut out = String::with_capacity(tag_body.len() + 24);
2463    out.push('<');
2464    out.push_str(tag_name);
2465
2466    let binding_start = meta.attr_bindings.len();
2467    let event_start = meta.events.len();
2468    let mut binding_count = 0usize;
2469
2470    while cursor < body_bytes.len() {
2471        while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2472            cursor += 1;
2473        }
2474        if cursor >= body_bytes.len() {
2475            break;
2476        }
2477
2478        let attr_start = cursor;
2479        while cursor < body_bytes.len()
2480            && !body_bytes[cursor].is_ascii_whitespace()
2481            && body_bytes[cursor] != b'='
2482        {
2483            cursor += 1;
2484        }
2485        if attr_start == cursor {
2486            cursor += 1;
2487            continue;
2488        }
2489
2490        let name = &tag_body[attr_start..cursor];
2491
2492        while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2493            cursor += 1;
2494        }
2495
2496        let mut value: Option<&str> = None;
2497        if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
2498            cursor += 1;
2499            while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2500                cursor += 1;
2501            }
2502
2503            if cursor < body_bytes.len()
2504                && (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
2505            {
2506                let quote = body_bytes[cursor];
2507                cursor += 1;
2508                let value_start = cursor;
2509                while cursor < body_bytes.len() && body_bytes[cursor] != quote {
2510                    cursor += 1;
2511                }
2512                value = Some(&tag_body[value_start..cursor]);
2513                if cursor < body_bytes.len() {
2514                    cursor += 1;
2515                }
2516            } else {
2517                let value_start = cursor;
2518                while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
2519                    cursor += 1;
2520                }
2521                value = Some(&tag_body[value_start..cursor]);
2522            }
2523        }
2524
2525        let raw_attr = tag_body[attr_start..cursor].trim();
2526        if raw_attr.is_empty() {
2527            continue;
2528        }
2529
2530        if let Some(event_name) = name.strip_prefix('@') {
2531            let Some(raw_value) = value else {
2532                continue;
2533            };
2534            match parse_event_handler(raw_value) {
2535                EventHandler::Valid(handler_name, args) => {
2536                    meta.events
2537                        .push((event_name.to_string(), handler_name, args));
2538                }
2539                EventHandler::Invalid(raw) => {
2540                    return Err(
2541                        invalid_event_handler(component, Some(tag_name), event_name, &raw).into(),
2542                    );
2543                }
2544                EventHandler::Empty => {}
2545            }
2546            continue;
2547        }
2548
2549        if name == "w-ref" {
2550            // Validate: w-ref must use {braces} to bind to a component property.
2551            // This is a fatal build-time check — the runtime also validates.
2552            if let Some(val) = value {
2553                if !val.starts_with('{') || !val.ends_with('}') {
2554                    return Err(Diagnostic::error("invalid w-ref binding")
2555                        .code(crate::diagnostic::codes::INVALID_W_REF)
2556                        .component(component)
2557                        .element(tag_name)
2558                        .snippet(format!("w-ref=\"{val}\""))
2559                        .help(format!(
2560                            "use w-ref={{{val}}} with braces to bind to a component property"
2561                        ))
2562                        .into());
2563                }
2564            }
2565            out.push(' ');
2566            out.push_str(raw_attr);
2567            continue;
2568        }
2569
2570        if let Some(bool_name) = name.strip_prefix('?') {
2571            if let Some(raw_value) = value.and_then(extract_single_handlebars) {
2572                meta.attr_bindings.push(CompiledAttrBinding::Boolean {
2573                    name: bool_name.to_string(),
2574                    condition: compile_condition_expr(&raw_value),
2575                });
2576                binding_count += 1;
2577            }
2578            continue;
2579        }
2580
2581        if name.starts_with(':') {
2582            if let Some(raw_value) = value.and_then(extract_single_handlebars) {
2583                // Strip the ':' prefix and convert to camelCase for the JS property name.
2584                // The runtime uses el[name] = value directly — no conversion needed.
2585                let prop_name = webui_protocol::attrs::attribute_to_camel(
2586                    name.strip_prefix(':').unwrap_or(name),
2587                );
2588                meta.attr_bindings.push(CompiledAttrBinding::Complex {
2589                    name: prop_name,
2590                    value: raw_value,
2591                });
2592                binding_count += 1;
2593            }
2594            continue;
2595        }
2596
2597        if let Some(raw_value) = value {
2598            if let Some(parts) = parse_attr_parts(raw_value) {
2599                let is_simple =
2600                    parts.len() == 1 && matches!(parts.first(), Some(CompiledAttrPart::Dynamic(_)));
2601                if is_simple {
2602                    if let Some(CompiledAttrPart::Dynamic(path)) = parts.into_iter().next() {
2603                        meta.attr_bindings.push(CompiledAttrBinding::Simple {
2604                            name: name.to_string(),
2605                            value: path,
2606                        });
2607                    }
2608                } else {
2609                    meta.attr_bindings.push(CompiledAttrBinding::Template {
2610                        name: name.to_string(),
2611                        parts,
2612                    });
2613                }
2614                binding_count += 1;
2615                continue;
2616            }
2617        }
2618
2619        out.push(' ');
2620        out.push_str(raw_attr);
2621    }
2622
2623    if binding_count == 1 {
2624        out.push_str(" data-w-b-");
2625        out.push_str(&binding_start.to_string());
2626    } else if binding_count > 1 {
2627        out.push_str(" data-w-c-");
2628        out.push_str(&binding_start.to_string());
2629        out.push('-');
2630        out.push_str(&binding_count.to_string());
2631    }
2632
2633    let event_count = meta.events.len().saturating_sub(event_start);
2634    if event_count > 0 {
2635        out.push_str(" data-ev=\"");
2636        let _ = write!(out, "{}", event_count);
2637        out.push('"');
2638    }
2639
2640    if self_closing {
2641        out.push_str(" />");
2642    } else {
2643        out.push('>');
2644    }
2645
2646    Ok(Some((out, end + 1)))
2647}
2648
2649enum EventHandler {
2650    Valid(String, Vec<EventArg>),
2651    Invalid(String),
2652    Empty,
2653}
2654
2655fn parse_event_handler(raw_value: &str) -> EventHandler {
2656    let inner = raw_value
2657        .trim()
2658        .strip_prefix('{')
2659        .and_then(|s| s.strip_suffix('}'))
2660        .unwrap_or(raw_value)
2661        .trim();
2662    if inner.is_empty() {
2663        return EventHandler::Empty;
2664    }
2665    match inner.find('(') {
2666        Some(paren) => {
2667            let close = match inner.rfind(')') {
2668                Some(close) if close > paren => close,
2669                _ => return EventHandler::Invalid(inner.to_string()),
2670            };
2671            if !inner[close + 1..].trim().is_empty() {
2672                return EventHandler::Invalid(inner.to_string());
2673            }
2674            let handler_name = inner[..paren].trim();
2675            if !is_valid_identifier(handler_name) {
2676                return EventHandler::Invalid(inner.to_string());
2677            }
2678            let Some(args) = parse_event_args(&inner[paren + 1..close]) else {
2679                return EventHandler::Invalid(inner.to_string());
2680            };
2681            EventHandler::Valid(handler_name.to_string(), args)
2682        }
2683        None => EventHandler::Invalid(inner.to_string()),
2684    }
2685}
2686
2687fn parse_event_args(raw_args: &str) -> Option<Vec<EventArg>> {
2688    if raw_args.trim().is_empty() {
2689        return Some(Vec::new());
2690    }
2691
2692    let raw_parts = split_event_args(raw_args)?;
2693    let mut args = Vec::with_capacity(raw_parts.len());
2694    for arg in raw_parts {
2695        let trimmed = arg.trim();
2696        if trimmed.is_empty() {
2697            return None;
2698        }
2699        args.push(parse_event_arg(trimmed)?);
2700    }
2701    Some(args)
2702}
2703
2704fn split_event_args(raw_args: &str) -> Option<Vec<&str>> {
2705    let mut args = Vec::new();
2706    let mut start = 0usize;
2707    let mut quote: Option<u8> = None;
2708    let mut escaped = false;
2709    for (idx, byte) in raw_args.bytes().enumerate() {
2710        if let Some(q) = quote {
2711            if escaped {
2712                escaped = false;
2713                continue;
2714            }
2715            if byte == b'\\' {
2716                escaped = true;
2717                continue;
2718            }
2719            if byte == q {
2720                quote = None;
2721            }
2722            continue;
2723        }
2724        if byte == b'"' || byte == b'\'' {
2725            quote = Some(byte);
2726            continue;
2727        }
2728        if byte == b',' {
2729            args.push(&raw_args[start..idx]);
2730            start = idx + 1;
2731        }
2732    }
2733    if quote.is_some() || escaped {
2734        return None;
2735    }
2736    args.push(&raw_args[start..]);
2737    Some(args)
2738}
2739
2740fn parse_event_arg(arg: &str) -> Option<EventArg> {
2741    if arg.is_empty() {
2742        return None;
2743    }
2744    if arg == "e" {
2745        return Some(EventArg::Event);
2746    }
2747    if arg == "true" {
2748        return Some(EventArg::Bool(true));
2749    }
2750    if arg == "false" {
2751        return Some(EventArg::Bool(false));
2752    }
2753    if arg == "null" {
2754        return Some(EventArg::Null);
2755    }
2756    if let Some(value) = parse_quoted_event_string(arg) {
2757        return Some(EventArg::String(value));
2758    }
2759    if is_quoted_event_arg_start(arg) {
2760        return None;
2761    }
2762    if is_number_literal(arg) {
2763        return Some(EventArg::Number(arg.to_string()));
2764    }
2765    is_valid_event_path(arg).then(|| EventArg::Path(arg.to_string()))
2766}
2767
2768fn parse_quoted_event_string(arg: &str) -> Option<String> {
2769    let bytes = arg.as_bytes();
2770    if bytes.len() < 2 {
2771        return None;
2772    }
2773    let quote = bytes[0];
2774    if (quote != b'"' && quote != b'\'') || bytes[bytes.len() - 1] != quote {
2775        return None;
2776    }
2777    Some(
2778        arg[1..arg.len() - 1]
2779            .replace("\\\"", "\"")
2780            .replace("\\'", "'"),
2781    )
2782}
2783
2784fn is_number_literal(arg: &str) -> bool {
2785    !arg.is_empty()
2786        && arg.parse::<f64>().is_ok()
2787        && arg
2788            .bytes()
2789            .all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+' | b'e' | b'E'))
2790}
2791
2792fn is_quoted_event_arg_start(arg: &str) -> bool {
2793    matches!(arg.as_bytes().first(), Some(b'"' | b'\''))
2794}
2795
2796fn is_valid_event_path(path: &str) -> bool {
2797    let mut parts = path.split('.');
2798    let Some(first) = parts.next() else {
2799        return false;
2800    };
2801    if !is_valid_identifier(first) {
2802        return false;
2803    }
2804    for part in parts {
2805        if part.is_empty() || (!is_valid_identifier(part) && !is_ascii_digits(part)) {
2806            return false;
2807        }
2808    }
2809    true
2810}
2811
2812fn is_valid_identifier(value: &str) -> bool {
2813    let mut bytes = value.bytes();
2814    let Some(first) = bytes.next() else {
2815        return false;
2816    };
2817    if !is_identifier_start(first) {
2818        return false;
2819    }
2820    bytes.all(is_identifier_continue)
2821}
2822
2823fn is_identifier_start(byte: u8) -> bool {
2824    byte.is_ascii_alphabetic() || byte == b'_' || byte == b'$'
2825}
2826
2827fn is_identifier_continue(byte: u8) -> bool {
2828    is_identifier_start(byte) || byte.is_ascii_digit()
2829}
2830
2831fn is_ascii_digits(value: &str) -> bool {
2832    !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
2833}
2834
2835fn extract_single_handlebars(value: &str) -> Option<String> {
2836    let trimmed = value.trim();
2837    if let Some(inner) = trimmed
2838        .strip_prefix("{{{")
2839        .and_then(|s| s.strip_suffix("}}}"))
2840    {
2841        return Some(inner.trim().to_string());
2842    }
2843
2844    if let Some(inner) = trimmed
2845        .strip_prefix("{{")
2846        .and_then(|s| s.strip_suffix("}}"))
2847    {
2848        return Some(inner.trim().to_string());
2849    }
2850
2851    None
2852}
2853
2854fn parse_attr_parts(value: &str) -> Option<Vec<CompiledAttrPart>> {
2855    let bytes = value.as_bytes();
2856    let len = bytes.len();
2857    let mut i = 0;
2858    let mut last = 0;
2859    let mut dynamic_found = false;
2860    let mut parts = Vec::new();
2861
2862    while i < len {
2863        if i + 2 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
2864            let brace_count = if i + 2 < len && bytes[i + 2] == b'{' {
2865                3
2866            } else {
2867                2
2868            };
2869
2870            if let Some(end) = find_brace_end(value, i + brace_count, brace_count) {
2871                if last < i {
2872                    parts.push(CompiledAttrPart::Static(value[last..i].to_string()));
2873                }
2874                parts.push(CompiledAttrPart::Dynamic(
2875                    value[i + brace_count..end].trim().to_string(),
2876                ));
2877                dynamic_found = true;
2878                i = end + brace_count;
2879                last = i;
2880                continue;
2881            }
2882        }
2883
2884        i += 1;
2885    }
2886
2887    if !dynamic_found {
2888        return None;
2889    }
2890
2891    if last < len {
2892        parts.push(CompiledAttrPart::Static(value[last..].to_string()));
2893    }
2894
2895    Some(parts)
2896}
2897
2898/// Extract `@event` bindings from the opening `<template>` tag.
2899///
2900/// These become "root events" (`re` array) attached to the host element
2901/// rather than to an element inside the shadow DOM.
2902fn extract_root_events(component: &str, html: &str) -> Result<Vec<EventBinding>> {
2903    if !html.starts_with("<template") {
2904        return Ok(Vec::new());
2905    }
2906    let Some(close) = find_tag_close(html) else {
2907        return Ok(Vec::new());
2908    };
2909    let tag = &html[..close];
2910    let mut events = Vec::new();
2911    let bytes = tag.as_bytes();
2912    let len = bytes.len();
2913    let mut i = 0;
2914
2915    while i < len {
2916        if bytes[i] == b'@' {
2917            if let Some((name, handler, args, consumed)) = parse_event_attr(component, tag, i)? {
2918                events.push((name, handler, args));
2919                i += consumed;
2920                continue;
2921            }
2922        }
2923        i += 1;
2924    }
2925
2926    Ok(events)
2927}
2928
2929fn extract_adopted_stylesheet_specifier(html: &str) -> Option<String> {
2930    if !html.starts_with("<template") {
2931        return None;
2932    }
2933    let close = find_tag_close(html)?;
2934    let tag = &html[..close];
2935    let attr = "shadowrootadoptedstylesheets=\"";
2936    let start = tag.find(attr)? + attr.len();
2937    let end = tag[start..].find('"')? + start;
2938    Some(tag[start..end].to_string())
2939}
2940
2941// ── Tests ──────────────────────────────────────────────────────────
2942
2943#[cfg(test)]
2944mod tests {
2945    use super::*;
2946
2947    /// Test helper: compile a template and unwrap. The vast majority of tests
2948    /// exercise valid templates; tests that assert on authoring errors call
2949    /// [`super::generate_compiled_template`] directly and inspect the `Result`.
2950    #[allow(clippy::disallowed_methods)]
2951    fn generate_compiled_template(tag_name: &str, html_content: &str) -> String {
2952        super::generate_compiled_template(tag_name, html_content).expect("valid template compiles")
2953    }
2954
2955    #[allow(clippy::disallowed_methods)]
2956    fn generate_compiled_template_payload(
2957        tag_name: &str,
2958        html_content: &str,
2959    ) -> CompiledTemplatePayload {
2960        super::generate_compiled_template_with_root_source(
2961            tag_name,
2962            html_content,
2963            html_content,
2964            false,
2965            false,
2966        )
2967        .expect("valid template compiles")
2968    }
2969
2970    fn assert_no_client_markers(result: &str) {
2971        assert!(!result.contains("<!--t:"), "text markers should be removed");
2972        assert!(
2973            !result.contains("<!--c:"),
2974            "conditional markers should be removed"
2975        );
2976        assert!(
2977            !result.contains("<!--r:"),
2978            "repeat markers should be removed"
2979        );
2980        assert!(
2981            !result.contains("data-w-b-"),
2982            "attribute binding markers should be removed"
2983        );
2984        assert!(
2985            !result.contains("data-w-c-"),
2986            "attribute binding range markers should be removed"
2987        );
2988        assert!(
2989            !result.contains("data-ev="),
2990            "event markers should be removed"
2991        );
2992    }
2993
2994    #[test]
2995    fn test_skip_event_attributes() {
2996        let mut plugin = WebUIParserPlugin::new();
2997        assert_eq!(plugin.classify_attribute("@click"), AttributeAction::Skip);
2998        assert_eq!(plugin.classify_attribute("@keydown"), AttributeAction::Skip);
2999        assert_eq!(plugin.classify_attribute("w-ref"), AttributeAction::Keep);
3000        assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
3001    }
3002
3003    #[test]
3004    fn test_metadata_has_text_bindings() {
3005        let result = generate_compiled_template("my-comp", "<h1>{{title}}</h1>");
3006        assert_no_client_markers(&result);
3007        assert!(result.contains(r#""h":"<h1></h1>""#));
3008        assert!(result.contains("\"title\""));
3009        assert!(result.contains(r#","tx":[[[[0],0],[["title"]]]]"#));
3010        assert!(!result.contains("{{"));
3011    }
3012
3013    #[test]
3014    fn test_metadata_emits_template_roots_and_observed_attrs() {
3015        let result = generate_compiled_template(
3016            "my-comp",
3017            r#"<h1>{{displayValue}}</h1><input ?readonly="{{readOnly}}" />"#,
3018        );
3019
3020        assert!(result.contains(r#","tr":["displayValue","readOnly"]"#));
3021        assert!(result.contains(r#","ta":["display-value","readonly"]"#));
3022    }
3023
3024    #[test]
3025    fn test_metadata_excludes_repeat_scope_roots() {
3026        let result = generate_compiled_template(
3027            "my-comp",
3028            r#"<for each="item in items"><p>{{item.title}} {{heading}}</p></for>"#,
3029        );
3030        assert!(result.contains(r#","tr":["items","heading"]"#));
3031    }
3032
3033    #[test]
3034    fn test_metadata_strips_html_comments_without_processing_bindings() {
3035        let result = generate_compiled_template(
3036            "my-comp",
3037            r#"<!-- {{title}} @click="{bad()}" --><div>hello</div>"#,
3038        );
3039
3040        assert_no_client_markers(&result);
3041        assert!(result.contains(r#""h":"<div>hello</div>""#));
3042        assert!(!result.contains("title"));
3043        assert!(!result.contains("@click"));
3044        assert!(!result.contains("-->"));
3045    }
3046
3047    #[test]
3048    fn test_metadata_strips_style_comments_without_processing_bindings() {
3049        let result = generate_compiled_template(
3050            "my-comp",
3051            r#"<style>/* prose {{ignored}} */ .x { color: {{color}}; } /*! @license {{kept}} */</style>"#,
3052        );
3053
3054        assert_no_client_markers(&result);
3055        assert!(!result.contains("ignored"));
3056        assert!(result.contains(r#"["color"]"#));
3057        assert!(!result.contains(r#"[["kept"]]"#));
3058        assert!(result.contains("/*! @license {{kept}} */"));
3059    }
3060
3061    #[test]
3062    fn test_metadata_processes_style_signal_comments() {
3063        let result = generate_compiled_template(
3064            "my-comp",
3065            r#"<style>:root{/*{{{tokens.light}}}*/}</style>"#,
3066        );
3067
3068        assert_no_client_markers(&result);
3069        assert!(result.contains(r#"["tokens.light"]"#));
3070        assert!(!result.contains("/*"));
3071        assert!(!result.contains("*/"));
3072    }
3073
3074    #[test]
3075    fn test_metadata_keeps_legal_style_comment_with_html_like_tag_as_raw_text() {
3076        let result = generate_compiled_template(
3077            "my-component",
3078            r#"<style>:host { display: block; }/*! @license The <my-component> element. */.container { padding: 16px; }</style><div>hello</div>"#,
3079        );
3080
3081        assert_no_client_markers(&result);
3082        assert!(result.contains("@license The <my-component> element."));
3083        assert!(!result.contains("</my-component>"));
3084        assert!(result.contains("</style><div>hello</div>"));
3085    }
3086
3087    #[test]
3088    fn test_metadata_keeps_marker_like_text_in_legal_style_comment_literal() {
3089        let result = generate_compiled_template(
3090            "my-component",
3091            r#"<p>{{title}}</p><style>/*! @license <!--t:0--> */.x { color: red; }</style>"#,
3092        );
3093
3094        assert!(result.contains("<!--t:0-->"));
3095        assert!(result.contains(
3096            r#""h":"<p></p><style>/*! @license <!--t:0--> */.x { color: red; }</style>""#
3097        ));
3098        assert!(result.contains(r#","tx":[[[[0],0],[["title"]]]]"#));
3099        assert!(!result.contains(r#"[[[1],0],[["title"]]]"#));
3100    }
3101
3102    #[test]
3103    fn test_metadata_keeps_marker_like_style_text_between_real_signal_markers() {
3104        let result = generate_compiled_template(
3105            "my-component",
3106            r#"<style>/*{{first}}*/📚/*! @license <!--t:0--> <my-component> *//*{{{second}}}*/</style><div>done</div>"#,
3107        );
3108
3109        assert!(result.contains(r#"["first"]"#));
3110        assert!(result.contains(r#"["second"]"#));
3111        assert!(result.contains("📚/*! @license <!--t:0--> <my-component> */"));
3112        assert!(!result.contains("</my-component>"));
3113        assert!(result.contains("</style><div>done</div>"));
3114    }
3115
3116    #[test]
3117    fn test_metadata_strips_style_line_comments_without_processing_bindings() {
3118        let result = generate_compiled_template(
3119            "my-comp",
3120            "<style>// {{ignored}}\n.x { color: {{color}}; }\n//! @license {{kept}}</style>",
3121        );
3122
3123        assert_no_client_markers(&result);
3124        assert!(!result.contains("ignored"));
3125        assert!(result.contains(r#"["color"]"#));
3126        assert!(!result.contains(r#"[["kept"]]"#));
3127        assert!(result.contains("//! @license {{kept}}"));
3128    }
3129
3130    #[test]
3131    fn test_metadata_has_conditionals() {
3132        let payload = generate_compiled_template_payload(
3133            "my-comp",
3134            r#"<if condition="state == 'done'"><span>yes</span></if>"#,
3135        );
3136        let result = payload.template_json;
3137        assert_no_client_markers(&result);
3138        assert!(result.contains(r#""c":[[[0,["state"]],0,[[],0]]]"#));
3139        assert!(payload
3140            .template_functions
3141            .contains(r#"function(v,s){return v("state",s)=="done"}"#));
3142        assert!(result.contains("<span>yes</span>"));
3143        assert!(!result.contains("<if"));
3144    }
3145
3146    #[test]
3147    fn test_metadata_has_conditionals_with_gt_in_condition_attr() {
3148        let result = generate_compiled_template(
3149            "my-comp",
3150            r#"<if condition="count > 0"><span>yes</span></if>"#,
3151        );
3152        assert_no_client_markers(&result);
3153        assert!(
3154            result.contains(r#","c":["#),
3155            "conditional metadata expected"
3156        );
3157        assert!(result.contains("<span>yes</span>"));
3158        assert!(!result.contains("<if"));
3159    }
3160
3161    #[test]
3162    fn test_parse_if_block_ignores_close_marker_inside_attr_value() {
3163        let input = r#"<if condition="count > 0"><div data-note="</if>">yes</div></if>"#;
3164        let (_, body, consumed) = parse_if_block(input).expect("if block should parse");
3165
3166        assert_eq!(body, r#"<div data-note="</if>">yes</div>"#);
3167        assert_eq!(consumed, input.len());
3168    }
3169
3170    #[test]
3171    fn test_metadata_has_for_loops() {
3172        let result = generate_compiled_template(
3173            "my-comp",
3174            r#"<for each="item in items"><p>{{item.name}}</p></for>"#,
3175        );
3176        assert_no_client_markers(&result);
3177        assert!(result.contains("\"items\""));
3178        assert!(result.contains("\"item\""));
3179        assert!(!result.contains("<for"));
3180    }
3181
3182    #[test]
3183    fn test_parse_for_block_ignores_open_marker_inside_attr_value() {
3184        let input =
3185            r#"<for each="item in items"><div data-note="<for fake>">{{item.name}}</div></for>"#;
3186        let (collection, item_var, body, consumed) =
3187            parse_for_block(input).expect("for block should parse");
3188
3189        assert_eq!(collection, "items");
3190        assert_eq!(item_var, "item");
3191        assert_eq!(body, r#"<div data-note="<for fake>">{{item.name}}</div>"#);
3192        assert_eq!(consumed, input.len());
3193    }
3194
3195    #[test]
3196    fn test_metadata_has_for_loops_with_gt_in_other_attr() {
3197        let result = generate_compiled_template(
3198            "my-comp",
3199            r#"<for each="item in items" data-note="a > b"><p>{{item.name}}</p></for>"#,
3200        );
3201        assert_no_client_markers(&result);
3202        assert!(result.contains("\"items\""));
3203        assert!(result.contains("\"item\""));
3204        assert!(!result.contains("<for"));
3205        assert!(!result.contains("{{item.name}}"));
3206    }
3207
3208    #[test]
3209    fn test_metadata_has_events() {
3210        let result =
3211            generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
3212        assert_no_client_markers(&result);
3213        assert!(result.contains("\"click\""));
3214        assert!(result.contains("\"onClick\""));
3215        assert!(!result.contains("@click"));
3216    }
3217
3218    #[test]
3219    fn test_metadata_has_events_unquoted() {
3220        let result =
3221            generate_compiled_template("my-comp", r#"<button @click={onClick()}>Go</button>"#);
3222        assert_no_client_markers(&result);
3223        assert!(result.contains("\"click\""));
3224        assert!(result.contains("\"onClick\""));
3225    }
3226
3227    #[test]
3228    fn test_metadata_has_events_with_arg() {
3229        let result = generate_compiled_template("my-comp", r#"<input @keydown="{onKey(e)}" />"#);
3230        assert_no_client_markers(&result);
3231        assert!(result.contains("\"keydown\""));
3232        assert!(result.contains("\"onKey\""));
3233        assert!(result.contains(r#"["e"]"#)); // event argument
3234    }
3235
3236    #[test]
3237    fn test_metadata_has_event_path_args() {
3238        let result = generate_compiled_template(
3239            "my-comp",
3240            r#"<button @click="{selectItem(item.id, e, 'ok', 7, true, null)}">Go</button>"#,
3241        );
3242        assert_no_client_markers(&result);
3243        assert!(result.contains(r#"["p","item.id"]"#));
3244        assert!(result.contains(r#"["e"]"#));
3245        assert!(result.contains(r#"["s","ok"]"#));
3246        assert!(result.contains(r#"["n",7]"#));
3247        assert!(result.contains(r#"["b",1]"#));
3248        assert!(result.contains(r#"["z"]"#));
3249    }
3250
3251    #[test]
3252    fn test_metadata_has_simple_attr_bindings() {
3253        let result = generate_compiled_template("my-comp", r#"<input title="{{title}}" />"#);
3254        assert_no_client_markers(&result);
3255        assert!(result.contains(r#","a":["#));
3256        assert!(result.contains(r#","ag":[[[0],0,1]]"#));
3257        assert!(result.contains(r#"["title",0,"title"]"#));
3258        assert!(!result.contains(r#"title=\"<!--t:"#));
3259    }
3260
3261    #[test]
3262    fn test_metadata_has_template_attr_bindings() {
3263        let result = generate_compiled_template(
3264            "my-comp",
3265            r#"<a href="/product/{{handle}}" class="card {{variant}}">Go</a>"#,
3266        );
3267        assert_no_client_markers(&result);
3268        assert!(result.contains(r#","a":["#));
3269        assert!(result.contains(r#","ag":[[[0],0,2]]"#));
3270        assert!(result.contains(r#"["href",3,["/product/",["handle"]]]"#));
3271        assert!(result.contains(r#"["class",3,["card ",["variant"]]]"#));
3272        assert!(!result.contains(r#"/product/<!--t:"#));
3273    }
3274
3275    #[test]
3276    fn test_metadata_has_boolean_attr_bindings() {
3277        let payload = generate_compiled_template_payload(
3278            "my-comp",
3279            r#"<a ?data-active="{{page == 'dashboard'}}">Go</a>"#,
3280        );
3281        let result = payload.template_json;
3282        assert_no_client_markers(&result);
3283        assert!(result.contains(r#","a":["#));
3284        assert!(result.contains(r#","ag":[[[0],0,1]]"#));
3285        assert!(result.contains(r#"["data-active",2,[0,["page"]]]"#));
3286        assert!(payload
3287            .template_functions
3288            .contains(r#"function(v,s){return v("page",s)=="dashboard"}"#));
3289    }
3290
3291    #[test]
3292    fn test_metadata_has_complex_attr_bindings() {
3293        let result = generate_compiled_template(
3294            "my-comp",
3295            r#"<child-view :config="{{settings}}"></child-view>"#,
3296        );
3297        assert_no_client_markers(&result);
3298        assert!(result.contains(r#","a":["#));
3299        assert!(result.contains(r#","ag":[[[0],0,1]]"#));
3300        assert!(result.contains(r#""config",1,"settings""#));
3301    }
3302
3303    #[test]
3304    fn test_w_ref_stays_in_html() {
3305        let result = generate_compiled_template("my-comp", r#"<input w-ref="{myInput}" />"#);
3306        // w-ref stays in the static HTML — runtime binds from the DOM directly
3307        assert!(result.contains("w-ref"));
3308        assert!(result.contains("myInput"));
3309    }
3310
3311    #[test]
3312    fn test_w_ref_unquoted_stays_in_html() {
3313        let result = generate_compiled_template("my-comp", r#"<span w-ref={myLabel}>0</span>"#);
3314        assert!(result.contains("w-ref"));
3315        assert!(result.contains("myLabel"));
3316    }
3317
3318    #[test]
3319    fn test_w_ref_without_braces_is_fatal() {
3320        let err =
3321            super::generate_compiled_template("mail-inbox-page", r#"<input w-ref="myInput" />"#)
3322                .expect_err("non-braced w-ref must fail the build");
3323        assert!(err.to_string().contains("invalid w-ref binding"));
3324    }
3325
3326    #[test]
3327    fn test_w_ref_error_names_component_and_element() {
3328        let err =
3329            super::generate_compiled_template("mail-inbox-page", r#"<input w-ref="myInput" />"#)
3330                .expect_err("non-braced w-ref must fail the build");
3331        assert!(err
3332            .to_string()
3333            .contains("component <mail-inbox-page> · element <input>"));
3334    }
3335
3336    #[test]
3337    fn test_metadata_has_root_events() {
3338        let result = generate_compiled_template(
3339            "my-comp",
3340            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>hi</div></template>"#,
3341        );
3342        assert!(result.contains(r#","re":["#));
3343        assert!(result.contains("\"click\""));
3344        assert!(result.contains("\"onClick\""));
3345    }
3346
3347    #[test]
3348    fn test_strips_shadowrootmode() {
3349        let result = generate_compiled_template(
3350            "my-comp",
3351            r#"<template shadowrootmode="open"><p>{{title}}</p></template>"#,
3352        );
3353        assert!(!result.contains("shadowrootmode"));
3354        assert_no_client_markers(&result);
3355        assert!(result.contains(r#","tx":[[[[0],0],[["title"]]]]"#));
3356    }
3357
3358    #[test]
3359    fn test_root_template_allows_gt_in_quoted_attr_values() {
3360        let result = generate_compiled_template(
3361            "my-comp",
3362            r#"<template shadowrootmode="open" data-note="a > b" @click="{onClick()}"><p>hi</p></template>"#,
3363        );
3364        assert_no_client_markers(&result);
3365        assert!(result.contains(r#""h":"<p>hi</p>""#));
3366        assert!(result.contains(r#","re":[["click","onClick",[]]]"#));
3367    }
3368
3369    #[test]
3370    fn test_outlet_converted() {
3371        let result = generate_compiled_template("my-comp", r#"<main><outlet /></main>"#);
3372        assert!(result.contains("<outlet></outlet>"));
3373    }
3374
3375    #[test]
3376    fn test_emoji_safe() {
3377        let result = generate_compiled_template("my-comp", r#"<span>📚 {{title}}</span>"#);
3378        assert!(result.contains("📚"));
3379        assert_no_client_markers(&result);
3380        assert!(result.contains(r#","tx":[[[[0],0],["📚 ",["title"]]]]"#));
3381    }
3382
3383    #[test]
3384    fn test_boolean_attr_in_for() {
3385        let payload = generate_compiled_template_payload(
3386            "my-comp",
3387            r#"<for each="s in sections"><a ?active="{{s.id == sectionId}}">{{s.name}}</a></for>"#,
3388        );
3389        let result = payload.template_json;
3390        assert_no_client_markers(&result);
3391        assert!(result.contains(r#"["active",2,[0,["s.id","sectionId"]]]"#));
3392        assert!(payload
3393            .template_functions
3394            .contains(r#"function(v,s){return v("s.id",s)==v("sectionId",s)}"#));
3395        assert!(!result.contains("?active"));
3396    }
3397
3398    #[test]
3399    fn test_deduplicates_components() {
3400        let mut plugin = WebUIParserPlugin::new();
3401        let comp = Component {
3402            tag_name: "test-el".to_string(),
3403            html_content: "<p>hi</p>".to_string(),
3404            css_content: None,
3405            css_definitions: Vec::new(),
3406            css_fallback_chains: Vec::new(),
3407            has_script: false,
3408        };
3409        plugin
3410            .register_component_template("test-el", &comp, &comp.html_content)
3411            .unwrap();
3412        plugin
3413            .register_component_template("test-el", &comp, &comp.html_content)
3414            .unwrap();
3415        assert_eq!(plugin.take_component_templates().unwrap().len(), 1);
3416    }
3417
3418    #[test]
3419    fn test_scriptless_stateful_component_template_is_static_host_marked() {
3420        let mut plugin = WebUIParserPlugin::new();
3421        let mut comp = Component {
3422            tag_name: "test-el".to_string(),
3423            html_content: "<p>{{name}}</p>".to_string(),
3424            css_content: None,
3425            css_definitions: Vec::new(),
3426            css_fallback_chains: Vec::new(),
3427            has_script: false,
3428        };
3429
3430        plugin
3431            .register_component_template("test-el", &comp, &comp.html_content)
3432            .unwrap();
3433        let templates = plugin.take_component_templates().unwrap();
3434        assert!(templates[0].template_json.contains(r#","th":1"#));
3435
3436        comp.has_script = true;
3437        let mut plugin = WebUIParserPlugin::new();
3438        plugin
3439            .register_component_template("test-el", &comp, &comp.html_content)
3440            .unwrap();
3441        let templates = plugin.take_component_templates().unwrap();
3442        assert!(!templates[0].template_json.contains(r#","th":1"#));
3443    }
3444
3445    #[test]
3446    fn test_scriptless_static_component_template_is_not_static_host_marked() {
3447        let mut plugin = WebUIParserPlugin::new();
3448        let comp = Component {
3449            tag_name: "test-el".to_string(),
3450            html_content: "<p>hi</p>".to_string(),
3451            css_content: None,
3452            css_definitions: Vec::new(),
3453            css_fallback_chains: Vec::new(),
3454            has_script: false,
3455        };
3456
3457        plugin
3458            .register_component_template("test-el", &comp, &comp.html_content)
3459            .unwrap();
3460        let templates = plugin.take_component_templates().unwrap();
3461        assert!(!templates[0].template_json.contains(r#","th":1"#));
3462    }
3463
3464    #[test]
3465    fn test_scriptless_event_component_template_fails() {
3466        let mut plugin = WebUIParserPlugin::new();
3467        let comp = Component {
3468            tag_name: "test-el".to_string(),
3469            html_content: r#"<button @click="{onClick()}">{{name}}</button>"#.to_string(),
3470            css_content: None,
3471            css_definitions: Vec::new(),
3472            css_fallback_chains: Vec::new(),
3473            has_script: false,
3474        };
3475
3476        plugin
3477            .register_component_template("test-el", &comp, &comp.html_content)
3478            .unwrap();
3479        let err = plugin
3480            .take_component_templates()
3481            .expect_err("scriptless event templates should fail");
3482        let message = err.to_string();
3483        assert!(
3484            message.contains("scriptless-event-handler"),
3485            "msg: {message}"
3486        );
3487    }
3488
3489    #[test]
3490    fn test_compiled_template_preserves_link_node_in_static_html() {
3491        let result = generate_compiled_template(
3492            "my-comp",
3493            r#"<template shadowrootmode="open"><link rel="stylesheet" href="my-comp.css"><p>hi</p></template>"#,
3494        );
3495        assert!(result.contains(r#"rel=\"stylesheet\""#));
3496        assert!(result.contains(r#"href=\"my-comp.css\""#));
3497        assert!(result.contains(r#"<p>hi</p>"#));
3498        assert!(!result.contains(",css:"));
3499    }
3500
3501    #[test]
3502    fn test_compiled_template_preserves_inline_style_in_static_html() {
3503        let result = generate_compiled_template(
3504            "my-comp",
3505            r#"<template shadowrootmode="open"><style>.root{color:red}</style><p>hi</p></template>"#,
3506        );
3507        assert!(result.contains(r#""h":"<style>.root{color:red}</style><p>hi</p>""#));
3508    }
3509
3510    #[test]
3511    fn test_compiled_template_emits_adopted_stylesheet_specifier() {
3512        let result = generate_compiled_template(
3513            "my-comp",
3514            r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><p>hi</p></template>"#,
3515        );
3516        assert!(result.contains(r#","sa":"my-comp""#));
3517    }
3518
3519    #[test]
3520    fn test_plugin_uses_processed_link_template_html() {
3521        let mut plugin = WebUIParserPlugin::new();
3522
3523        let comp = Component {
3524            tag_name: "test-el".to_string(),
3525            html_content: "<p>hi</p>".to_string(),
3526            css_content: Some(".root { color: red; }".to_string()),
3527            css_definitions: Vec::new(),
3528            css_fallback_chains: Vec::new(),
3529            has_script: true,
3530        };
3531
3532        plugin
3533            .register_component_template(
3534                "test-el",
3535                &comp,
3536                r#"<template shadowrootmode="open"><link rel="stylesheet" href="test-el.css"><p>hi</p></template>"#,
3537            )
3538            .unwrap();
3539        let templates = plugin.take_component_templates().unwrap();
3540        assert_eq!(templates.len(), 1);
3541        assert!(templates[0].template_json.contains(r#"rel=\"stylesheet\""#));
3542        assert!(templates[0]
3543            .template_json
3544            .contains(r#"href=\"test-el.css\""#));
3545        assert!(templates[0].template_json.contains(r#"<p>hi</p>"#));
3546    }
3547
3548    #[test]
3549    fn test_plugin_preserves_root_events_from_raw_template_source() {
3550        let mut plugin = WebUIParserPlugin::new();
3551
3552        let comp = Component {
3553            tag_name: "test-el".to_string(),
3554            html_content:
3555                r#"<template shadowrootmode="open" @click="{onClick(e)}"><p>hi</p></template>"#
3556                    .to_string(),
3557            css_content: Some(".root { color: red; }".to_string()),
3558            css_definitions: Vec::new(),
3559            css_fallback_chains: Vec::new(),
3560            has_script: true,
3561        };
3562
3563        plugin
3564            .register_component_template(
3565                "test-el",
3566                &comp,
3567                r#"<template shadowrootmode="open"><link rel="stylesheet" href="test-el.css"><p>hi</p></template>"#,
3568            )
3569            .unwrap();
3570        let templates = plugin.take_component_templates().unwrap();
3571        assert_eq!(templates.len(), 1);
3572        assert!(templates[0]
3573            .template_json
3574            .contains(r#","re":[["click","onClick",[["e"]]]]"#));
3575    }
3576
3577    #[test]
3578    fn test_plugin_uses_processed_module_template_html() {
3579        let mut plugin = WebUIParserPlugin::new();
3580
3581        let comp = Component {
3582            tag_name: "test-el".to_string(),
3583            html_content: "<p>hi</p>".to_string(),
3584            css_content: Some(".root { color: red; }".to_string()),
3585            css_definitions: Vec::new(),
3586            css_fallback_chains: Vec::new(),
3587            has_script: false,
3588        };
3589
3590        plugin
3591            .register_component_template(
3592                "test-el",
3593                &comp,
3594                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="test-el"><p>hi</p></template>"#,
3595            )
3596            .unwrap();
3597        let templates = plugin.take_component_templates().unwrap();
3598        assert_eq!(templates.len(), 1);
3599        assert!(templates[0].template_json.contains(r#","sa":"test-el""#));
3600    }
3601
3602    #[test]
3603    fn test_nested_if_inside_for_compiles_nested_blocks() {
3604        let result = generate_compiled_template(
3605            "my-comp",
3606            r#"<for each="item in items"><if condition="item.active"><span>{{item.name}}</span></if></for>"#,
3607        );
3608
3609        assert_no_client_markers(&result);
3610        assert!(
3611            result.contains(r#","b":["#),
3612            "compiled block table expected"
3613        );
3614        assert!(
3615            result.contains(r#"["items","item",0,[[],0]]"#),
3616            "repeat block index expected"
3617        );
3618        assert!(
3619            result.contains(r#"[[0,["item.active"]],1,[[],0]]"#),
3620            "nested conditional block index expected"
3621        );
3622        assert!(!result.contains("<if"), "nested if should be compiled");
3623        assert!(!result.contains("{{"), "nested bindings should be compiled");
3624    }
3625
3626    #[test]
3627    fn test_for_inside_if_compiles_nested_blocks() {
3628        let result = generate_compiled_template(
3629            "my-comp",
3630            r#"<if condition="showList"><for each="item in items"><p>{{item.name}}</p></for></if>"#,
3631        );
3632
3633        assert_no_client_markers(&result);
3634        assert!(
3635            result.contains(r#","b":["#),
3636            "compiled block table expected"
3637        );
3638        assert!(
3639            result.contains(r#"[[0,["showList"]],0,[[],0]]"#),
3640            "conditional block index expected"
3641        );
3642        assert!(
3643            result.contains(r#"["items","item",1,[[],0]]"#),
3644            "nested repeat block index expected"
3645        );
3646        assert!(!result.contains("<for"), "nested for should be compiled");
3647        assert!(!result.contains("{{"), "nested bindings should be compiled");
3648    }
3649
3650    #[test]
3651    fn test_nested_for_inside_for_compiles_nested_blocks() {
3652        let result = generate_compiled_template(
3653            "my-comp",
3654            r#"<for each="group in groups"><div><for each="item in group.items"><button data-group="{{group.name}}" ?disabled="{{item.disabled}}">{{item.label}}</button></for></div></for>"#,
3655        );
3656
3657        assert_no_client_markers(&result);
3658        assert!(
3659            result.contains(r#","b":["#),
3660            "compiled block table expected"
3661        );
3662        assert!(
3663            result.contains(r#"["groups","group",0,[[],0]]"#),
3664            "outer repeat block index expected"
3665        );
3666        assert!(
3667            result.contains(r#"["group.items","item",1,[[0],0]]"#),
3668            "inner repeat block index expected"
3669        );
3670        assert!(!result.contains("<for"), "nested for should be compiled");
3671        assert!(!result.contains("{{"), "nested bindings should be compiled");
3672        assert!(
3673            !result.contains("?disabled"),
3674            "nested boolean attrs should be compiled"
3675        );
3676    }
3677
3678    #[test]
3679    fn test_multiple_events_on_same_element() {
3680        let result = generate_compiled_template(
3681            "my-comp",
3682            r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
3683        );
3684        assert_no_client_markers(&result);
3685        assert!(result.contains("\"keydown\""), "keydown event expected");
3686        assert!(result.contains("\"onKey\""), "onKey handler expected");
3687        assert!(result.contains("\"focus\""), "focus event expected");
3688        assert!(result.contains("\"onFocus\""), "onFocus handler expected");
3689    }
3690
3691    #[test]
3692    fn test_regular_tag_uses_single_event_marker_per_element() {
3693        let mut meta = TemplateSectionMeta::default();
3694        let (tag_html, _) = parse_regular_tag(
3695            "test-input",
3696            r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
3697            &mut meta,
3698        )
3699        .expect("regular tag parse should succeed")
3700        .expect("regular tag should produce output");
3701
3702        assert_eq!(tag_html, r#"<input data-ev="2" />"#);
3703    }
3704
3705    #[test]
3706    fn test_empty_template() {
3707        let result = generate_compiled_template("my-comp", "");
3708        assert!(result.contains(r#""h":"""#), "empty html expected");
3709        // No optional arrays should be present
3710        assert!(!result.contains(r#","tx":"#), "no text bindings");
3711        assert!(!result.contains(r#","c":"#), "no conditionals");
3712        assert!(!result.contains(r#","r":"#), "no repeats");
3713        assert!(!result.contains(r#","eg":"#), "no events");
3714        assert!(!result.contains(r#","re":"#), "no root events");
3715    }
3716
3717    #[test]
3718    fn test_whitespace_only_template() {
3719        let result = generate_compiled_template("my-comp", "   \n  ");
3720        assert!(
3721            result.contains(r#""h":"""#),
3722            "whitespace-only should produce empty html"
3723        );
3724    }
3725
3726    #[test]
3727    fn test_multiple_text_bindings() {
3728        let result = generate_compiled_template(
3729            "my-comp",
3730            r#"<p>{{first}}</p><p>{{second}}</p><p>{{third}}</p>"#,
3731        );
3732        assert_no_client_markers(&result);
3733        assert!(result.contains(
3734            r#","tx":[[[[0],0],[["first"]]],[[[1],0],[["second"]]],[[[2],0],[["third"]]]]"#
3735        ));
3736        assert!(result.contains("\"first\""));
3737        assert!(result.contains("\"second\""));
3738        assert!(result.contains("\"third\""));
3739    }
3740
3741    #[test]
3742    fn test_multiple_conditionals() {
3743        let result = generate_compiled_template(
3744            "my-comp",
3745            r#"<if condition="a"><span>A</span></if><if condition="b"><span>B</span></if>"#,
3746        );
3747        assert_no_client_markers(&result);
3748    }
3749
3750    #[test]
3751    fn test_locator_paths_merge_adjacent_static_text_nodes() {
3752        let result = generate_compiled_template(
3753            "mp-product-grid",
3754            r#"
3755<template shadowrootmode="open">
3756  <if condition="products.length == 0 && query">
3757    <p class="empty-results">There are no products that match "<strong>{{query}}</strong>"</p>
3758  </if>
3759  <div class="grid">
3760    <for each="product in products">
3761      <mp-product-card
3762        handle="{{product.handle}}"
3763        title="{{product.title}}"
3764        price="{{product.price}}"
3765        gradient="{{product.gradient}}"
3766        image-url="{{product.imageUrl}}"
3767        image-loading="lazy"
3768        variant="grid"
3769      ></mp-product-card>
3770    </for>
3771  </div>
3772</template>
3773"#,
3774        );
3775
3776        assert_no_client_markers(&result);
3777    }
3778
3779    #[test]
3780    fn test_mixed_bindings_events_refs() {
3781        let result = generate_compiled_template(
3782            "my-comp",
3783            r#"<button w-ref="{myBtn}" @click="{onClick()}">{{label}}</button>"#,
3784        );
3785        // w-ref stays in HTML (quotes escaped inside JS string)
3786        assert_no_client_markers(&result);
3787        assert!(result.contains("w-ref"));
3788        assert!(result.contains("myBtn"));
3789        // event compiled
3790        assert!(result.contains("\"click\""));
3791        assert!(result.contains("\"onClick\""));
3792        // text binding compiled
3793        assert!(result.contains(r#","tx":[[[[0],0],[["label"]]]]"#));
3794        assert!(result.contains("\"label\""));
3795    }
3796
3797    #[test]
3798    fn test_event_without_e_arg() {
3799        let result =
3800            generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
3801        assert!(
3802            result.contains(r#"["click",[["onClick",[],[0]]]]"#),
3803            "empty argument list should be preserved"
3804        );
3805    }
3806
3807    #[test]
3808    fn test_duplicate_event_groups_preserve_first_seen_order() {
3809        let result = generate_compiled_template(
3810            "my-comp",
3811            r#"<button @click="{onA()}"></button><a @mouseover="{onHover()}"></a><button @click="{onB(e)}"></button>"#,
3812        );
3813
3814        let click_pos = result.find(r#"["click","#).unwrap();
3815        let hover_pos = result.find(r#"["mouseover","#).unwrap();
3816        assert!(
3817            click_pos < hover_pos,
3818            "event groups should preserve first-seen order: {result}"
3819        );
3820        assert!(
3821            result.contains(r#"["click",[["onA",[],[0]],["onB",[["e"]],[2],1]]]"#),
3822            "duplicate click handlers should share one group: {result}"
3823        );
3824    }
3825
3826    #[test]
3827    fn test_root_event_without_e_arg() {
3828        let result = generate_compiled_template(
3829            "my-comp",
3830            r#"<template shadowrootmode="open" @submit="{onSubmit()}"><form>hi</form></template>"#,
3831        );
3832        assert!(result.contains(r#","re":["#));
3833        assert!(result.contains("\"submit\""));
3834        assert!(result.contains("\"onSubmit\""));
3835        assert!(
3836            result.contains(r#"["submit","onSubmit",[]]"#),
3837            "empty root argument list should be preserved"
3838        );
3839    }
3840
3841    #[test]
3842    fn test_triple_brace_raw_binding() {
3843        let result = generate_compiled_template("my-comp", r#"<div>{{{rawHtml}}}</div>"#);
3844        assert_no_client_markers(&result);
3845        assert!(
3846            result.contains(r#","tx":[[[[0],0],[["rawHtml"]],1]]"#),
3847            "triple brace produces text locator with raw flag: {result}"
3848        );
3849        assert!(
3850            result.contains("\"rawHtml\""),
3851            "rawHtml path in text bindings"
3852        );
3853    }
3854
3855    #[test]
3856    fn test_for_with_complex_body() {
3857        let result = generate_compiled_template(
3858            "my-comp",
3859            r#"<for each="user in users"><div class="card"><h2>{{user.name}}</h2><p>{{user.bio}}</p></div></for>"#,
3860        );
3861        assert_no_client_markers(&result);
3862        assert!(result.contains("\"users\""), "collection path");
3863        assert!(result.contains("\"user\""), "item variable");
3864        assert!(
3865            result.contains(r#","b":["#),
3866            "compiled block table expected"
3867        );
3868        assert!(
3869            !result.contains("{{user.name}}"),
3870            "bindings should be compiled"
3871        );
3872        assert!(
3873            !result.contains("{{user.bio}}"),
3874            "bindings should be compiled"
3875        );
3876    }
3877
3878    #[test]
3879    fn test_mixed_text_uses_text_runs() {
3880        let result = generate_compiled_template("my-comp", r#"<p>Hello {{name}}!</p>"#);
3881
3882        assert_no_client_markers(&result);
3883        assert!(result.contains(r#""h":"<p></p>""#));
3884        assert!(result.contains(r#","tx":[[[[0],0],["Hello ",["name"],"!"]]]"#));
3885    }
3886
3887    #[test]
3888    fn test_js_string_escaping() {
3889        let payload = generate_compiled_template_payload(
3890            "my-comp",
3891            r#"<if condition="name == &quot;test&quot;"><span>ok</span></if>"#,
3892        );
3893        assert!(
3894            payload.template_json.contains(r#","c":["#),
3895            "conditional array present"
3896        );
3897        // The right-side literal should remain properly escaped in the AST payload.
3898        assert!(
3899            payload
3900                .template_functions
3901                .contains(r#"function(v,s){return v("name",s)==v("&quot;test&quot;",s)}"#),
3902            "condition function with escaped quotes should be present: {}",
3903            payload.template_functions
3904        );
3905        assert!(
3906            !payload.template_json.contains("function(v,s)"),
3907            "metadata JSON should not contain executable functions"
3908        );
3909    }
3910
3911    #[test]
3912    fn test_js_string_escapes_script_breakout_sequences() {
3913        let result = generate_compiled_template(
3914            "my-comp",
3915            r#"<div title="</script><script>alert(1)</script>"></div>"#,
3916        );
3917
3918        assert!(
3919            result.contains(r#"\u003C/script><script>alert(1)\u003C/script>"#),
3920            "script-closing sequences should be neutralized inside metadata strings: {result}"
3921        );
3922        assert!(
3923            !result.contains(r#"title=\"</script><script>alert(1)</script>\""#),
3924            "raw </script> must not appear inside serialized metadata strings: {result}"
3925        );
3926    }
3927
3928    #[test]
3929    fn test_js_string_escapes_line_separator_codepoints() {
3930        let html = format!("<p>before{}middle{}after</p>", '\u{2028}', '\u{2029}');
3931        let result = generate_compiled_template("my-comp", &html);
3932
3933        assert!(
3934            result.contains(r#"\u2028"#),
3935            "U+2028 should be escaped in metadata strings: {result}"
3936        );
3937        assert!(
3938            result.contains(r#"\u2029"#),
3939            "U+2029 should be escaped in metadata strings: {result}"
3940        );
3941    }
3942
3943    #[test]
3944    fn test_on_element_parsed_encodes_12_bytes() {
3945        let mut plugin = WebUIParserPlugin::new();
3946        // Simulate 2 event attributes skipped
3947        let _ = plugin.classify_attribute("@click");
3948        let _ = plugin.classify_attribute("@keydown");
3949        // Call on_element_parsed with 3 binding attributes
3950        let data = plugin.finish_element(3);
3951        assert!(data.is_some());
3952        let bytes = data.unwrap();
3953        assert_eq!(bytes.len(), 12, "plugin data should be 12 bytes");
3954        let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
3955        let event_start = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
3956        let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
3957        assert_eq!(binding_count, 3);
3958        assert_eq!(event_start, 0);
3959        assert_eq!(event_count, 2);
3960    }
3961
3962    #[test]
3963    fn test_on_element_parsed_no_data_when_empty() {
3964        let mut plugin = WebUIParserPlugin::new();
3965        let data = plugin.finish_element(0);
3966        assert!(
3967            data.is_none(),
3968            "no plugin data when no bindings and no events"
3969        );
3970    }
3971
3972    #[test]
3973    fn test_on_element_parsed_events_only() {
3974        let mut plugin = WebUIParserPlugin::new();
3975        let _ = plugin.classify_attribute("@click");
3976        let data = plugin.finish_element(0);
3977        assert!(
3978            data.is_some(),
3979            "should emit data for events even with 0 bindings"
3980        );
3981        let bytes = data.unwrap();
3982        let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
3983        let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
3984        assert_eq!(binding_count, 0);
3985        assert_eq!(event_count, 1);
3986    }
3987
3988    #[test]
3989    fn test_event_index_increments_across_elements() {
3990        let mut plugin = WebUIParserPlugin::new();
3991        // First element: 2 events
3992        let _ = plugin.classify_attribute("@click");
3993        let _ = plugin.classify_attribute("@focus");
3994        let data1 = plugin.finish_element(0).unwrap();
3995        let ev_start_1 = u32::from_le_bytes([data1[4], data1[5], data1[6], data1[7]]);
3996        assert_eq!(ev_start_1, 0, "first element starts at event 0");
3997
3998        // Second element: 1 event
3999        let _ = plugin.classify_attribute("@blur");
4000        let data2 = plugin.finish_element(0).unwrap();
4001        let ev_start_2 = u32::from_le_bytes([data2[4], data2[5], data2[6], data2[7]]);
4002        assert_eq!(
4003            ev_start_2, 2,
4004            "second element starts at event 2 (after first element's 2 events)"
4005        );
4006    }
4007
4008    #[test]
4009    fn test_text_run_decodes_html_entities_in_static_parts() {
4010        // Template: `<nav>{{sectionName}} &gt; {{topicName}}</nav>`
4011        // The `&gt;` is a static part between two dynamic bindings.
4012        // On the client, $resolveAttrParts concatenates parts and sets
4013        // textContent, which does NOT decode entities — so the compiler
4014        // must decode them in the metadata.
4015        let result =
4016            generate_compiled_template("my-comp", "<nav>{{sectionName}} &gt; {{topicName}}</nav>");
4017        assert_no_client_markers(&result);
4018        // The static part " > " should be decoded, not " &gt; "
4019        assert!(
4020            result.contains(r#"" > ""#),
4021            "static text run part should contain decoded '>' not '&gt;': {}",
4022            result
4023        );
4024        assert!(
4025            !result.contains(r#"" &gt; ""#),
4026            "static text run part should not contain raw '&gt;': {}",
4027            result
4028        );
4029    }
4030
4031    #[test]
4032    fn test_invalid_event_handler_error_names_component_and_element() {
4033        let err = super::generate_compiled_template(
4034            "mail-inbox-page",
4035            r#"<button @pointerdown="e.preventDefault()">x</button>"#,
4036        )
4037        .expect_err("invalid @event handler must fail the build");
4038        assert!(err
4039            .to_string()
4040            .contains("component <mail-inbox-page> · element <button>"));
4041    }
4042
4043    #[test]
4044    fn test_invalid_root_event_handler_error_names_component() {
4045        let err = super::generate_compiled_template(
4046            "my-card",
4047            r#"<template shadowrootmode="open" @click="e.stopPropagation()"><slot></slot></template>"#,
4048        )
4049        .expect_err("invalid root @event handler must fail the build");
4050        assert!(err.to_string().contains("in component <my-card>"));
4051    }
4052
4053    #[test]
4054    fn test_parse_event_handler_bare_name_is_invalid() {
4055        assert!(matches!(
4056            parse_event_handler("{closeMenu}"),
4057            EventHandler::Invalid(ref name) if name == "closeMenu"
4058        ));
4059    }
4060
4061    #[test]
4062    fn test_parse_event_attr_bare_name_errors() {
4063        let input = r#"<button @click="{closeMenu}">Click</button>"#;
4064        let err = parse_event_attr("test-button", input, 8)
4065            .expect_err("bare handler name must be rejected");
4066        assert!(err.to_string().contains("invalid @click handler"));
4067    }
4068
4069    #[test]
4070    fn test_parse_event_handler_with_parens() {
4071        assert!(matches!(
4072            parse_event_handler("{onClick()}"),
4073            EventHandler::Valid(ref name, ref args) if name == "onClick" && args.is_empty()
4074        ));
4075    }
4076
4077    #[test]
4078    fn test_parse_event_handler_with_event_arg() {
4079        assert!(matches!(
4080            parse_event_handler("{onClick(e)}"),
4081            EventHandler::Valid(ref name, ref args) if name == "onClick" && args == &vec![EventArg::Event]
4082        ));
4083    }
4084
4085    #[test]
4086    fn test_parse_event_handler_with_mixed_args() {
4087        assert!(matches!(
4088            parse_event_handler("{onClick(item.id, e, 'ok', 7, false, null)}"),
4089            EventHandler::Valid(ref name, ref args)
4090                if name == "onClick"
4091                    && args == &vec![
4092                        EventArg::Path("item.id".to_string()),
4093                        EventArg::Event,
4094                        EventArg::String("ok".to_string()),
4095                        EventArg::Number("7".to_string()),
4096                        EventArg::Bool(false),
4097                        EventArg::Null,
4098                    ]
4099        ));
4100    }
4101
4102    #[test]
4103    fn test_parse_event_handler_rejects_trailing_tokens() {
4104        assert!(matches!(
4105            parse_event_handler("{onClick(e)} trailing"),
4106            EventHandler::Invalid(ref raw) if raw == "{onClick(e)} trailing"
4107        ));
4108        assert!(matches!(
4109            parse_event_handler("{onClick(e) trailing}"),
4110            EventHandler::Invalid(ref raw) if raw == "onClick(e) trailing"
4111        ));
4112    }
4113
4114    #[test]
4115    fn test_parse_event_handler_rejects_empty_args() {
4116        assert!(matches!(
4117            parse_event_handler("{onClick(, e)}"),
4118            EventHandler::Invalid(ref raw) if raw == "onClick(, e)"
4119        ));
4120        assert!(matches!(
4121            parse_event_handler("{onClick(e,)}"),
4122            EventHandler::Invalid(ref raw) if raw == "onClick(e,)"
4123        ));
4124    }
4125
4126    #[test]
4127    fn test_parse_event_handler_rejects_malformed_args() {
4128        assert!(matches!(
4129            parse_event_handler("{onClick('unterminated)}"),
4130            EventHandler::Invalid(ref raw) if raw == "onClick('unterminated)"
4131        ));
4132        assert!(matches!(
4133            parse_event_handler("{onClick(other())}"),
4134            EventHandler::Invalid(ref raw) if raw == "onClick(other())"
4135        ));
4136        assert!(matches!(
4137            parse_event_handler("{onClick(count + 1)}"),
4138            EventHandler::Invalid(ref raw) if raw == "onClick(count + 1)"
4139        ));
4140    }
4141
4142    #[test]
4143    fn test_parse_event_handler_rejects_invalid_handler_name() {
4144        assert!(matches!(
4145            parse_event_handler("{handler.name()}"),
4146            EventHandler::Invalid(ref raw) if raw == "handler.name()"
4147        ));
4148        assert!(matches!(
4149            parse_event_handler("{1handler()}"),
4150            EventHandler::Invalid(ref raw) if raw == "1handler()"
4151        ));
4152    }
4153
4154    #[test]
4155    fn test_parse_event_handler_empty_value() {
4156        assert!(matches!(parse_event_handler("{}"), EventHandler::Empty));
4157    }
4158
4159    #[test]
4160    fn test_attribute_to_camel_aria() {
4161        use webui_protocol::attrs::attribute_to_camel;
4162        assert_eq!(attribute_to_camel("aria-describedby"), "ariaDescribedBy");
4163        assert_eq!(attribute_to_camel("aria-labelledby"), "ariaLabelledBy");
4164        assert_eq!(
4165            attribute_to_camel("aria-activedescendant"),
4166            "ariaActiveDescendant"
4167        );
4168        assert_eq!(attribute_to_camel("aria-label"), "ariaLabel");
4169        assert_eq!(attribute_to_camel("aria-hidden"), "ariaHidden");
4170    }
4171
4172    #[test]
4173    fn test_attribute_to_camel_html_global() {
4174        use webui_protocol::attrs::attribute_to_camel;
4175        assert_eq!(attribute_to_camel("readonly"), "readOnly");
4176        assert_eq!(attribute_to_camel("tabindex"), "tabIndex");
4177        assert_eq!(attribute_to_camel("accesskey"), "accessKey");
4178        assert_eq!(attribute_to_camel("contenteditable"), "contentEditable");
4179        assert_eq!(attribute_to_camel("inputmode"), "inputMode");
4180        assert_eq!(attribute_to_camel("maxlength"), "maxLength");
4181        assert_eq!(attribute_to_camel("formaction"), "formAction");
4182    }
4183}