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