Skip to main content

webui_parser/plugin/
webui.rs

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