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