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