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