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