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