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