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