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