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