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