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 raw
9//! JS IIFE strings in the protocol. During SSR the handler wraps them in
10//! a single `<script>` tag; during SPA navigation the router evaluates
11//! them directly. Each metadata object contains
12//! **marker-free static HTML** plus locator arrays for client-created DOM
13//! (`tx`, `ag`, `cl`, `rl`, `el`) and semantic arrays (`a`, `c`, `r`, `e`,
14//! `re`, `b`). The client runtime resolves those locators once and then
15//! patches direct node references — **no template string parsing, no regex,
16//! no DOM scanning** on the client-created path.
17//!
18//! # Metadata object format
19//!
20//! ```js
21//! window.__webui_templates['my-component'] = {
22//!   h: "<button class=\"item\"><span></span></button>",
23//!   tx: [[[[0, 0], 0], [["title"]]]],
24//!   a: [["title", 0, "title"]],
25//!   ag: [[[0], 0, 1]],
26//!   c: [[[1, "state", 3, "'done'"], 0]],
27//!   cl: [[[0], 1]],
28//!   e: [["click", "onClick", 0]],
29//!   el: [[0]],
30//!   b: [{ h: "<span class=\"check\">✓</span>" }],
31//!   sa: "my-component",
32//!   re: [["submit", "onSubmit", 1]],
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 final template
59//!    HTML for later compilation (deduplicates by tag name).
60//! 4. **`take_component_templates`** — called after parsing is complete;
61//!    compiles each tracked component into a raw JS IIFE string.
62
63use super::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
64use crate::component_registry::Component;
65use crate::{ConditionParser, CssStrategy, DomStrategy, Result};
66use std::cell::Cell;
67use std::fmt::Write;
68use webui_protocol::{condition_expr, ConditionExpr, WebUIElementData};
69
70/// A component whose final template HTML has been captured for compilation.
71/// Repeated tracking for the same tag updates the stored template so the plugin
72/// can prefer processed HTML over raw component source.
73struct TrackedComponent {
74    tag_name: String,
75    template_html: String,
76    root_event_source: String,
77}
78
79/// WebUI Framework parser plugin.
80///
81/// Intercepts `@event` attributes and component registrations during parsing,
82/// then compiles each component's HTML template into a metadata JS IIFE string.
83///
84/// # Event tracking
85///
86/// Event indices are assigned **globally** across all elements in a component.
87/// `element_events` counts events on the current element (reset per element),
88/// while `next_event_idx` is a monotonically increasing global counter.
89/// This preserves event order in the emitted metadata while letting SSR HTML
90/// mark one element with a single `data-ev="COUNT"` attribute.
91pub struct WebUIParserPlugin {
92    components: Vec<TrackedComponent>,
93    /// Per-element event count, reset on each `finish_element` call.
94    element_events: Cell<u32>,
95    /// Global event index, incremented across all elements in a component.
96    next_event_idx: Cell<u32>,
97    /// DOM strategy — shadow or light.
98    dom_strategy: DomStrategy,
99}
100
101impl WebUIParserPlugin {
102    #[must_use]
103    pub fn new() -> Self {
104        Self {
105            components: Vec::new(),
106            element_events: Cell::new(0),
107            next_event_idx: Cell::new(0),
108            dom_strategy: DomStrategy::default(),
109        }
110    }
111
112    pub fn set_css_strategy(&mut self, strategy: CssStrategy) {
113        let _ = strategy;
114    }
115
116    pub fn set_dom_strategy(&mut self, strategy: DomStrategy) {
117        self.dom_strategy = strategy;
118    }
119
120    /// Compile all tracked components and return `(tag_name, script_block)` pairs.
121    ///
122    /// Each script block is a self-executing IIFE that registers a metadata
123    /// object into `window.__webui_templates[tagName]`. Call this after all
124    /// HTML parsing is complete.
125    #[must_use]
126    pub fn take_component_templates(&self) -> Vec<(String, String)> {
127        let use_shadow = matches!(self.dom_strategy, DomStrategy::Shadow);
128        self.components
129            .iter()
130            .map(|c| {
131                let script = generate_compiled_template_with_root_source(
132                    &c.tag_name,
133                    &c.template_html,
134                    &c.root_event_source,
135                    use_shadow,
136                );
137                (c.tag_name.clone(), script)
138            })
139            .collect()
140    }
141
142    fn store_component_template(
143        &mut self,
144        tag_name: &str,
145        template_html: &str,
146        root_event_source: &str,
147    ) {
148        if let Some(component) = self.components.iter_mut().find(|c| c.tag_name == tag_name) {
149            component.template_html.clear();
150            component.template_html.push_str(template_html);
151            component.root_event_source.clear();
152            component.root_event_source.push_str(root_event_source);
153            return;
154        }
155
156        self.components.push(TrackedComponent {
157            tag_name: tag_name.to_string(),
158            template_html: template_html.to_string(),
159            root_event_source: root_event_source.to_string(),
160        });
161    }
162}
163
164impl Default for WebUIParserPlugin {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl ParserPlugin for WebUIParserPlugin {
171    fn start_fragment(&mut self, _fragment_id: &str) {
172        self.element_events.set(0);
173        self.next_event_idx.set(0);
174    }
175
176    fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
177        if attr_name.starts_with('@') {
178            self.element_events.set(self.element_events.get() + 1);
179            return AttributeAction::Skip;
180        }
181        AttributeAction::Keep
182    }
183
184    fn register_component_template(
185        &mut self,
186        tag_name: &str,
187        component: &Component,
188        processed_template: &str,
189    ) -> Result<()> {
190        self.store_component_template(tag_name, processed_template, &component.html_content);
191        Ok(())
192    }
193
194    fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
195        let ev_count = self.element_events.get();
196        let ev_start = self.next_event_idx.get();
197
198        // Advance the global event index
199        self.next_event_idx.set(ev_start + ev_count);
200        // Reset per-element event counter
201        self.element_events.set(0);
202
203        if binding_attribute_count == 0 && ev_count == 0 {
204            return None;
205        }
206
207        Some(
208            WebUIElementData {
209                binding_count: binding_attribute_count,
210                event_start: ev_start,
211                event_count: ev_count,
212            }
213            .encode()
214            .to_vec(),
215        )
216    }
217
218    fn into_artifacts(self: Box<Self>) -> ParserPluginArtifacts {
219        ParserPluginArtifacts::ComponentTemplates(self.take_component_templates())
220    }
221}
222
223// ── Compiled template generation ───────────────────────────────────
224
225/// A compiled template section.
226///
227/// Used for the root template and for nested block-table entries.
228/// `conditionals` and `repeats` point at block-table indices instead of
229/// inlining raw body HTML so the client never reparses nested template syntax.
230/// The final `html` payload is marker-free; client-created DOM uses the
231/// locator tables to connect bindings without scanning comments or attrs.
232#[derive(Default)]
233struct TemplateSectionMeta {
234    /// Marker-free static HTML for client-created DOM.
235    html: String,
236    /// Intermediate text binding paths collected during compilation.
237    /// These are resolved into `text_runs` during finalization.
238    /// Each entry is `(path, raw)` where raw indicates triple-brace `{{{...}}}`.
239    text_bindings: Vec<(String, bool)>,
240    /// Client text-run metadata: `(slot, parts, raw)`.
241    /// `raw` is true when the binding uses triple-brace `{{{...}}}` syntax.
242    text_runs: Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
243    /// Attribute bindings in source order, shared by SSR markers and client `ag[]` locators.
244    attr_bindings: Vec<CompiledAttrBinding>,
245    /// Client attribute groups: `(element_path, start, count)`.
246    attr_groups: Vec<(Vec<usize>, usize, usize)>,
247    /// Conditional blocks: `(condition_expression_ast, block_index)`.
248    conditionals: Vec<(ConditionExpr, usize)>,
249    /// Client conditional anchor slots aligned to `conditionals`.
250    condition_slots: Vec<SlotLocator>,
251    /// For-loop blocks: `(collection_path, item_variable, block_index)`.
252    repeats: Vec<(String, String, usize)>,
253    /// Client repeat anchor slots aligned to `repeats`.
254    repeat_slots: Vec<SlotLocator>,
255    /// Body-level events: `(event_name, handler_method, needs_event_arg)`.
256    events: Vec<(String, String, bool)>,
257    /// Client event target element paths aligned to `events`.
258    event_targets: Vec<Vec<usize>>,
259}
260
261#[derive(Clone)]
262struct SlotLocator {
263    parent_path: Vec<usize>,
264    before_index: usize,
265    order: usize,
266}
267
268/// Collected metadata produced by [`compile_to_metadata`].
269///
270/// The root template emits directly into the top-level metadata object.
271/// Nested blocks live in `blocks` and are referenced by index from `c` / `r`.
272struct TemplateMeta {
273    root: TemplateSectionMeta,
274    blocks: Vec<TemplateSectionMeta>,
275    /// Root-level events from the `<template>` wrapper tag.
276    /// Attached to the host element (shadow root host) by the client.
277    root_events: Vec<(String, String, bool)>,
278}
279
280enum CompiledAttrBinding {
281    Simple {
282        name: String,
283        value: String,
284    },
285    Complex {
286        name: String,
287        value: String,
288    },
289    Boolean {
290        name: String,
291        condition: ConditionExpr,
292    },
293    Template {
294        name: String,
295        parts: Vec<CompiledAttrPart>,
296    },
297}
298
299enum CompiledAttrPart {
300    Static(String),
301    Dynamic(String),
302}
303
304/// Generate a compiled template as a raw JS IIFE string.
305///
306/// The output is a self-executing function that registers the component's
307/// metadata on `window.__webui_templates[tagName]`.  During SSR, the
308/// handler wraps one or more of these in a single `<script>` tag.  During
309/// SPA partial navigation, the router evaluates them directly.
310///
311/// # Flow
312///
313/// 1. Extract `@event` bindings from the `<template>` wrapper (→ `root_events`).
314/// 2. Strip the `<template shadowrootmode="…">` wrapper if present.
315/// 3. Compile the inner body via [`compile_to_metadata`].
316/// 4. Serialize into a self-executing IIFE that registers the metadata
317///    on `window.__webui_templates[tagName]`.
318///
319/// # Compilation rules
320///
321/// | Source syntax                          | Metadata field(s)          | Client `h` result                 |
322/// |---------------------------------------|----------------------------|-----------------------------------|
323/// | `{{expr}}`, `{{{expr}}}`, mixed text  | `tx[]`                     | dynamic text run removed          |
324/// | `href="{{url}}"`, `class="x {{y}}"`   | `a[]` + `ag[]`             | element kept marker-free          |
325/// | `?disabled="{{expr}}"`                | `a[]` + `ag[]`             | element kept marker-free          |
326/// | `:config="{{settings}}"`              | `a[]` + `ag[]`             | element kept marker-free          |
327/// | `<if condition="…">body</if>`         | `c[]` + `cl[]` + `b[]`     | block removed; anchor slot stored |
328/// | `<for each="v in coll">body</for>`    | `r[]` + `rl[]` + `b[]`     | block removed; anchor slot stored |
329/// | `<link>` / `<style>` child nodes      | `h`                        | preserved in static HTML          |
330/// | module adopted stylesheet specifier   | `sa`                       | stored from `<template>` wrapper  |
331/// | `@event="{handler(e)}"`               | `e[]` + `el[]`             | element kept marker-free          |
332/// | `w-ref="name"` / `w-ref={name}`       | *(stays in HTML)*          | *(unchanged)*                     |
333/// | `<outlet />` / `<outlet>`             | *(stays in HTML)*          | `<outlet></outlet>`               |
334pub fn generate_compiled_template(tag_name: &str, html_content: &str) -> String {
335    generate_compiled_template_with_root_source(tag_name, html_content, html_content, false)
336}
337
338fn generate_compiled_template_with_root_source(
339    tag_name: &str,
340    html_content: &str,
341    root_event_source: &str,
342    shadow_dom: bool,
343) -> String {
344    let trimmed = html_content.trim();
345    let root_events = extract_root_events(root_event_source.trim());
346    let adopted_stylesheet = extract_adopted_stylesheet_specifier(trimmed);
347    let body = strip_template_wrapper(trimmed);
348    let meta = compile_to_metadata(body, root_events);
349
350    let mut out = String::with_capacity(512 + html_content.len());
351    out.push_str("(function(){var w=window.__webui_templates||(window.__webui_templates={});w['");
352    out.push_str(tag_name);
353    out.push_str("']={");
354
355    emit_js_template_section(&meta.root, &mut out);
356
357    if let Some(adopted_stylesheet) = adopted_stylesheet.as_deref() {
358        out.push_str(",sa:");
359        emit_js_string(adopted_stylesheet, &mut out);
360    }
361
362    // sd: shadow DOM flag — tells the client runtime to use shadow root
363    if shadow_dom {
364        out.push_str(",sd:1");
365    }
366
367    // re: root events
368    if !meta.root_events.is_empty() {
369        out.push_str(",re:[");
370        for (i, (event, handler, needs_event)) in meta.root_events.iter().enumerate() {
371            if i > 0 {
372                out.push(',');
373            }
374            out.push('[');
375            emit_js_string(event, &mut out);
376            out.push(',');
377            emit_js_string(handler, &mut out);
378            out.push(',');
379            out.push(if *needs_event { '1' } else { '0' });
380            out.push(']');
381        }
382        out.push(']');
383    }
384
385    // b: nested block table
386    if !meta.blocks.is_empty() {
387        out.push_str(",b:[");
388        for (i, block) in meta.blocks.iter().enumerate() {
389            if i > 0 {
390                out.push(',');
391            }
392            out.push('{');
393            emit_js_template_section(block, &mut out);
394            out.push('}');
395        }
396        out.push(']');
397    }
398
399    out.push_str("};})();\n");
400    out
401}
402
403fn emit_js_string(s: &str, out: &mut String) {
404    out.push('"');
405    let mut index = 0usize;
406    while index < s.len() {
407        let remaining = &s[index..];
408        if remaining.as_bytes().starts_with(b"</")
409            && remaining
410                .as_bytes()
411                .get(2..8)
412                .map(|bytes| bytes.eq_ignore_ascii_case(b"script"))
413                .unwrap_or(false)
414        {
415            out.push_str("\\u003C");
416            index += 1;
417            continue;
418        }
419        let Some(ch) = remaining.chars().next() else {
420            break;
421        };
422        match ch {
423            '\0' => out.push_str("\\0"),
424            '"' => out.push_str("\\\""),
425            '\\' => out.push_str("\\\\"),
426            '\n' => out.push_str("\\n"),
427            '\r' => out.push_str("\\r"),
428            '\u{2028}' => out.push_str("\\u2028"),
429            '\u{2029}' => out.push_str("\\u2029"),
430            _ => out.push(ch),
431        }
432        index += ch.len_utf8();
433    }
434    out.push('"');
435}
436
437fn emit_js_attr_binding(binding: &CompiledAttrBinding, out: &mut String) {
438    out.push('[');
439    match binding {
440        CompiledAttrBinding::Simple { name, value } => {
441            emit_js_string(name, out);
442            out.push_str(",0,");
443            emit_js_string(value, out);
444        }
445        CompiledAttrBinding::Complex { name, value } => {
446            emit_js_string(name, out);
447            out.push_str(",1,");
448            emit_js_string(value, out);
449        }
450        CompiledAttrBinding::Boolean { name, condition } => {
451            emit_js_string(name, out);
452            out.push_str(",2,");
453            emit_js_condition(condition, out);
454        }
455        CompiledAttrBinding::Template { name, parts } => {
456            emit_js_string(name, out);
457            out.push_str(",3,[");
458            for (i, part) in parts.iter().enumerate() {
459                if i > 0 {
460                    out.push(',');
461                }
462                match part {
463                    CompiledAttrPart::Static(value) => emit_js_string(value, out),
464                    CompiledAttrPart::Dynamic(path) => {
465                        out.push('[');
466                        emit_js_string(path, out);
467                        out.push(']');
468                    }
469                }
470            }
471            out.push(']');
472        }
473    }
474    out.push(']');
475}
476
477/// Emit a compiled condition as `[function(v,s){return EXPR},["path1",...]]`.
478/// The function evaluates the condition using `v(path,s)` for value resolution.
479/// The paths array lists all referenced identifiers for the reactive path index.
480fn emit_js_condition(condition: &ConditionExpr, out: &mut String) {
481    out.push_str("[function(v,s){return ");
482    emit_js_condition_expr(condition, out);
483    out.push_str("},[");
484    let mut paths = Vec::new();
485    collect_condition_paths(condition, &mut paths);
486    for (i, path) in paths.iter().enumerate() {
487        if i > 0 {
488            out.push(',');
489        }
490        emit_js_string(path, out);
491    }
492    out.push_str("]]");
493}
494
495/// Emit the JS expression body for a condition (no wrapping).
496fn emit_js_condition_expr(condition: &ConditionExpr, out: &mut String) {
497    match &condition.expr {
498        Some(condition_expr::Expr::Identifier(id)) => {
499            // Truthiness check: !!v("path",s)
500            out.push_str("!!v(");
501            emit_js_string(&id.value, out);
502            out.push_str(",s)");
503        }
504        Some(condition_expr::Expr::Predicate(pred)) => {
505            // Comparison: resolve left, compare with right
506            out.push_str("v(");
507            emit_js_string(&pred.left, out);
508            out.push_str(",s)");
509            match pred.operator {
510                1 => out.push('>'),      // GT
511                2 => out.push('<'),      // LT
512                3 => out.push_str("=="), // EQ (use == for loose comparison like Object.is)
513                4 => out.push_str("!="), // NEQ
514                5 => out.push_str(">="), // GTE
515                6 => out.push_str("<="), // LTE
516                _ => out.push_str("=="),
517            }
518            // Right side: resolve as value or emit literal
519            emit_js_predicate_right(&pred.right, out);
520        }
521        Some(condition_expr::Expr::Not(not_cond)) => {
522            out.push('!');
523            if let Some(inner) = not_cond.condition.as_ref() {
524                out.push('(');
525                emit_js_condition_expr(inner, out);
526                out.push(')');
527            } else {
528                out.push_str("false");
529            }
530        }
531        Some(condition_expr::Expr::Compound(compound)) => {
532            out.push('(');
533            if let Some(left) = compound.left.as_ref() {
534                emit_js_condition_expr(left, out);
535            } else {
536                out.push_str("false");
537            }
538            match compound.op {
539                1 => out.push_str("&&"), // AND
540                2 => out.push_str("||"), // OR
541                _ => out.push_str("&&"),
542            }
543            if let Some(right) = compound.right.as_ref() {
544                emit_js_condition_expr(right, out);
545            } else {
546                out.push_str("false");
547            }
548            out.push(')');
549        }
550        None => out.push_str("false"),
551    }
552}
553
554/// Emit the right-hand side of a predicate comparison.
555/// Literals are emitted directly; identifiers are resolved via v().
556fn emit_js_predicate_right(value: &str, out: &mut String) {
557    // Check for string literals: "..." or '...'
558    if (value.starts_with('"') && value.ends_with('"'))
559        || (value.starts_with('\'') && value.ends_with('\''))
560    {
561        // Emit as JS string (strip outer quotes, re-emit as double-quoted)
562        emit_js_string(&value[1..value.len() - 1], out);
563        return;
564    }
565    // Boolean literals
566    if value == "true" || value == "false" {
567        out.push_str(value);
568        return;
569    }
570    // Numeric literals
571    if value
572        .bytes()
573        .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-')
574        && !value.is_empty()
575    {
576        out.push_str(value);
577        return;
578    }
579    // Otherwise it's an identifier — resolve it
580    out.push_str("v(");
581    emit_js_string(value, out);
582    out.push_str(",s)");
583}
584
585/// Collect all identifier paths referenced by a condition.
586fn collect_condition_paths(condition: &ConditionExpr, paths: &mut Vec<String>) {
587    match &condition.expr {
588        Some(condition_expr::Expr::Identifier(id)) => {
589            paths.push(id.value.clone());
590        }
591        Some(condition_expr::Expr::Predicate(pred)) => {
592            paths.push(pred.left.clone());
593            // Right side might also be an identifier (not a literal)
594            let r = &pred.right;
595            if !((r.starts_with('"') && r.ends_with('"'))
596                || (r.starts_with('\'') && r.ends_with('\''))
597                || r == "true"
598                || r == "false"
599                || r.bytes()
600                    .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-'))
601            {
602                paths.push(r.clone());
603            }
604        }
605        Some(condition_expr::Expr::Not(not_cond)) => {
606            if let Some(inner) = not_cond.condition.as_ref() {
607                collect_condition_paths(inner, paths);
608            }
609        }
610        Some(condition_expr::Expr::Compound(compound)) => {
611            if let Some(left) = compound.left.as_ref() {
612                collect_condition_paths(left, paths);
613            }
614            if let Some(right) = compound.right.as_ref() {
615                collect_condition_paths(right, paths);
616            }
617        }
618        None => {}
619    }
620}
621
622fn emit_js_node_path(path: &[usize], out: &mut String) {
623    out.push('[');
624    for (i, index) in path.iter().enumerate() {
625        if i > 0 {
626            out.push(',');
627        }
628        let _ = write!(out, "{}", index);
629    }
630    out.push(']');
631}
632
633fn emit_js_slot(slot: &SlotLocator, out: &mut String) {
634    out.push('[');
635    emit_js_node_path(&slot.parent_path, out);
636    out.push(',');
637    let _ = write!(out, "{}", slot.before_index);
638    if slot.order > 0 {
639        out.push(',');
640        let _ = write!(out, "{}", slot.order);
641    }
642    out.push(']');
643}
644
645fn emit_js_text_parts(parts: &[CompiledAttrPart], out: &mut String) {
646    out.push('[');
647    for (i, part) in parts.iter().enumerate() {
648        if i > 0 {
649            out.push(',');
650        }
651        match part {
652            CompiledAttrPart::Static(value) => emit_js_string(value, out),
653            CompiledAttrPart::Dynamic(path) => {
654                out.push('[');
655                emit_js_string(path, out);
656                out.push(']');
657            }
658        }
659    }
660    out.push(']');
661}
662
663fn emit_js_template_section(meta: &TemplateSectionMeta, out: &mut String) {
664    out.push_str("h:");
665    emit_js_string(&meta.html, out);
666
667    if !meta.text_runs.is_empty() {
668        out.push_str(",tx:[");
669        for (i, (slot, parts, raw)) in meta.text_runs.iter().enumerate() {
670            if i > 0 {
671                out.push(',');
672            }
673            out.push('[');
674            emit_js_slot(slot, out);
675            out.push(',');
676            emit_js_text_parts(parts, out);
677            if *raw {
678                out.push_str(",1");
679            }
680            out.push(']');
681        }
682        out.push(']');
683    }
684
685    if !meta.attr_bindings.is_empty() {
686        out.push_str(",a:[");
687        for (i, binding) in meta.attr_bindings.iter().enumerate() {
688            if i > 0 {
689                out.push(',');
690            }
691            emit_js_attr_binding(binding, out);
692        }
693        out.push(']');
694    }
695
696    if !meta.attr_groups.is_empty() {
697        out.push_str(",ag:[");
698        for (i, (path, start, count)) in meta.attr_groups.iter().enumerate() {
699            if i > 0 {
700                out.push(',');
701            }
702            out.push('[');
703            emit_js_node_path(path, out);
704            out.push(',');
705            let _ = write!(out, "{}", start);
706            out.push(',');
707            let _ = write!(out, "{}", count);
708            out.push(']');
709        }
710        out.push(']');
711    }
712
713    if !meta.conditionals.is_empty() {
714        out.push_str(",c:[");
715        for (i, ((cond, block_index), slot)) in meta
716            .conditionals
717            .iter()
718            .zip(meta.condition_slots.iter())
719            .enumerate()
720        {
721            if i > 0 {
722                out.push(',');
723            }
724            out.push('[');
725            emit_js_condition(cond, out);
726            out.push(',');
727            let _ = write!(out, "{}", block_index);
728            out.push(',');
729            emit_js_slot(slot, out);
730            out.push(']');
731        }
732        out.push(']');
733    }
734
735    if !meta.repeats.is_empty() {
736        out.push_str(",r:[");
737        for (i, ((collection, item_var, block_index), slot)) in meta
738            .repeats
739            .iter()
740            .zip(meta.repeat_slots.iter())
741            .enumerate()
742        {
743            if i > 0 {
744                out.push(',');
745            }
746            out.push('[');
747            emit_js_string(collection, out);
748            out.push(',');
749            emit_js_string(item_var, out);
750            out.push(',');
751            let _ = write!(out, "{}", block_index);
752            out.push(',');
753            emit_js_slot(slot, out);
754            out.push(']');
755        }
756        out.push(']');
757    }
758
759    if !meta.events.is_empty() {
760        out.push_str(",e:[");
761        for (i, ((event, handler, needs_event), target)) in meta
762            .events
763            .iter()
764            .zip(meta.event_targets.iter())
765            .enumerate()
766        {
767            if i > 0 {
768                out.push(',');
769            }
770            out.push('[');
771            emit_js_string(event, out);
772            out.push(',');
773            emit_js_string(handler, out);
774            out.push(',');
775            out.push(if *needs_event { '1' } else { '0' });
776            out.push(',');
777            emit_js_node_path(target, out);
778            out.push(']');
779        }
780        out.push(']');
781    }
782}
783
784/// Compile a template body into a [`TemplateMeta`] struct.
785///
786/// Performs a single forward pass over the input bytes to build an intermediate
787/// section, then finalizes that section into marker-free client HTML plus
788/// locator metadata. During the forward pass:
789///
790/// - **Multi-byte char boundary check** — ensures we don't split UTF-8 (e.g. emoji).
791/// - **`{{{expr}}}`** — triple-brace raw binding → text run metadata.
792/// - **`{{expr}}`** — double-brace escaped binding → text run metadata.
793/// - **regular start tags** — attrs are compiled into explicit `a[]` metadata plus
794///   SSR binding markers that are later stripped from client `h`.
795/// - **`<if condition="…">`** — parsed via [`parse_if_block`] → conditional slot.
796/// - **`<for each="v in coll">`** — parsed via [`parse_for_block`] → repeat slot.
797/// - **`<outlet …>`** — normalized to `<outlet></outlet>`.
798/// - **`@event="…"`** (inside a tag) — parsed into `e[]` entries plus
799///   SSR `data-ev="COUNT"` markers that are later replaced by client locators.
800/// - **Everything else** — copied verbatim to the intermediate static HTML.
801///
802fn compile_to_metadata(input: &str, root_events: Vec<(String, String, bool)>) -> TemplateMeta {
803    let mut blocks = Vec::new();
804    let mut root = compile_section(input, &mut blocks);
805    finalize_template_section(&mut root);
806    for block in &mut blocks {
807        finalize_template_section(block);
808    }
809    TemplateMeta {
810        root,
811        blocks,
812        root_events,
813    }
814}
815
816fn compile_section(input: &str, blocks: &mut Vec<TemplateSectionMeta>) -> TemplateSectionMeta {
817    let mut meta = TemplateSectionMeta {
818        html: String::with_capacity(input.len()),
819        text_bindings: Vec::new(),
820        text_runs: Vec::new(),
821        attr_bindings: Vec::new(),
822        attr_groups: Vec::new(),
823        conditionals: Vec::new(),
824        condition_slots: Vec::new(),
825        repeats: Vec::new(),
826        repeat_slots: Vec::new(),
827        events: Vec::new(),
828        event_targets: Vec::new(),
829    };
830
831    let bytes = input.as_bytes();
832    let len = bytes.len();
833    let mut i = 0;
834
835    while i < len {
836        if !input.is_char_boundary(i) {
837            meta.html.push(bytes[i] as char);
838            i += 1;
839            continue;
840        }
841
842        // Triple-brace {{{expr}}} → marker + text binding (raw/unescaped)
843        if i + 4 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' && bytes[i + 2] == b'{' {
844            if let Some(end) = find_brace_end(input, i + 3, 3) {
845                let expr = input[i + 3..end].trim();
846                let idx = meta.text_bindings.len();
847                meta.text_bindings.push((expr.to_string(), true));
848                meta.html.push_str(&format!("<!--t:{idx}-->"));
849                i = end + 3;
850                continue;
851            }
852        }
853
854        // Double-brace {{expr}} → marker + text binding (escaped)
855        if i + 3 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
856            if let Some(end) = find_brace_end(input, i + 2, 2) {
857                let expr = input[i + 2..end].trim();
858                let idx = meta.text_bindings.len();
859                meta.text_bindings.push((expr.to_string(), false));
860                meta.html.push_str(&format!("<!--t:{idx}-->"));
861                i = end + 2;
862                continue;
863            }
864        }
865
866        // <if condition="...">...</if> → marker + conditional
867        if bytes[i] == b'<' {
868            let remaining = &input[i..];
869            if remaining.starts_with("<if ") || remaining.starts_with("<if\n") {
870                if let Some((cond, body, consumed)) = parse_if_block(remaining) {
871                    let block_index = blocks.len();
872                    blocks.push(TemplateSectionMeta::default());
873                    let block = compile_section(&body, blocks);
874                    blocks[block_index] = block;
875                    let idx = meta.conditionals.len();
876                    meta.conditionals.push((cond, block_index));
877                    meta.html.push_str(&format!("<!--c:{idx}-->"));
878                    i += consumed;
879                    continue;
880                }
881            }
882
883            // <for each="item in collection">...</for> → marker + repeat
884            if remaining.starts_with("<for ") || remaining.starts_with("<for\n") {
885                if let Some((collection, item_var, body, consumed)) = parse_for_block(remaining) {
886                    let block_index = blocks.len();
887                    blocks.push(TemplateSectionMeta::default());
888                    let block = compile_section(&body, blocks);
889                    blocks[block_index] = block;
890                    let idx = meta.repeats.len();
891                    meta.repeats.push((collection, item_var, block_index));
892                    meta.html.push_str(&format!("<!--r:{idx}-->"));
893                    i += consumed;
894                    continue;
895                }
896            }
897
898            // <outlet ... /> → keep as <outlet></outlet>
899            if remaining.starts_with("<outlet") {
900                if let Some(close) = find_tag_end(remaining) {
901                    meta.html.push_str("<outlet></outlet>");
902                    i += close + 1;
903                    continue;
904                }
905            }
906
907            if let Some((tag_html, consumed)) = parse_regular_tag(remaining, &mut meta) {
908                meta.html.push_str(&tag_html);
909                i += consumed;
910                continue;
911            }
912        }
913
914        // @event attributes → replace with a per-element event-count marker
915        if bytes[i] == b'@' && is_inside_tag(input, i) {
916            if let Some((event_name, handler, needs_event, consumed)) = parse_event_attr(input, i) {
917                meta.events.push((event_name, handler, needs_event));
918                meta.html.push_str("data-ev=\"1\"");
919                i += consumed;
920                continue;
921            }
922        }
923
924        // w-ref stays in static HTML as-is — the runtime binds from the DOM directly.
925        // No metadata entry needed; the attribute value IS the property name.
926
927        // Copy character
928        let ch = &input[i..];
929        if let Some(c) = ch.chars().next() {
930            meta.html.push(c);
931            i += c.len_utf8();
932        } else {
933            i += 1;
934        }
935    }
936
937    meta
938}
939
940#[derive(Clone)]
941enum FragmentNode {
942    Element(FragmentElement),
943    Text(String),
944    Comment(String),
945}
946
947#[derive(Clone)]
948struct FragmentElement {
949    tag_name: String,
950    attrs: Vec<FragmentAttr>,
951    children: Vec<FragmentNode>,
952    self_closing: bool,
953}
954
955#[derive(Clone)]
956struct FragmentAttr {
957    name: String,
958    value: Option<String>,
959}
960
961fn finalize_template_section(meta: &mut TemplateSectionMeta) {
962    let raw_html = std::mem::take(&mut meta.html);
963    let nodes = parse_fragment_nodes(&raw_html);
964    let text_bindings = meta.text_bindings.clone();
965    let mut finalized_html = String::with_capacity(raw_html.len());
966    let mut text_runs = Vec::new();
967    let mut attr_groups = Vec::new();
968    let mut condition_slots = vec![None; meta.conditionals.len()];
969    let mut repeat_slots = vec![None; meta.repeats.len()];
970    let mut event_targets = vec![None; meta.events.len()];
971    let mut event_cursor = 0usize;
972
973    process_fragment_children(
974        &nodes,
975        &[],
976        &text_bindings,
977        &mut finalized_html,
978        &mut text_runs,
979        &mut attr_groups,
980        &mut condition_slots,
981        &mut repeat_slots,
982        &mut event_targets,
983        &mut event_cursor,
984    );
985    debug_assert_eq!(event_cursor, meta.events.len());
986
987    meta.html = finalized_html;
988    meta.text_runs = text_runs;
989    meta.attr_groups = attr_groups;
990    meta.condition_slots = condition_slots.into_iter().flatten().collect();
991    meta.repeat_slots = repeat_slots.into_iter().flatten().collect();
992    meta.event_targets = event_targets.into_iter().flatten().collect();
993}
994
995#[allow(clippy::too_many_arguments)]
996fn process_fragment_children(
997    nodes: &[FragmentNode],
998    parent_path: &[usize],
999    text_bindings: &[(String, bool)],
1000    out: &mut String,
1001    text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
1002    attr_groups: &mut Vec<(Vec<usize>, usize, usize)>,
1003    condition_slots: &mut [Option<SlotLocator>],
1004    repeat_slots: &mut [Option<SlotLocator>],
1005    event_targets: &mut [Option<Vec<usize>>],
1006    event_cursor: &mut usize,
1007) {
1008    let mut child_index = 0usize;
1009    let mut index = 0usize;
1010    // Browser HTML parsing merges adjacent emitted text into a single Text node.
1011    // Track that here so marker-free locator paths match the real client DOM.
1012    let mut previous_emitted_text = false;
1013    let mut slot_orders = std::collections::BTreeMap::<usize, usize>::new();
1014
1015    while index < nodes.len() {
1016        if let Some((parts, consumed, has_dynamic, is_raw)) =
1017            collect_text_run(&nodes[index..], text_bindings)
1018        {
1019            if has_dynamic {
1020                // Decode HTML entities in static parts so that runtime
1021                // textContent assignment renders decoded characters
1022                // (e.g. `&gt;` → `>`).  Static-only text runs are
1023                // baked into `meta.h` and parsed via innerHTML which
1024                // handles entity decoding natively.
1025                let decoded_parts = parts
1026                    .into_iter()
1027                    .map(|part| match part {
1028                        CompiledAttrPart::Static(s) => {
1029                            let decoded = html_escape::decode_html_entities(&s);
1030                            CompiledAttrPart::Static(decoded.into_owned())
1031                        }
1032                        other => other,
1033                    })
1034                    .collect();
1035                let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1036                slot_orders.insert(child_index, order + 1);
1037                text_runs.push((
1038                    SlotLocator {
1039                        parent_path: parent_path.to_vec(),
1040                        before_index: child_index,
1041                        order,
1042                    },
1043                    decoded_parts,
1044                    is_raw,
1045                ));
1046            } else {
1047                let static_text = collect_static_text(&parts);
1048                if !static_text.is_empty() {
1049                    out.push_str(&static_text);
1050                    if !previous_emitted_text {
1051                        child_index += 1;
1052                    }
1053                    previous_emitted_text = true;
1054                }
1055            }
1056            index += consumed;
1057            continue;
1058        }
1059
1060        match &nodes[index] {
1061            FragmentNode::Comment(data) => {
1062                if let Some(idx) = parse_marker_index(data, "c:") {
1063                    if let Some(slot) = condition_slots.get_mut(idx) {
1064                        let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1065                        slot_orders.insert(child_index, order + 1);
1066                        *slot = Some(SlotLocator {
1067                            parent_path: parent_path.to_vec(),
1068                            before_index: child_index,
1069                            order,
1070                        });
1071                    }
1072                } else if let Some(idx) = parse_marker_index(data, "r:") {
1073                    if let Some(slot) = repeat_slots.get_mut(idx) {
1074                        let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1075                        slot_orders.insert(child_index, order + 1);
1076                        *slot = Some(SlotLocator {
1077                            parent_path: parent_path.to_vec(),
1078                            before_index: child_index,
1079                            order,
1080                        });
1081                    }
1082                } else {
1083                    out.push_str("<!--");
1084                    out.push_str(data);
1085                    out.push_str("-->");
1086                    child_index += 1;
1087                    previous_emitted_text = false;
1088                }
1089            }
1090            FragmentNode::Text(text) => {
1091                out.push_str(text);
1092                if !text.is_empty() {
1093                    if !previous_emitted_text {
1094                        child_index += 1;
1095                    }
1096                    previous_emitted_text = true;
1097                }
1098            }
1099            FragmentNode::Element(element) => {
1100                let mut element_path = parent_path.to_vec();
1101                element_path.push(child_index);
1102                serialize_fragment_element(
1103                    element,
1104                    &element_path,
1105                    text_bindings,
1106                    out,
1107                    text_runs,
1108                    attr_groups,
1109                    condition_slots,
1110                    repeat_slots,
1111                    event_targets,
1112                    event_cursor,
1113                );
1114                child_index += 1;
1115                previous_emitted_text = false;
1116            }
1117        }
1118
1119        index += 1;
1120    }
1121}
1122
1123#[allow(clippy::too_many_arguments)]
1124fn serialize_fragment_element(
1125    element: &FragmentElement,
1126    element_path: &[usize],
1127    text_bindings: &[(String, bool)],
1128    out: &mut String,
1129    text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
1130    attr_groups: &mut Vec<(Vec<usize>, usize, usize)>,
1131    condition_slots: &mut [Option<SlotLocator>],
1132    repeat_slots: &mut [Option<SlotLocator>],
1133    event_targets: &mut [Option<Vec<usize>>],
1134    event_cursor: &mut usize,
1135) {
1136    out.push('<');
1137    out.push_str(&element.tag_name);
1138
1139    for attr in &element.attrs {
1140        if let Some((start, count)) = parse_attr_group_marker(&attr.name) {
1141            attr_groups.push((element_path.to_vec(), start, count));
1142            continue;
1143        }
1144
1145        if attr.name == "data-ev" {
1146            if let Some(count) = attr.value.as_deref().and_then(parse_event_marker_count) {
1147                let target_path = element_path.to_vec();
1148                for slot in event_targets.iter_mut().skip(*event_cursor).take(count) {
1149                    *slot = Some(target_path.clone());
1150                }
1151                *event_cursor += count;
1152            }
1153            continue;
1154        }
1155
1156        out.push(' ');
1157        out.push_str(&attr.name);
1158        if let Some(value) = &attr.value {
1159            out.push_str("=\"");
1160            emit_html_attr_value(value, out);
1161            out.push('"');
1162        }
1163    }
1164
1165    if element.self_closing {
1166        out.push_str(" />");
1167        return;
1168    }
1169
1170    out.push('>');
1171    process_fragment_children(
1172        &element.children,
1173        element_path,
1174        text_bindings,
1175        out,
1176        text_runs,
1177        attr_groups,
1178        condition_slots,
1179        repeat_slots,
1180        event_targets,
1181        event_cursor,
1182    );
1183    out.push_str("</");
1184    out.push_str(&element.tag_name);
1185    out.push('>');
1186}
1187
1188fn collect_text_run(
1189    nodes: &[FragmentNode],
1190    text_bindings: &[(String, bool)],
1191) -> Option<(Vec<CompiledAttrPart>, usize, bool, bool)> {
1192    let mut parts = Vec::new();
1193    let mut consumed = 0usize;
1194    let mut has_dynamic = false;
1195    let mut is_raw = false;
1196
1197    for node in nodes {
1198        match node {
1199            FragmentNode::Text(text) => {
1200                if !text.is_empty() {
1201                    parts.push(CompiledAttrPart::Static(text.clone()));
1202                }
1203                consumed += 1;
1204            }
1205            FragmentNode::Comment(data) => {
1206                if let Some(index) = parse_marker_index(data, "t:") {
1207                    if let Some((path, raw)) = text_bindings.get(index) {
1208                        parts.push(CompiledAttrPart::Dynamic(path.clone()));
1209                        has_dynamic = true;
1210                        if *raw {
1211                            is_raw = true;
1212                        }
1213                    }
1214                    consumed += 1;
1215                } else {
1216                    break;
1217                }
1218            }
1219            FragmentNode::Element(_) => break,
1220        }
1221    }
1222
1223    if consumed == 0 {
1224        return None;
1225    }
1226
1227    Some((parts, consumed, has_dynamic, is_raw))
1228}
1229
1230fn collect_static_text(parts: &[CompiledAttrPart]) -> String {
1231    let mut text = String::new();
1232    for part in parts {
1233        if let CompiledAttrPart::Static(value) = part {
1234            text.push_str(value);
1235        }
1236    }
1237    text
1238}
1239
1240fn parse_marker_index(data: &str, prefix: &str) -> Option<usize> {
1241    data.strip_prefix(prefix)?.parse().ok()
1242}
1243
1244fn parse_attr_group_marker(name: &str) -> Option<(usize, usize)> {
1245    if let Some(start) = name.strip_prefix("data-w-b-") {
1246        return start.parse::<usize>().ok().map(|index| (index, 1));
1247    }
1248
1249    let rest = name.strip_prefix("data-w-c-")?;
1250    let mut parts = rest.split('-');
1251    let start = parts.next()?.parse::<usize>().ok()?;
1252    let count = parts.next()?.parse::<usize>().ok()?;
1253    Some((start, count))
1254}
1255
1256fn parse_event_marker_count(value: &str) -> Option<usize> {
1257    let count = value.parse::<usize>().ok()?;
1258    (count > 0).then_some(count)
1259}
1260
1261fn emit_html_attr_value(value: &str, out: &mut String) {
1262    for ch in value.chars() {
1263        match ch {
1264            '"' => out.push_str("&quot;"),
1265            _ => out.push(ch),
1266        }
1267    }
1268}
1269
1270fn parse_fragment_nodes(input: &str) -> Vec<FragmentNode> {
1271    let mut roots = Vec::new();
1272    let mut stack: Vec<FragmentElement> = Vec::new();
1273    let bytes = input.as_bytes();
1274    let len = bytes.len();
1275    let mut index = 0usize;
1276
1277    while index < len {
1278        let remaining = &input[index..];
1279        if remaining.starts_with("<!--") {
1280            if let Some(close) = remaining.find("-->") {
1281                push_fragment_node(
1282                    &mut roots,
1283                    &mut stack,
1284                    FragmentNode::Comment(remaining[4..close].to_string()),
1285                );
1286                index += close + 3;
1287                continue;
1288            }
1289        }
1290
1291        if remaining.starts_with("</") {
1292            if let Some(close) = remaining.find('>') {
1293                if let Some(element) = stack.pop() {
1294                    push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1295                }
1296                index += close + 1;
1297                continue;
1298            }
1299        }
1300
1301        if remaining.starts_with('<') {
1302            if let Some((element, consumed)) = parse_fragment_start_tag(remaining) {
1303                if element.self_closing {
1304                    push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1305                } else {
1306                    stack.push(element);
1307                }
1308                index += consumed;
1309                continue;
1310            }
1311        }
1312
1313        let next = remaining.find('<').unwrap_or(remaining.len());
1314        let text = &remaining[..next];
1315        if !text.is_empty() {
1316            push_fragment_node(&mut roots, &mut stack, FragmentNode::Text(text.to_string()));
1317        }
1318        index += next.max(1);
1319    }
1320
1321    while let Some(element) = stack.pop() {
1322        push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1323    }
1324
1325    roots
1326}
1327
1328fn push_fragment_node(
1329    roots: &mut Vec<FragmentNode>,
1330    stack: &mut [FragmentElement],
1331    node: FragmentNode,
1332) {
1333    if let Some(parent) = stack.last_mut() {
1334        parent.children.push(node);
1335    } else {
1336        roots.push(node);
1337    }
1338}
1339
1340fn parse_fragment_start_tag(input: &str) -> Option<(FragmentElement, usize)> {
1341    if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!--") {
1342        return None;
1343    }
1344
1345    let end = find_tag_end(input)?;
1346    let tag_content = &input[1..end];
1347    let tag_body = tag_content.trim_end();
1348    let self_closing = tag_body.ends_with('/');
1349    let tag_body = if self_closing {
1350        tag_body[..tag_body.len().saturating_sub(1)].trim_end()
1351    } else {
1352        tag_body
1353    };
1354
1355    let body_bytes = tag_body.as_bytes();
1356    let mut cursor = 0usize;
1357    while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
1358        cursor += 1;
1359    }
1360
1361    let tag_name = &tag_body[..cursor];
1362    if tag_name.is_empty() {
1363        return None;
1364    }
1365
1366    let mut attrs = Vec::new();
1367    while cursor < body_bytes.len() {
1368        while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1369            cursor += 1;
1370        }
1371        if cursor >= body_bytes.len() {
1372            break;
1373        }
1374
1375        let attr_start = cursor;
1376        while cursor < body_bytes.len()
1377            && !body_bytes[cursor].is_ascii_whitespace()
1378            && body_bytes[cursor] != b'='
1379        {
1380            cursor += 1;
1381        }
1382        if attr_start == cursor {
1383            cursor += 1;
1384            continue;
1385        }
1386
1387        let name = tag_body[attr_start..cursor].to_string();
1388        while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1389            cursor += 1;
1390        }
1391
1392        let mut value: Option<String> = None;
1393        if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
1394            cursor += 1;
1395            while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1396                cursor += 1;
1397            }
1398
1399            if cursor < body_bytes.len()
1400                && (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
1401            {
1402                let quote = body_bytes[cursor];
1403                cursor += 1;
1404                let value_start = cursor;
1405                while cursor < body_bytes.len() && body_bytes[cursor] != quote {
1406                    cursor += 1;
1407                }
1408                value = Some(tag_body[value_start..cursor].to_string());
1409                if cursor < body_bytes.len() {
1410                    cursor += 1;
1411                }
1412            } else {
1413                let value_start = cursor;
1414                while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
1415                    cursor += 1;
1416                }
1417                value = Some(tag_body[value_start..cursor].to_string());
1418            }
1419        }
1420
1421        attrs.push(FragmentAttr { name, value });
1422    }
1423
1424    Some((
1425        FragmentElement {
1426            tag_name: tag_name.to_string(),
1427            attrs,
1428            children: Vec::new(),
1429            self_closing: self_closing || is_void_element(tag_name),
1430        },
1431        end + 1,
1432    ))
1433}
1434
1435fn is_void_element(tag_name: &str) -> bool {
1436    matches!(
1437        tag_name,
1438        "area"
1439            | "base"
1440            | "br"
1441            | "col"
1442            | "embed"
1443            | "hr"
1444            | "img"
1445            | "input"
1446            | "link"
1447            | "meta"
1448            | "param"
1449            | "source"
1450            | "track"
1451            | "wbr"
1452    )
1453}
1454
1455// ── Parsing helpers ────────────────────────────────────────────────
1456
1457/// Find the position of `count` consecutive closing braces (`}`).
1458/// Returns `Some(start_of_closing_braces)` or `None` if not found.
1459fn find_brace_end(input: &str, start: usize, count: usize) -> Option<usize> {
1460    let bytes = input.as_bytes();
1461    let mut i = start;
1462    while i + count - 1 < bytes.len() {
1463        let all_close = (0..count).all(|j| bytes[i + j] == b'}');
1464        if all_close {
1465            return Some(i);
1466        }
1467        i += 1;
1468    }
1469    None
1470}
1471
1472/// Check whether byte position `pos` is inside an HTML tag (between `<` and `>`).
1473/// Scans backward from `pos` — returns `true` if we hit `<` before `>`.
1474fn is_inside_tag(input: &str, pos: usize) -> bool {
1475    let bytes = input.as_bytes();
1476    let mut i = pos;
1477    while i > 0 {
1478        i -= 1;
1479        if bytes[i] == b'>' {
1480            return false;
1481        }
1482        if bytes[i] == b'<' {
1483            return true;
1484        }
1485    }
1486    false
1487}
1488
1489/// Strip `<template shadowrootmode="…">…</template>` wrapper, returning inner content.
1490/// Returns the input unchanged if no wrapper is present.
1491fn strip_template_wrapper(html: &str) -> &str {
1492    if !html.starts_with("<template") {
1493        return html;
1494    }
1495    let Some(open_end) = find_tag_end(html) else {
1496        return html;
1497    };
1498    let inner_start = open_end + 1;
1499    let inner_end = html.rfind("</template>").unwrap_or(html.len());
1500    if inner_start >= inner_end {
1501        return "";
1502    }
1503    &html[inner_start..inner_end]
1504}
1505
1506fn find_next_block_token(input: &str, cursor: usize, token: &str) -> Option<usize> {
1507    let bytes = input.as_bytes();
1508    let token_bytes = token.as_bytes();
1509    let mut index = cursor;
1510    let mut in_tag = false;
1511    let mut in_comment = false;
1512    let mut quote: Option<u8> = None;
1513
1514    while index + token_bytes.len() <= bytes.len() {
1515        if in_comment {
1516            if bytes[index..].starts_with(b"-->") {
1517                in_comment = false;
1518                index += 3;
1519                continue;
1520            }
1521            index += 1;
1522            continue;
1523        }
1524
1525        if let Some(active) = quote {
1526            if bytes[index] == active {
1527                quote = None;
1528            }
1529            index += 1;
1530            continue;
1531        }
1532
1533        if in_tag {
1534            match bytes[index] {
1535                b'"' | b'\'' => quote = Some(bytes[index]),
1536                b'>' => in_tag = false,
1537                _ => {}
1538            }
1539            index += 1;
1540            continue;
1541        }
1542
1543        if &bytes[index..index + token_bytes.len()] == token_bytes {
1544            return Some(index);
1545        }
1546
1547        if bytes[index] == b'<' {
1548            if bytes[index..].starts_with(b"<!--") {
1549                in_comment = true;
1550                index += 4;
1551                continue;
1552            }
1553            in_tag = true;
1554        }
1555
1556        index += 1;
1557    }
1558
1559    None
1560}
1561
1562fn find_next_block_open(input: &str, cursor: usize, name: &str) -> Option<usize> {
1563    let token = format!("<{name}");
1564    let bytes = input.as_bytes();
1565    let token_bytes = token.as_bytes();
1566    let mut search = cursor;
1567
1568    while let Some(index) = find_next_block_token(input, search, &token) {
1569        let next = bytes.get(index + token_bytes.len()).copied();
1570        if match next {
1571            None => true,
1572            Some(byte) => byte == b'>' || byte.is_ascii_whitespace(),
1573        } {
1574            return Some(index);
1575        }
1576        search = index + 1;
1577    }
1578
1579    None
1580}
1581
1582fn find_next_block_close(input: &str, cursor: usize, name: &str) -> Option<usize> {
1583    let token = format!("</{name}>");
1584    find_next_block_token(input, cursor, &token)
1585}
1586
1587fn find_matching_block_end(input: &str, name: &str) -> Option<usize> {
1588    let open_token = format!("<{name}");
1589    let close_token = format!("</{name}>");
1590    if !input.starts_with(&open_token) {
1591        return None;
1592    }
1593
1594    let mut depth = 1usize;
1595    let mut cursor = open_token.len();
1596
1597    while cursor < input.len() {
1598        let next_open = find_next_block_open(input, cursor, name);
1599        let next_close = find_next_block_close(input, cursor, name);
1600
1601        match (next_open, next_close) {
1602            (_, None) => return None,
1603            (Some(open_pos), Some(close_pos)) if open_pos < close_pos => {
1604                depth += 1;
1605                cursor = open_pos + open_token.len();
1606            }
1607            (_, Some(close_pos)) => {
1608                depth = depth.saturating_sub(1);
1609                if depth == 0 {
1610                    return Some(close_pos);
1611                }
1612                cursor = close_pos + close_token.len();
1613            }
1614        }
1615    }
1616
1617    None
1618}
1619
1620/// Parse `<if condition="EXPR">BODY</if>` → `(condition, body, bytes_consumed)`.
1621///
1622/// Only handles the outermost `<if>` — nested `<if>` blocks inside the body
1623/// are left as raw HTML for the client runtime to process.
1624fn parse_if_block(input: &str) -> Option<(ConditionExpr, String, usize)> {
1625    let close_tag = "</if>";
1626    let end_pos = find_matching_block_end(input, "if")?;
1627    let tag_content = &input[..end_pos];
1628
1629    // Extract condition
1630    let cond_start = tag_content.find("condition=\"")? + "condition=\"".len();
1631    let cond_end = tag_content[cond_start..].find('"')? + cond_start;
1632    let condition = compile_condition_expr(&tag_content[cond_start..cond_end]);
1633
1634    // Extract body (after the closing >)
1635    let body_start = find_tag_end(tag_content)? + 1;
1636    let body = tag_content[body_start..].trim().to_string();
1637
1638    Some((condition, body, end_pos + close_tag.len()))
1639}
1640
1641fn compile_condition_expr(input: &str) -> ConditionExpr {
1642    let parser = ConditionParser::new();
1643    match parser.parse(input) {
1644        Ok(condition) => condition,
1645        // Component HTML is compiled after the main parser has already validated
1646        // condition syntax. Keep the emitted metadata structurally valid if that
1647        // earlier guarantee regresses.
1648        Err(_) => ConditionExpr::identifier(input),
1649    }
1650}
1651
1652/// Parse `<for each="item in collection">BODY</for>` → `(collection, item_var, body, consumed)`.
1653///
1654/// The `each` attribute must follow the `"item in collection"` pattern.
1655/// The body template retains `{{expr}}` mustaches — they are resolved by the
1656/// client runtime during reconciliation.
1657fn parse_for_block(input: &str) -> Option<(String, String, String, usize)> {
1658    let close_tag = "</for>";
1659    let end_pos = find_matching_block_end(input, "for")?;
1660    let tag_content = &input[..end_pos];
1661
1662    // Extract each="item in collection"
1663    let each_start = tag_content.find("each=\"")? + "each=\"".len();
1664    let each_end = tag_content[each_start..].find('"')? + each_start;
1665    let each_val = &tag_content[each_start..each_end];
1666
1667    let parts: Vec<&str> = each_val.splitn(3, ' ').collect();
1668    if parts.len() != 3 || parts[1] != "in" {
1669        return None;
1670    }
1671    let item_var = parts[0].to_string();
1672    let collection = parts[2].to_string();
1673
1674    // Extract body
1675    let body_start = find_tag_end(tag_content)? + 1;
1676    let body = tag_content[body_start..].trim().to_string();
1677
1678    Some((collection, item_var, body, end_pos + close_tag.len()))
1679}
1680
1681/// Parse `@event="{handler()}"` or `@event={handler()}` → `(event_name, handler_name, needs_event, consumed)`.
1682///
1683/// Supports three value quoting styles:
1684/// - `@click="{onClick()}"` — quoted with braces inside
1685/// - `@click={onClick()}` — unquoted braces
1686/// - `@click='onClick()'` — single-quoted
1687///
1688/// The handler name is extracted from the function call syntax. `needs_event`
1689/// is `true` when the argument list contains `(e)`.
1690fn parse_event_attr(input: &str, pos: usize) -> Option<(String, String, bool, usize)> {
1691    let remaining = &input[pos..];
1692    let eq_pos = remaining.find('=')?;
1693    let event_name = remaining[1..eq_pos].to_string(); // skip @
1694
1695    let after_eq = &remaining[eq_pos + 1..];
1696    if after_eq.is_empty() {
1697        return None;
1698    }
1699
1700    let first = after_eq.as_bytes()[0];
1701    let (raw_value, total_consumed) = if first == b'"' || first == b'\'' {
1702        let value_start = eq_pos + 2;
1703        let close = remaining[value_start..].find(first as char)?;
1704        (
1705            &remaining[value_start..value_start + close],
1706            value_start + close + 1,
1707        )
1708    } else if first == b'{' {
1709        let value_start = eq_pos + 2;
1710        let close = remaining[value_start..].find('}')?;
1711        (
1712            &remaining[value_start..value_start + close],
1713            value_start + close + 1,
1714        )
1715    } else {
1716        return None;
1717    };
1718
1719    let inner = raw_value
1720        .trim()
1721        .strip_prefix('{')
1722        .and_then(|s| s.strip_suffix('}'))
1723        .unwrap_or(raw_value)
1724        .trim();
1725
1726    // Extract method name from "handler()" or "handler(e)"
1727    if inner.is_empty() {
1728        return None;
1729    }
1730    let paren = match inner.find('(') {
1731        Some(p) => p,
1732        None => panic!(
1733            "Invalid @{event_name} handler: \"{inner}\". \
1734             Use @{event_name}=\"{{handler()}}\" or \
1735             @{event_name}=\"{{handler(e)}}\" to pass the event.",
1736        ),
1737    };
1738    let handler_name = inner[..paren].to_string();
1739    let needs_event = inner.contains("(e)");
1740
1741    Some((event_name, handler_name, needs_event, total_consumed))
1742}
1743
1744fn parse_regular_tag(input: &str, meta: &mut TemplateSectionMeta) -> Option<(String, usize)> {
1745    if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!") {
1746        return None;
1747    }
1748
1749    let bytes = input.as_bytes();
1750    if bytes.len() < 2 || !bytes[1].is_ascii_alphabetic() {
1751        return None;
1752    }
1753
1754    let end = find_tag_end(input)?;
1755    let tag_content = &input[1..end];
1756    let tag_body = tag_content.trim_end();
1757    let self_closing = tag_body.ends_with('/');
1758    let tag_body = if self_closing {
1759        tag_body[..tag_body.len().saturating_sub(1)].trim_end()
1760    } else {
1761        tag_body
1762    };
1763
1764    let body_bytes = tag_body.as_bytes();
1765    let mut cursor = 0;
1766    while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
1767        cursor += 1;
1768    }
1769
1770    let tag_name = &tag_body[..cursor];
1771    if tag_name.is_empty() {
1772        return None;
1773    }
1774
1775    let mut out = String::with_capacity(tag_body.len() + 24);
1776    out.push('<');
1777    out.push_str(tag_name);
1778
1779    let binding_start = meta.attr_bindings.len();
1780    let event_start = meta.events.len();
1781    let mut binding_count = 0usize;
1782
1783    while cursor < body_bytes.len() {
1784        while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1785            cursor += 1;
1786        }
1787        if cursor >= body_bytes.len() {
1788            break;
1789        }
1790
1791        let attr_start = cursor;
1792        while cursor < body_bytes.len()
1793            && !body_bytes[cursor].is_ascii_whitespace()
1794            && body_bytes[cursor] != b'='
1795        {
1796            cursor += 1;
1797        }
1798        if attr_start == cursor {
1799            cursor += 1;
1800            continue;
1801        }
1802
1803        let name = &tag_body[attr_start..cursor];
1804
1805        while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1806            cursor += 1;
1807        }
1808
1809        let mut value: Option<&str> = None;
1810        if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
1811            cursor += 1;
1812            while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1813                cursor += 1;
1814            }
1815
1816            if cursor < body_bytes.len()
1817                && (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
1818            {
1819                let quote = body_bytes[cursor];
1820                cursor += 1;
1821                let value_start = cursor;
1822                while cursor < body_bytes.len() && body_bytes[cursor] != quote {
1823                    cursor += 1;
1824                }
1825                value = Some(&tag_body[value_start..cursor]);
1826                if cursor < body_bytes.len() {
1827                    cursor += 1;
1828                }
1829            } else {
1830                let value_start = cursor;
1831                while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
1832                    cursor += 1;
1833                }
1834                value = Some(&tag_body[value_start..cursor]);
1835            }
1836        }
1837
1838        let raw_attr = tag_body[attr_start..cursor].trim();
1839        if raw_attr.is_empty() {
1840            continue;
1841        }
1842
1843        if let Some(event_name) = name.strip_prefix('@') {
1844            let Some(raw_value) = value else {
1845                continue;
1846            };
1847            match parse_event_handler(raw_value) {
1848                EventHandler::Valid(handler_name, needs_event) => {
1849                    meta.events
1850                        .push((event_name.to_string(), handler_name, needs_event));
1851                }
1852                EventHandler::Invalid(raw) => {
1853                    panic!(
1854                        "Invalid @{event_name} handler: \"{raw}\". \
1855                         Use @{event_name}=\"{{handler()}}\" or \
1856                         @{event_name}=\"{{handler(e)}}\" to pass the event.",
1857                    );
1858                }
1859                EventHandler::Empty => {}
1860            }
1861            continue;
1862        }
1863
1864        if name == "w-ref" {
1865            // Validate: w-ref must use {braces} to bind to a component property.
1866            // This is a build-time check — the runtime also validates.
1867            if let Some(val) = value {
1868                if !val.starts_with('{') || !val.ends_with('}') {
1869                    eprintln!(
1870                        "\x1b[1;31merror\x1b[0m: invalid w-ref=\"{}\" in <{}> — \
1871                         use w-ref={{{}}} with braces to bind to a component property",
1872                        val, tag_name, val
1873                    );
1874                }
1875            }
1876            out.push(' ');
1877            out.push_str(raw_attr);
1878            continue;
1879        }
1880
1881        if let Some(bool_name) = name.strip_prefix('?') {
1882            if let Some(raw_value) = value.and_then(extract_single_handlebars) {
1883                meta.attr_bindings.push(CompiledAttrBinding::Boolean {
1884                    name: bool_name.to_string(),
1885                    condition: compile_condition_expr(&raw_value),
1886                });
1887                binding_count += 1;
1888            }
1889            continue;
1890        }
1891
1892        if name.starts_with(':') {
1893            if let Some(raw_value) = value.and_then(extract_single_handlebars) {
1894                // Strip the ':' prefix and convert to camelCase for the JS property name.
1895                // The runtime uses el[name] = value directly — no conversion needed.
1896                let prop_name = webui_protocol::attrs::attribute_to_camel(
1897                    name.strip_prefix(':').unwrap_or(name),
1898                );
1899                meta.attr_bindings.push(CompiledAttrBinding::Complex {
1900                    name: prop_name,
1901                    value: raw_value,
1902                });
1903                binding_count += 1;
1904            }
1905            continue;
1906        }
1907
1908        if let Some(raw_value) = value {
1909            if let Some(parts) = parse_attr_parts(raw_value) {
1910                let is_simple =
1911                    parts.len() == 1 && matches!(parts.first(), Some(CompiledAttrPart::Dynamic(_)));
1912                if is_simple {
1913                    if let Some(CompiledAttrPart::Dynamic(path)) = parts.into_iter().next() {
1914                        meta.attr_bindings.push(CompiledAttrBinding::Simple {
1915                            name: name.to_string(),
1916                            value: path,
1917                        });
1918                    }
1919                } else {
1920                    meta.attr_bindings.push(CompiledAttrBinding::Template {
1921                        name: name.to_string(),
1922                        parts,
1923                    });
1924                }
1925                binding_count += 1;
1926                continue;
1927            }
1928        }
1929
1930        out.push(' ');
1931        out.push_str(raw_attr);
1932    }
1933
1934    if binding_count == 1 {
1935        out.push_str(" data-w-b-");
1936        out.push_str(&binding_start.to_string());
1937    } else if binding_count > 1 {
1938        out.push_str(" data-w-c-");
1939        out.push_str(&binding_start.to_string());
1940        out.push('-');
1941        out.push_str(&binding_count.to_string());
1942    }
1943
1944    let event_count = meta.events.len().saturating_sub(event_start);
1945    if event_count > 0 {
1946        out.push_str(" data-ev=\"");
1947        let _ = write!(out, "{}", event_count);
1948        out.push('"');
1949    }
1950
1951    if self_closing {
1952        out.push_str(" />");
1953    } else {
1954        out.push('>');
1955    }
1956
1957    Some((out, end + 1))
1958}
1959
1960fn find_tag_end(input: &str) -> Option<usize> {
1961    let bytes = input.as_bytes();
1962    let mut quote: Option<u8> = None;
1963    let mut i = 1;
1964
1965    while i < bytes.len() {
1966        let ch = bytes[i];
1967        if let Some(active) = quote {
1968            if ch == active {
1969                quote = None;
1970            }
1971        } else if ch == b'"' || ch == b'\'' {
1972            quote = Some(ch);
1973        } else if ch == b'>' {
1974            return Some(i);
1975        }
1976        i += 1;
1977    }
1978
1979    None
1980}
1981
1982enum EventHandler {
1983    Valid(String, bool),
1984    Invalid(String),
1985    Empty,
1986}
1987
1988fn parse_event_handler(raw_value: &str) -> EventHandler {
1989    let inner = raw_value
1990        .trim()
1991        .strip_prefix('{')
1992        .and_then(|s| s.strip_suffix('}'))
1993        .unwrap_or(raw_value)
1994        .trim();
1995    if inner.is_empty() {
1996        return EventHandler::Empty;
1997    }
1998    // `handler(e)` → needs event, `handler()` → no event
1999    match inner.find('(') {
2000        Some(paren) => {
2001            EventHandler::Valid(inner[..paren].trim().to_string(), inner.contains("(e)"))
2002        }
2003        None => EventHandler::Invalid(inner.to_string()),
2004    }
2005}
2006
2007fn extract_single_handlebars(value: &str) -> Option<String> {
2008    let trimmed = value.trim();
2009    if let Some(inner) = trimmed
2010        .strip_prefix("{{{")
2011        .and_then(|s| s.strip_suffix("}}}"))
2012    {
2013        return Some(inner.trim().to_string());
2014    }
2015
2016    if let Some(inner) = trimmed
2017        .strip_prefix("{{")
2018        .and_then(|s| s.strip_suffix("}}"))
2019    {
2020        return Some(inner.trim().to_string());
2021    }
2022
2023    None
2024}
2025
2026fn parse_attr_parts(value: &str) -> Option<Vec<CompiledAttrPart>> {
2027    let bytes = value.as_bytes();
2028    let len = bytes.len();
2029    let mut i = 0;
2030    let mut last = 0;
2031    let mut dynamic_found = false;
2032    let mut parts = Vec::new();
2033
2034    while i < len {
2035        if i + 2 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
2036            let brace_count = if i + 2 < len && bytes[i + 2] == b'{' {
2037                3
2038            } else {
2039                2
2040            };
2041
2042            if let Some(end) = find_brace_end(value, i + brace_count, brace_count) {
2043                if last < i {
2044                    parts.push(CompiledAttrPart::Static(value[last..i].to_string()));
2045                }
2046                parts.push(CompiledAttrPart::Dynamic(
2047                    value[i + brace_count..end].trim().to_string(),
2048                ));
2049                dynamic_found = true;
2050                i = end + brace_count;
2051                last = i;
2052                continue;
2053            }
2054        }
2055
2056        i += 1;
2057    }
2058
2059    if !dynamic_found {
2060        return None;
2061    }
2062
2063    if last < len {
2064        parts.push(CompiledAttrPart::Static(value[last..].to_string()));
2065    }
2066
2067    Some(parts)
2068}
2069
2070/// Extract `@event` bindings from the opening `<template>` tag.
2071///
2072/// These become "root events" (`re` array) attached to the host element
2073/// rather than to an element inside the shadow DOM.
2074fn extract_root_events(html: &str) -> Vec<(String, String, bool)> {
2075    if !html.starts_with("<template") {
2076        return Vec::new();
2077    }
2078    let Some(close) = find_tag_end(html) else {
2079        return Vec::new();
2080    };
2081    let tag = &html[..close];
2082    let mut events = Vec::new();
2083    let bytes = tag.as_bytes();
2084    let len = bytes.len();
2085    let mut i = 0;
2086
2087    while i < len {
2088        if bytes[i] == b'@' {
2089            if let Some((name, handler, needs_event, consumed)) = parse_event_attr(tag, i) {
2090                events.push((name, handler, needs_event));
2091                i += consumed;
2092                continue;
2093            }
2094        }
2095        i += 1;
2096    }
2097
2098    events
2099}
2100
2101fn extract_adopted_stylesheet_specifier(html: &str) -> Option<String> {
2102    if !html.starts_with("<template") {
2103        return None;
2104    }
2105    let close = find_tag_end(html)?;
2106    let tag = &html[..close];
2107    let attr = "shadowrootadoptedstylesheets=\"";
2108    let start = tag.find(attr)? + attr.len();
2109    let end = tag[start..].find('"')? + start;
2110    Some(tag[start..end].to_string())
2111}
2112
2113// ── Tests ──────────────────────────────────────────────────────────
2114
2115#[cfg(test)]
2116mod tests {
2117    use super::*;
2118
2119    fn assert_no_client_markers(result: &str) {
2120        assert!(!result.contains("<!--t:"), "text markers should be removed");
2121        assert!(
2122            !result.contains("<!--c:"),
2123            "conditional markers should be removed"
2124        );
2125        assert!(
2126            !result.contains("<!--r:"),
2127            "repeat markers should be removed"
2128        );
2129        assert!(
2130            !result.contains("data-w-b-"),
2131            "attribute binding markers should be removed"
2132        );
2133        assert!(
2134            !result.contains("data-w-c-"),
2135            "attribute binding range markers should be removed"
2136        );
2137        assert!(
2138            !result.contains("data-ev="),
2139            "event markers should be removed"
2140        );
2141    }
2142
2143    #[test]
2144    fn test_skip_event_attributes() {
2145        let mut plugin = WebUIParserPlugin::new();
2146        assert_eq!(plugin.classify_attribute("@click"), AttributeAction::Skip);
2147        assert_eq!(plugin.classify_attribute("@keydown"), AttributeAction::Skip);
2148        assert_eq!(plugin.classify_attribute("w-ref"), AttributeAction::Keep);
2149        assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
2150    }
2151
2152    #[test]
2153    fn test_metadata_has_text_bindings() {
2154        let result = generate_compiled_template("my-comp", "<h1>{{title}}</h1>");
2155        assert_no_client_markers(&result);
2156        assert!(result.contains(r#"h:"<h1></h1>""#));
2157        assert!(result.contains("\"title\""));
2158        assert!(result.contains(r#",tx:[[[[0],0],[["title"]]]]"#));
2159        assert!(!result.contains("{{"));
2160    }
2161
2162    #[test]
2163    fn test_metadata_has_conditionals() {
2164        let result = generate_compiled_template(
2165            "my-comp",
2166            r#"<if condition="state == 'done'"><span>yes</span></if>"#,
2167        );
2168        assert_no_client_markers(&result);
2169        assert!(result
2170            .contains(r#"c:[[[function(v,s){return v("state",s)=="done"},["state"]],0,[[],0]]]"#));
2171        assert!(result.contains("<span>yes</span>"));
2172        assert!(!result.contains("<if"));
2173    }
2174
2175    #[test]
2176    fn test_metadata_has_conditionals_with_gt_in_condition_attr() {
2177        let result = generate_compiled_template(
2178            "my-comp",
2179            r#"<if condition="count > 0"><span>yes</span></if>"#,
2180        );
2181        assert_no_client_markers(&result);
2182        assert!(result.contains(",c:["), "conditional metadata expected");
2183        assert!(result.contains("<span>yes</span>"));
2184        assert!(!result.contains("<if"));
2185    }
2186
2187    #[test]
2188    fn test_parse_if_block_ignores_close_marker_inside_attr_value() {
2189        let input = r#"<if condition="count > 0"><div data-note="</if>">yes</div></if>"#;
2190        let (_, body, consumed) = parse_if_block(input).expect("if block should parse");
2191
2192        assert_eq!(body, r#"<div data-note="</if>">yes</div>"#);
2193        assert_eq!(consumed, input.len());
2194    }
2195
2196    #[test]
2197    fn test_metadata_has_for_loops() {
2198        let result = generate_compiled_template(
2199            "my-comp",
2200            r#"<for each="item in items"><p>{{item.name}}</p></for>"#,
2201        );
2202        assert_no_client_markers(&result);
2203        assert!(result.contains("\"items\""));
2204        assert!(result.contains("\"item\""));
2205        assert!(!result.contains("<for"));
2206    }
2207
2208    #[test]
2209    fn test_parse_for_block_ignores_open_marker_inside_attr_value() {
2210        let input =
2211            r#"<for each="item in items"><div data-note="<for fake>">{{item.name}}</div></for>"#;
2212        let (collection, item_var, body, consumed) =
2213            parse_for_block(input).expect("for block should parse");
2214
2215        assert_eq!(collection, "items");
2216        assert_eq!(item_var, "item");
2217        assert_eq!(body, r#"<div data-note="<for fake>">{{item.name}}</div>"#);
2218        assert_eq!(consumed, input.len());
2219    }
2220
2221    #[test]
2222    fn test_metadata_has_for_loops_with_gt_in_other_attr() {
2223        let result = generate_compiled_template(
2224            "my-comp",
2225            r#"<for each="item in items" data-note="a > b"><p>{{item.name}}</p></for>"#,
2226        );
2227        assert_no_client_markers(&result);
2228        assert!(result.contains("\"items\""));
2229        assert!(result.contains("\"item\""));
2230        assert!(!result.contains("<for"));
2231        assert!(!result.contains("{{item.name}}"));
2232    }
2233
2234    #[test]
2235    fn test_metadata_has_events() {
2236        let result =
2237            generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
2238        assert_no_client_markers(&result);
2239        assert!(result.contains("\"click\""));
2240        assert!(result.contains("\"onClick\""));
2241        assert!(!result.contains("@click"));
2242    }
2243
2244    #[test]
2245    fn test_metadata_has_events_unquoted() {
2246        let result =
2247            generate_compiled_template("my-comp", r#"<button @click={onClick()}>Go</button>"#);
2248        assert_no_client_markers(&result);
2249        assert!(result.contains("\"click\""));
2250        assert!(result.contains("\"onClick\""));
2251    }
2252
2253    #[test]
2254    fn test_metadata_has_events_with_arg() {
2255        let result = generate_compiled_template("my-comp", r#"<input @keydown="{onKey(e)}" />"#);
2256        assert_no_client_markers(&result);
2257        assert!(result.contains("\"keydown\""));
2258        assert!(result.contains("\"onKey\""));
2259        assert!(result.contains(",1")); // needs_event = true
2260    }
2261
2262    #[test]
2263    fn test_metadata_has_simple_attr_bindings() {
2264        let result = generate_compiled_template("my-comp", r#"<input title="{{title}}" />"#);
2265        assert_no_client_markers(&result);
2266        assert!(result.contains(",a:["));
2267        assert!(result.contains(",ag:[[[0],0,1]]"));
2268        assert!(result.contains(r#"["title",0,"title"]"#));
2269        assert!(!result.contains(r#"title=\"<!--t:"#));
2270    }
2271
2272    #[test]
2273    fn test_metadata_has_template_attr_bindings() {
2274        let result = generate_compiled_template(
2275            "my-comp",
2276            r#"<a href="/product/{{handle}}" class="card {{variant}}">Go</a>"#,
2277        );
2278        assert_no_client_markers(&result);
2279        assert!(result.contains(",a:["));
2280        assert!(result.contains(",ag:[[[0],0,2]]"));
2281        assert!(result.contains(r#"["href",3,["/product/",["handle"]]]"#));
2282        assert!(result.contains(r#"["class",3,["card ",["variant"]]]"#));
2283        assert!(!result.contains(r#"/product/<!--t:"#));
2284    }
2285
2286    #[test]
2287    fn test_metadata_has_boolean_attr_bindings() {
2288        let result = generate_compiled_template(
2289            "my-comp",
2290            r#"<a ?data-active="{{page == 'dashboard'}}">Go</a>"#,
2291        );
2292        assert_no_client_markers(&result);
2293        assert!(result.contains(",a:["));
2294        assert!(result.contains(",ag:[[[0],0,1]]"));
2295        assert!(result.contains(
2296            r#"["data-active",2,[function(v,s){return v("page",s)=="dashboard"},["page"]]]"#
2297        ));
2298    }
2299
2300    #[test]
2301    fn test_metadata_has_complex_attr_bindings() {
2302        let result = generate_compiled_template(
2303            "my-comp",
2304            r#"<child-view :config="{{settings}}"></child-view>"#,
2305        );
2306        assert_no_client_markers(&result);
2307        assert!(result.contains(",a:["));
2308        assert!(result.contains(",ag:[[[0],0,1]]"));
2309        assert!(result.contains(r#""config",1,"settings""#));
2310    }
2311
2312    #[test]
2313    fn test_w_ref_stays_in_html() {
2314        let result = generate_compiled_template("my-comp", r#"<input w-ref="{myInput}" />"#);
2315        // w-ref stays in the static HTML — runtime binds from the DOM directly
2316        assert!(result.contains("w-ref"));
2317        assert!(result.contains("myInput"));
2318    }
2319
2320    #[test]
2321    fn test_w_ref_unquoted_stays_in_html() {
2322        let result = generate_compiled_template("my-comp", r#"<span w-ref={myLabel}>0</span>"#);
2323        assert!(result.contains("w-ref"));
2324        assert!(result.contains("myLabel"));
2325    }
2326
2327    #[test]
2328    fn test_metadata_has_root_events() {
2329        let result = generate_compiled_template(
2330            "my-comp",
2331            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>hi</div></template>"#,
2332        );
2333        assert!(result.contains(",re:["));
2334        assert!(result.contains("\"click\""));
2335        assert!(result.contains("\"onClick\""));
2336    }
2337
2338    #[test]
2339    fn test_strips_shadowrootmode() {
2340        let result = generate_compiled_template(
2341            "my-comp",
2342            r#"<template shadowrootmode="open"><p>{{title}}</p></template>"#,
2343        );
2344        assert!(!result.contains("shadowrootmode"));
2345        assert_no_client_markers(&result);
2346        assert!(result.contains(r#",tx:[[[[0],0],[["title"]]]]"#));
2347    }
2348
2349    #[test]
2350    fn test_root_template_allows_gt_in_quoted_attr_values() {
2351        let result = generate_compiled_template(
2352            "my-comp",
2353            r#"<template shadowrootmode="open" data-note="a > b" @click="{onClick()}"><p>hi</p></template>"#,
2354        );
2355        assert_no_client_markers(&result);
2356        assert!(result.contains(r#"h:"<p>hi</p>""#));
2357        assert!(result.contains(r#",re:[["click","onClick",0]]"#));
2358    }
2359
2360    #[test]
2361    fn test_outlet_converted() {
2362        let result = generate_compiled_template("my-comp", r#"<main><outlet /></main>"#);
2363        assert!(result.contains("<outlet></outlet>"));
2364    }
2365
2366    #[test]
2367    fn test_emoji_safe() {
2368        let result = generate_compiled_template("my-comp", r#"<span>📚 {{title}}</span>"#);
2369        assert!(result.contains("📚"));
2370        assert_no_client_markers(&result);
2371        assert!(result.contains(r#",tx:[[[[0],0],["📚 ",["title"]]]]"#));
2372    }
2373
2374    #[test]
2375    fn test_boolean_attr_in_for() {
2376        let result = generate_compiled_template(
2377            "my-comp",
2378            r#"<for each="s in sections"><a ?active="{{s.id == sectionId}}">{{s.name}}</a></for>"#,
2379        );
2380        assert_no_client_markers(&result);
2381        assert!(result.contains(r#"["active",2,[function(v,s){return v("s.id",s)==v("sectionId",s)},["s.id","sectionId"]]]"#));
2382        assert!(!result.contains("?active"));
2383    }
2384
2385    #[test]
2386    fn test_deduplicates_components() {
2387        let mut plugin = WebUIParserPlugin::new();
2388        let comp = Component {
2389            tag_name: "test-el".to_string(),
2390            html_content: "<p>hi</p>".to_string(),
2391            css_content: None,
2392            css_tokens: Vec::new(),
2393            source_path: std::path::PathBuf::new(),
2394            class_name: None,
2395        };
2396        plugin
2397            .register_component_template("test-el", &comp, &comp.html_content)
2398            .unwrap();
2399        plugin
2400            .register_component_template("test-el", &comp, &comp.html_content)
2401            .unwrap();
2402        assert_eq!(plugin.take_component_templates().len(), 1);
2403    }
2404
2405    #[test]
2406    fn test_compiled_template_preserves_link_node_in_static_html() {
2407        let result = generate_compiled_template(
2408            "my-comp",
2409            r#"<template shadowrootmode="open"><link rel="stylesheet" href="/my-comp.css"><p>hi</p></template>"#,
2410        );
2411        assert!(result.contains(r#"rel=\"stylesheet\""#));
2412        assert!(result.contains(r#"href=\"/my-comp.css\""#));
2413        assert!(result.contains(r#"<p>hi</p>"#));
2414        assert!(!result.contains(",css:"));
2415    }
2416
2417    #[test]
2418    fn test_compiled_template_preserves_inline_style_in_static_html() {
2419        let result = generate_compiled_template(
2420            "my-comp",
2421            r#"<template shadowrootmode="open"><style>.root{color:red}</style><p>hi</p></template>"#,
2422        );
2423        assert!(result.contains(r#"h:"<style>.root{color:red}</style><p>hi</p>""#));
2424    }
2425
2426    #[test]
2427    fn test_compiled_template_emits_adopted_stylesheet_specifier() {
2428        let result = generate_compiled_template(
2429            "my-comp",
2430            r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><p>hi</p></template>"#,
2431        );
2432        assert!(result.contains(r#",sa:"my-comp""#));
2433    }
2434
2435    #[test]
2436    fn test_plugin_uses_processed_link_template_html() {
2437        let mut plugin = WebUIParserPlugin::new();
2438
2439        let comp = Component {
2440            tag_name: "test-el".to_string(),
2441            html_content: "<p>hi</p>".to_string(),
2442            css_content: Some(".root { color: red; }".to_string()),
2443            css_tokens: Vec::new(),
2444            source_path: std::path::PathBuf::new(),
2445            class_name: None,
2446        };
2447
2448        plugin
2449            .register_component_template(
2450                "test-el",
2451                &comp,
2452                r#"<template shadowrootmode="open"><link rel="stylesheet" href="/test-el.css"><p>hi</p></template>"#,
2453            )
2454            .unwrap();
2455        let templates = plugin.take_component_templates();
2456        assert_eq!(templates.len(), 1);
2457        assert!(templates[0].1.contains(r#"rel=\"stylesheet\""#));
2458        assert!(templates[0].1.contains(r#"href=\"/test-el.css\""#));
2459        assert!(templates[0].1.contains(r#"<p>hi</p>"#));
2460    }
2461
2462    #[test]
2463    fn test_plugin_preserves_root_events_from_raw_template_source() {
2464        let mut plugin = WebUIParserPlugin::new();
2465
2466        let comp = Component {
2467            tag_name: "test-el".to_string(),
2468            html_content:
2469                r#"<template shadowrootmode="open" @click="{onClick(e)}"><p>hi</p></template>"#
2470                    .to_string(),
2471            css_content: Some(".root { color: red; }".to_string()),
2472            css_tokens: Vec::new(),
2473            source_path: std::path::PathBuf::new(),
2474            class_name: None,
2475        };
2476
2477        plugin
2478            .register_component_template(
2479                "test-el",
2480                &comp,
2481                r#"<template shadowrootmode="open"><link rel="stylesheet" href="/test-el.css"><p>hi</p></template>"#,
2482            )
2483            .unwrap();
2484        let templates = plugin.take_component_templates();
2485        assert_eq!(templates.len(), 1);
2486        assert!(templates[0].1.contains(r#",re:[["click","onClick",1]]"#));
2487    }
2488
2489    #[test]
2490    fn test_plugin_uses_processed_module_template_html() {
2491        let mut plugin = WebUIParserPlugin::new();
2492
2493        let comp = Component {
2494            tag_name: "test-el".to_string(),
2495            html_content: "<p>hi</p>".to_string(),
2496            css_content: Some(".root { color: red; }".to_string()),
2497            css_tokens: Vec::new(),
2498            source_path: std::path::PathBuf::new(),
2499            class_name: None,
2500        };
2501
2502        plugin
2503            .register_component_template(
2504                "test-el",
2505                &comp,
2506                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="test-el"><p>hi</p></template>"#,
2507            )
2508            .unwrap();
2509        let templates = plugin.take_component_templates();
2510        assert_eq!(templates.len(), 1);
2511        assert!(templates[0].1.contains(r#",sa:"test-el""#));
2512    }
2513
2514    #[test]
2515    fn test_nested_if_inside_for_compiles_nested_blocks() {
2516        let result = generate_compiled_template(
2517            "my-comp",
2518            r#"<for each="item in items"><if condition="item.active"><span>{{item.name}}</span></if></for>"#,
2519        );
2520
2521        assert_no_client_markers(&result);
2522        assert!(result.contains(",b:["), "compiled block table expected");
2523        assert!(
2524            result.contains(r#"["items","item",0,[[],0]]"#),
2525            "repeat block index expected"
2526        );
2527        assert!(
2528            result.contains(
2529                r#"[[function(v,s){return !!v("item.active",s)},["item.active"]],1,[[],0]]"#
2530            ),
2531            "nested conditional block index expected"
2532        );
2533        assert!(!result.contains("<if"), "nested if should be compiled");
2534        assert!(!result.contains("{{"), "nested bindings should be compiled");
2535    }
2536
2537    #[test]
2538    fn test_for_inside_if_compiles_nested_blocks() {
2539        let result = generate_compiled_template(
2540            "my-comp",
2541            r#"<if condition="showList"><for each="item in items"><p>{{item.name}}</p></for></if>"#,
2542        );
2543
2544        assert_no_client_markers(&result);
2545        assert!(result.contains(",b:["), "compiled block table expected");
2546        assert!(
2547            result.contains(r#"[[function(v,s){return !!v("showList",s)},["showList"]],0,[[],0]]"#),
2548            "conditional block index expected"
2549        );
2550        assert!(
2551            result.contains(r#"["items","item",1,[[],0]]"#),
2552            "nested repeat block index expected"
2553        );
2554        assert!(!result.contains("<for"), "nested for should be compiled");
2555        assert!(!result.contains("{{"), "nested bindings should be compiled");
2556    }
2557
2558    #[test]
2559    fn test_nested_for_inside_for_compiles_nested_blocks() {
2560        let result = generate_compiled_template(
2561            "my-comp",
2562            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>"#,
2563        );
2564
2565        assert_no_client_markers(&result);
2566        assert!(result.contains(",b:["), "compiled block table expected");
2567        assert!(
2568            result.contains(r#"["groups","group",0,[[],0]]"#),
2569            "outer repeat block index expected"
2570        );
2571        assert!(
2572            result.contains(r#"["group.items","item",1,[[0],0]]"#),
2573            "inner repeat block index expected"
2574        );
2575        assert!(!result.contains("<for"), "nested for should be compiled");
2576        assert!(!result.contains("{{"), "nested bindings should be compiled");
2577        assert!(
2578            !result.contains("?disabled"),
2579            "nested boolean attrs should be compiled"
2580        );
2581    }
2582
2583    #[test]
2584    fn test_multiple_events_on_same_element() {
2585        let result = generate_compiled_template(
2586            "my-comp",
2587            r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
2588        );
2589        assert_no_client_markers(&result);
2590        assert!(result.contains("\"keydown\""), "keydown event expected");
2591        assert!(result.contains("\"onKey\""), "onKey handler expected");
2592        assert!(result.contains("\"focus\""), "focus event expected");
2593        assert!(result.contains("\"onFocus\""), "onFocus handler expected");
2594    }
2595
2596    #[test]
2597    fn test_regular_tag_uses_single_event_marker_per_element() {
2598        let mut meta = TemplateSectionMeta::default();
2599        let (tag_html, _) = parse_regular_tag(
2600            r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
2601            &mut meta,
2602        )
2603        .expect("regular tag should parse");
2604
2605        assert_eq!(tag_html, r#"<input data-ev="2" />"#);
2606    }
2607
2608    #[test]
2609    fn test_empty_template() {
2610        let result = generate_compiled_template("my-comp", "");
2611        assert!(result.contains("__webui_templates"));
2612        assert!(result.contains("h:\"\""), "empty html expected");
2613        // No optional arrays should be present
2614        assert!(!result.contains(",t:"), "no text bindings");
2615        assert!(!result.contains(",c:"), "no conditionals");
2616        assert!(!result.contains(",r:"), "no repeats");
2617        assert!(!result.contains(",e:"), "no events");
2618        assert!(!result.contains(",re:"), "no root events");
2619    }
2620
2621    #[test]
2622    fn test_whitespace_only_template() {
2623        let result = generate_compiled_template("my-comp", "   \n  ");
2624        assert!(result.contains("__webui_templates"));
2625        assert!(
2626            result.contains("h:\"\""),
2627            "whitespace-only should produce empty html"
2628        );
2629    }
2630
2631    #[test]
2632    fn test_multiple_text_bindings() {
2633        let result = generate_compiled_template(
2634            "my-comp",
2635            r#"<p>{{first}}</p><p>{{second}}</p><p>{{third}}</p>"#,
2636        );
2637        assert_no_client_markers(&result);
2638        assert!(result.contains(
2639            r#",tx:[[[[0],0],[["first"]]],[[[1],0],[["second"]]],[[[2],0],[["third"]]]]"#
2640        ));
2641        assert!(result.contains("\"first\""));
2642        assert!(result.contains("\"second\""));
2643        assert!(result.contains("\"third\""));
2644    }
2645
2646    #[test]
2647    fn test_multiple_conditionals() {
2648        let result = generate_compiled_template(
2649            "my-comp",
2650            r#"<if condition="a"><span>A</span></if><if condition="b"><span>B</span></if>"#,
2651        );
2652        assert_no_client_markers(&result);
2653    }
2654
2655    #[test]
2656    fn test_locator_paths_merge_adjacent_static_text_nodes() {
2657        let result = generate_compiled_template(
2658            "mp-product-grid",
2659            r#"
2660<template shadowrootmode="open">
2661  <if condition="products.length == 0 && query">
2662    <p class="empty-results">There are no products that match "<strong>{{query}}</strong>"</p>
2663  </if>
2664  <div class="grid">
2665    <for each="product in products">
2666      <mp-product-card
2667        handle="{{product.handle}}"
2668        title="{{product.title}}"
2669        price="{{product.price}}"
2670        gradient="{{product.gradient}}"
2671        image-url="{{product.imageUrl}}"
2672        image-loading="lazy"
2673        variant="grid"
2674      ></mp-product-card>
2675    </for>
2676  </div>
2677</template>
2678"#,
2679        );
2680
2681        assert_no_client_markers(&result);
2682    }
2683
2684    #[test]
2685    fn test_mixed_bindings_events_refs() {
2686        let result = generate_compiled_template(
2687            "my-comp",
2688            r#"<button w-ref="{myBtn}" @click="{onClick()}">{{label}}</button>"#,
2689        );
2690        // w-ref stays in HTML (quotes escaped inside JS string)
2691        assert_no_client_markers(&result);
2692        assert!(result.contains("w-ref"));
2693        assert!(result.contains("myBtn"));
2694        // event compiled
2695        assert!(result.contains("\"click\""));
2696        assert!(result.contains("\"onClick\""));
2697        // text binding compiled
2698        assert!(result.contains(r#",tx:[[[[0],0],[["label"]]]]"#));
2699        assert!(result.contains("\"label\""));
2700    }
2701
2702    #[test]
2703    fn test_event_without_e_arg() {
2704        let result =
2705            generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
2706        assert!(
2707            result.contains(r#"["click","onClick",0,[0]]"#),
2708            "needs_event should be 0"
2709        );
2710    }
2711
2712    #[test]
2713    fn test_root_event_without_e_arg() {
2714        let result = generate_compiled_template(
2715            "my-comp",
2716            r#"<template shadowrootmode="open" @submit="{onSubmit()}"><form>hi</form></template>"#,
2717        );
2718        assert!(result.contains(",re:["));
2719        assert!(result.contains("\"submit\""));
2720        assert!(result.contains("\"onSubmit\""));
2721        assert!(result.contains(",0]"), "needs_event should be 0 for no (e)");
2722    }
2723
2724    #[test]
2725    fn test_triple_brace_raw_binding() {
2726        let result = generate_compiled_template("my-comp", r#"<div>{{{rawHtml}}}</div>"#);
2727        assert_no_client_markers(&result);
2728        assert!(
2729            result.contains(r#",tx:[[[[0],0],[["rawHtml"]],1]]"#),
2730            "triple brace produces text locator with raw flag: {result}"
2731        );
2732        assert!(
2733            result.contains("\"rawHtml\""),
2734            "rawHtml path in text bindings"
2735        );
2736    }
2737
2738    #[test]
2739    fn test_for_with_complex_body() {
2740        let result = generate_compiled_template(
2741            "my-comp",
2742            r#"<for each="user in users"><div class="card"><h2>{{user.name}}</h2><p>{{user.bio}}</p></div></for>"#,
2743        );
2744        assert_no_client_markers(&result);
2745        assert!(result.contains("\"users\""), "collection path");
2746        assert!(result.contains("\"user\""), "item variable");
2747        assert!(result.contains(",b:["), "compiled block table expected");
2748        assert!(
2749            !result.contains("{{user.name}}"),
2750            "bindings should be compiled"
2751        );
2752        assert!(
2753            !result.contains("{{user.bio}}"),
2754            "bindings should be compiled"
2755        );
2756    }
2757
2758    #[test]
2759    fn test_mixed_text_uses_text_runs() {
2760        let result = generate_compiled_template("my-comp", r#"<p>Hello {{name}}!</p>"#);
2761
2762        assert_no_client_markers(&result);
2763        assert!(result.contains(r#"h:"<p></p>""#));
2764        assert!(result.contains(r#",tx:[[[[0],0],["Hello ",["name"],"!"]]]"#));
2765    }
2766
2767    #[test]
2768    fn test_js_string_escaping() {
2769        let result = generate_compiled_template(
2770            "my-comp",
2771            r#"<if condition="name == &quot;test&quot;"><span>ok</span></if>"#,
2772        );
2773        assert!(result.contains(",c:["), "conditional array present");
2774        // The right-side literal should remain properly escaped in the AST payload.
2775        assert!(
2776            result.contains(r#"function(v,s){return v("name",s)==v("&quot;test&quot;",s)}"#),
2777            "condition function with escaped quotes should be present: {}",
2778            result
2779        );
2780    }
2781
2782    #[test]
2783    fn test_js_string_escapes_script_breakout_sequences() {
2784        let result = generate_compiled_template(
2785            "my-comp",
2786            r#"<div title="</script><script>alert(1)</script>"></div>"#,
2787        );
2788
2789        assert!(
2790            result.contains(r#"\u003C/script><script>alert(1)\u003C/script>"#),
2791            "script-closing sequences should be neutralized inside metadata strings: {result}"
2792        );
2793        assert!(
2794            !result.contains(r#"title=\"</script><script>alert(1)</script>\""#),
2795            "raw </script> must not appear inside serialized metadata strings: {result}"
2796        );
2797    }
2798
2799    #[test]
2800    fn test_js_string_escapes_line_separator_codepoints() {
2801        let html = format!("<p>before{}middle{}after</p>", '\u{2028}', '\u{2029}');
2802        let result = generate_compiled_template("my-comp", &html);
2803
2804        assert!(
2805            result.contains(r#"\u2028"#),
2806            "U+2028 should be escaped in metadata strings: {result}"
2807        );
2808        assert!(
2809            result.contains(r#"\u2029"#),
2810            "U+2029 should be escaped in metadata strings: {result}"
2811        );
2812    }
2813
2814    #[test]
2815    fn test_on_element_parsed_encodes_12_bytes() {
2816        let mut plugin = WebUIParserPlugin::new();
2817        // Simulate 2 event attributes skipped
2818        let _ = plugin.classify_attribute("@click");
2819        let _ = plugin.classify_attribute("@keydown");
2820        // Call on_element_parsed with 3 binding attributes
2821        let data = plugin.finish_element(3);
2822        assert!(data.is_some());
2823        let bytes = data.unwrap();
2824        assert_eq!(bytes.len(), 12, "plugin data should be 12 bytes");
2825        let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
2826        let event_start = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
2827        let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
2828        assert_eq!(binding_count, 3);
2829        assert_eq!(event_start, 0);
2830        assert_eq!(event_count, 2);
2831    }
2832
2833    #[test]
2834    fn test_on_element_parsed_no_data_when_empty() {
2835        let mut plugin = WebUIParserPlugin::new();
2836        let data = plugin.finish_element(0);
2837        assert!(
2838            data.is_none(),
2839            "no plugin data when no bindings and no events"
2840        );
2841    }
2842
2843    #[test]
2844    fn test_on_element_parsed_events_only() {
2845        let mut plugin = WebUIParserPlugin::new();
2846        let _ = plugin.classify_attribute("@click");
2847        let data = plugin.finish_element(0);
2848        assert!(
2849            data.is_some(),
2850            "should emit data for events even with 0 bindings"
2851        );
2852        let bytes = data.unwrap();
2853        let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
2854        let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
2855        assert_eq!(binding_count, 0);
2856        assert_eq!(event_count, 1);
2857    }
2858
2859    #[test]
2860    fn test_event_index_increments_across_elements() {
2861        let mut plugin = WebUIParserPlugin::new();
2862        // First element: 2 events
2863        let _ = plugin.classify_attribute("@click");
2864        let _ = plugin.classify_attribute("@focus");
2865        let data1 = plugin.finish_element(0).unwrap();
2866        let ev_start_1 = u32::from_le_bytes([data1[4], data1[5], data1[6], data1[7]]);
2867        assert_eq!(ev_start_1, 0, "first element starts at event 0");
2868
2869        // Second element: 1 event
2870        let _ = plugin.classify_attribute("@blur");
2871        let data2 = plugin.finish_element(0).unwrap();
2872        let ev_start_2 = u32::from_le_bytes([data2[4], data2[5], data2[6], data2[7]]);
2873        assert_eq!(
2874            ev_start_2, 2,
2875            "second element starts at event 2 (after first element's 2 events)"
2876        );
2877    }
2878
2879    #[test]
2880    fn test_text_run_decodes_html_entities_in_static_parts() {
2881        // Template: `<nav>{{sectionName}} &gt; {{topicName}}</nav>`
2882        // The `&gt;` is a static part between two dynamic bindings.
2883        // On the client, $resolveAttrParts concatenates parts and sets
2884        // textContent, which does NOT decode entities — so the compiler
2885        // must decode them in the metadata.
2886        let result =
2887            generate_compiled_template("my-comp", "<nav>{{sectionName}} &gt; {{topicName}}</nav>");
2888        assert_no_client_markers(&result);
2889        // The static part " > " should be decoded, not " &gt; "
2890        assert!(
2891            result.contains(r#"" > ""#),
2892            "static text run part should contain decoded '>' not '&gt;': {}",
2893            result
2894        );
2895        assert!(
2896            !result.contains(r#"" &gt; ""#),
2897            "static text run part should not contain raw '&gt;': {}",
2898            result
2899        );
2900    }
2901
2902    #[test]
2903    fn test_parse_event_handler_bare_name_is_invalid() {
2904        assert!(matches!(
2905            parse_event_handler("{closeMenu}"),
2906            EventHandler::Invalid(ref name) if name == "closeMenu"
2907        ));
2908    }
2909
2910    #[test]
2911    #[should_panic(expected = "Invalid @click handler")]
2912    fn test_parse_event_attr_bare_name_panics() {
2913        let input = r#"<button @click="{closeMenu}">Click</button>"#;
2914        parse_event_attr(input, 8);
2915    }
2916
2917    #[test]
2918    fn test_parse_event_handler_with_parens() {
2919        assert!(matches!(
2920            parse_event_handler("{onClick()}"),
2921            EventHandler::Valid(ref name, false) if name == "onClick"
2922        ));
2923    }
2924
2925    #[test]
2926    fn test_parse_event_handler_with_event_arg() {
2927        assert!(matches!(
2928            parse_event_handler("{onClick(e)}"),
2929            EventHandler::Valid(ref name, true) if name == "onClick"
2930        ));
2931    }
2932
2933    #[test]
2934    fn test_parse_event_handler_empty_value() {
2935        assert!(matches!(parse_event_handler("{}"), EventHandler::Empty));
2936    }
2937
2938    #[test]
2939    fn test_attribute_to_camel_aria() {
2940        use webui_protocol::attrs::attribute_to_camel;
2941        assert_eq!(attribute_to_camel("aria-describedby"), "ariaDescribedBy");
2942        assert_eq!(attribute_to_camel("aria-labelledby"), "ariaLabelledBy");
2943        assert_eq!(
2944            attribute_to_camel("aria-activedescendant"),
2945            "ariaActiveDescendant"
2946        );
2947        assert_eq!(attribute_to_camel("aria-label"), "ariaLabel");
2948        assert_eq!(attribute_to_camel("aria-hidden"), "ariaHidden");
2949    }
2950
2951    #[test]
2952    fn test_attribute_to_camel_html_global() {
2953        use webui_protocol::attrs::attribute_to_camel;
2954        assert_eq!(attribute_to_camel("readonly"), "readOnly");
2955        assert_eq!(attribute_to_camel("tabindex"), "tabIndex");
2956        assert_eq!(attribute_to_camel("accesskey"), "accessKey");
2957        assert_eq!(attribute_to_camel("contenteditable"), "contentEditable");
2958        assert_eq!(attribute_to_camel("inputmode"), "inputMode");
2959        assert_eq!(attribute_to_camel("maxlength"), "maxLength");
2960        assert_eq!(attribute_to_camel("formaction"), "formAction");
2961    }
2962}