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