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