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