Skip to main content

webui_parser/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Directive parser for WebUI template directives.
5//!
6//! This module handles parsing WebUI-specific directives like <for>, <if>, etc.
7mod component_registry;
8mod condition_parser;
9mod css_parser;
10mod error;
11mod handlebars_parser;
12pub mod plugin;
13mod route_parser;
14
15pub use component_registry::{Component, ComponentRegistry};
16pub use condition_parser::ConditionParser;
17pub use css_parser::CssParser;
18pub use error::{ParserError, Result};
19pub use handlebars_parser::HandlebarsParser;
20
21use crate::plugin::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
22use std::collections::{HashMap, HashSet};
23use tree_sitter::{Node, Parser, Query, QueryCursor, StreamingIteratorMut};
24use tree_sitter_html::LANGUAGE;
25use webui_protocol::{
26    web_ui_fragment, web_ui_fragment::Fragment, ConditionExpr, FragmentList, WebUIFragment,
27    WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentRoute,
28};
29
30/// Strategy for how component CSS is delivered in rendered output.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
33pub enum CssStrategy {
34    /// Emit `<link rel="stylesheet" href="component.css">` tags (default).
35    #[default]
36    Link,
37    /// Embed CSS content in `<style>` tags within the component.
38    Style,
39    /// Emit a `<style type="module" specifier="component">` definition once per
40    /// page. The client runtime applies it via the document's adopted stylesheets.
41    Module,
42}
43
44impl std::fmt::Display for CssStrategy {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            CssStrategy::Link => write!(f, "link"),
48            CssStrategy::Style => write!(f, "style"),
49            CssStrategy::Module => write!(f, "module"),
50        }
51    }
52}
53
54impl std::str::FromStr for CssStrategy {
55    type Err = String;
56
57    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
58        match s {
59            "link" => Ok(CssStrategy::Link),
60            "style" => Ok(CssStrategy::Style),
61            "module" => Ok(CssStrategy::Module),
62            other => Err(format!(
63                "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
64            )),
65        }
66    }
67}
68
69/// Strategy for how component DOM is structured.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
72pub enum DomStrategy {
73    /// Use shadow DOM with declarative shadow roots for SSR (default).
74    #[default]
75    Shadow,
76    /// Use light DOM — component content is rendered as direct children.
77    Light,
78}
79
80impl std::fmt::Display for DomStrategy {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            DomStrategy::Shadow => write!(f, "shadow"),
84            DomStrategy::Light => write!(f, "light"),
85        }
86    }
87}
88
89impl std::str::FromStr for DomStrategy {
90    type Err = String;
91
92    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
93        match s {
94            "shadow" => Ok(DomStrategy::Shadow),
95            "light" => Ok(DomStrategy::Light),
96            other => Err(format!(
97                "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
98            )),
99        }
100    }
101}
102
103/// Framework plugin to load for build-time and render-time processing.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
106pub enum Plugin {
107    /// Deprecated compatibility alias for the FAST 2 hydration plugin.
108    #[cfg_attr(feature = "cli", value(name = "fast"))]
109    Fast,
110    /// Deprecated FAST 2 hydration plugin with legacy marker output.
111    #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
112    FastV2,
113    /// FAST 3 hydration plugin with compact marker output.
114    #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
115    FastV3,
116    /// WebUI plugin — full component model with shadow DOM support.
117    #[cfg_attr(feature = "cli", value(name = "webui"))]
118    WebUI,
119}
120
121impl std::fmt::Display for Plugin {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        match self {
124            Plugin::Fast => write!(f, "fast"),
125            Plugin::FastV2 => write!(f, "fast-v2"),
126            Plugin::FastV3 => write!(f, "fast-v3"),
127            Plugin::WebUI => write!(f, "webui"),
128        }
129    }
130}
131
132impl std::str::FromStr for Plugin {
133    type Err = String;
134
135    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
136        match s {
137            "fast" => Ok(Plugin::Fast),
138            "fast-v2" => Ok(Plugin::FastV2),
139            "fast-v3" => Ok(Plugin::FastV3),
140            "webui" => Ok(Plugin::WebUI),
141            other => Err(format!(
142                "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
143            )),
144        }
145    }
146}
147
148/// Counter for generating unique fragment IDs.
149struct FragmentIdCounter {
150    /// Map of counter types to their current values.
151    counters: HashMap<String, usize>,
152}
153
154impl FragmentIdCounter {
155    /// Create a new fragment ID counter.
156    fn new() -> Self {
157        Self {
158            counters: HashMap::new(),
159        }
160    }
161
162    /// Generate a unique fragment ID.
163    fn next_id(&mut self, prefix: &str) -> String {
164        let count = self.counters.entry(prefix.to_string()).or_insert(0);
165        *count += 1;
166        format!("{}-{}", prefix, count)
167    }
168}
169
170impl Default for HtmlParser {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176/// Parser for WebUI directives.
177pub struct HtmlParser {
178    /// CSS parser.
179    css_parser: CssParser,
180
181    /// Tree-sitter parser for HTML.
182    parser: Parser,
183
184    /// Fragment ID counter.
185    id_counter: FragmentIdCounter,
186
187    /// Condition parser for parsing conditions in directives.
188    condition_parser: ConditionParser,
189
190    /// Handlebars parser for parsing handlebars expressions.
191    handlebars_parser: HandlebarsParser,
192
193    /// Component registry for WebUI components.
194    component_registry: ComponentRegistry,
195
196    /// Map of fragment IDs to their fragments
197    fragment_records: WebUIFragmentRecords,
198
199    /// Buffer for accumulating raw content
200    raw_buffer: String,
201
202    /// How component CSS is delivered in output.
203    css_strategy: CssStrategy,
204
205    /// How component DOM is structured (shadow or light).
206    dom_strategy: DomStrategy,
207
208    /// Optional parser plugin for framework-specific behavior.
209    plugin: Option<Box<dyn ParserPlugin>>,
210
211    /// Accumulated CSS custom property token names from all processed
212    /// components and inline `<style>` tags.
213    token_store: HashSet<String>,
214
215    /// CSS custom property names **defined** in inline `<style>` tags
216    /// (e.g., `:root { --color-primary: #0078d4; }`). These are excluded
217    /// from the final token set since the app already provides their values.
218    token_definitions: HashSet<String>,
219}
220
221impl HtmlParser {
222    /// Create a new directive parser.
223    pub fn new() -> Self {
224        let mut parser = Parser::new();
225        // Grammar is statically linked — set_language cannot fail at runtime.
226        #[allow(clippy::disallowed_methods)]
227        parser
228            .set_language(&LANGUAGE.into())
229            .expect("Error loading HTML grammar");
230
231        Self {
232            component_registry: ComponentRegistry::new(),
233            css_parser: CssParser::new(),
234            id_counter: FragmentIdCounter::new(),
235            condition_parser: ConditionParser::new(),
236            handlebars_parser: HandlebarsParser::new(),
237            raw_buffer: String::new(),
238            fragment_records: WebUIFragmentRecords::new(),
239            css_strategy: CssStrategy::default(),
240            dom_strategy: DomStrategy::default(),
241            plugin: None,
242            token_store: HashSet::new(),
243            token_definitions: HashSet::new(),
244            parser,
245        }
246    }
247
248    /// Create a new parser with a plugin for framework-specific behavior.
249    pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
250        let mut p = Self::new();
251        p.plugin = Some(plugin);
252        p
253    }
254
255    /// Set the CSS strategy for component stylesheet delivery.
256    pub fn set_css_strategy(&mut self, strategy: CssStrategy) -> &mut Self {
257        self.css_strategy = strategy;
258        self
259    }
260
261    /// Set the DOM strategy for component rendering (shadow or light).
262    pub fn set_dom_strategy(&mut self, strategy: DomStrategy) -> &mut Self {
263        self.dom_strategy = strategy;
264        self
265    }
266
267    /// Get a mutable reference to the component registry.
268    pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
269        &mut self.component_registry
270    }
271
272    pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
273        std::mem::take(&mut self.fragment_records)
274    }
275
276    /// Check if a fragment ID has been parsed (exists in the fragment records).
277    pub fn has_fragment(&self, fragment_id: &str) -> bool {
278        self.fragment_records.contains_key(fragment_id)
279    }
280
281    /// Take any post-parse artifacts captured by the parser plugin.
282    #[must_use]
283    pub fn take_plugin_artifacts(&mut self) -> ParserPluginArtifacts {
284        self.plugin
285            .take()
286            .map_or(ParserPluginArtifacts::None, |plugin| {
287                plugin.into_artifacts()
288            })
289    }
290
291    /// Take the accumulated CSS tokens as a sorted, deduplicated `Vec`.
292    ///
293    /// Tokens that are **defined** in inline `<style>` tags (e.g., in a
294    /// `:root` block) are excluded — only externally-referenced tokens
295    /// that the app does not already define are returned.
296    ///
297    /// This consumes the internal token store. Call after parsing is complete.
298    #[must_use]
299    pub fn take_tokens(&mut self) -> Vec<String> {
300        let definitions = std::mem::take(&mut self.token_definitions);
301        let mut tokens: Vec<String> = std::mem::take(&mut self.token_store)
302            .into_iter()
303            .filter(|t| !definitions.contains(t))
304            .collect();
305        tokens.sort();
306        tokens
307    }
308
309    /// Parse HTML content to generate WebUI fragments.
310    pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
311        // Reset sub-fragments for new parse
312        self.raw_buffer.clear();
313        if let Some(ref mut plugin) = self.plugin {
314            plugin.start_fragment(fragment_id);
315        }
316
317        // Parse HTML
318        let tree = self
319            .parser
320            .parse(html_content, None)
321            .ok_or_else(|| ParserError::Html("Failed to parse HTML".to_string()))?;
322
323        let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
324
325        // Start processing the HTML node.
326        self.process_html_node(tree.root_node(), html_content, &mut entry_fragment)?;
327
328        self.flush_raw_buffer(&mut entry_fragment);
329
330        // Insert the entry record.
331        self.fragment_records.insert(
332            fragment_id.to_string(),
333            FragmentList {
334                fragments: entry_fragment,
335            },
336        );
337
338        // Return all fragments including generated sub-fragments
339        Ok(())
340    }
341
342    /// Add raw content to the buffer
343    fn add_raw_fragment(&mut self, content: &str) {
344        if !content.is_empty() {
345            self.raw_buffer.push_str(content);
346        }
347    }
348
349    /// Add a for fragment, flushing raw buffer first
350    fn add_for_fragment(
351        &mut self,
352        item: String,
353        collection: String,
354        fragment_id: String,
355        fragments: &mut Vec<WebUIFragment>,
356    ) {
357        self.flush_raw_buffer(fragments);
358        fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
359    }
360
361    /// Add an if fragment, flushing raw buffer first
362    fn add_if_fragment(
363        &mut self,
364        condition: ConditionExpr,
365        fragment_id: String,
366        fragments: &mut Vec<WebUIFragment>,
367    ) {
368        self.flush_raw_buffer(fragments);
369        fragments.push(WebUIFragment::if_cond(condition, fragment_id));
370    }
371
372    /// Add a non-raw fragment, flushing the raw buffer first if needed
373    fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
374        self.flush_raw_buffer(fragments);
375        fragments.push(fragment);
376    }
377
378    /// Flush the raw buffer into fragments if not empty
379    fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
380        if !self.raw_buffer.is_empty() {
381            fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
382        }
383    }
384
385    /// Returns true when a text node should be emitted into the fragment stream.
386    ///
387    /// Pure formatting nodes that contain line breaks are still dropped, but
388    /// inline whitespace-only separators such as the spaces around `&gt;` in
389    /// `{{sectionName}} &gt; {{topicName}}` must be preserved. Tree-sitter can
390    /// split those separators into standalone text nodes.
391    fn should_emit_text_content(content: &str) -> bool {
392        if content.is_empty() {
393            return false;
394        }
395
396        if !content.trim().is_empty() {
397            return true;
398        }
399
400        content.chars().all(char::is_whitespace)
401            && !content.contains('\n')
402            && !content.contains('\r')
403    }
404
405    /// Process child nodes while preserving raw source gaps between them.
406    ///
407    /// Tree-sitter HTML treats some authored whitespace as extras rather than
408    /// concrete child nodes. Reconstructing the byte gaps between children keeps
409    /// inline separators like `{{a}} &gt; {{b}}` intact.
410    fn process_children_with_source_gaps(
411        &mut self,
412        node: Node,
413        source: &str,
414        fragments: &mut Vec<WebUIFragment>,
415        skip_structural_tags: bool,
416    ) -> Result<()> {
417        let mut cursor = node.walk();
418        let mut last_end = node.start_byte();
419
420        for child in node.children(&mut cursor) {
421            if child.start_byte() > last_end {
422                let gap = &source[last_end..child.start_byte()];
423                if Self::should_emit_text_content(gap) {
424                    self.add_raw_fragment(gap);
425                }
426            }
427
428            let kind = child.kind();
429            if !skip_structural_tags || (kind != "start_tag" && kind != "end_tag") {
430                self.process_child_node(child, source, fragments)?;
431            }
432
433            last_end = child.end_byte();
434        }
435
436        if node.end_byte() > last_end {
437            let gap = &source[last_end..node.end_byte()];
438            if Self::should_emit_text_content(gap) {
439                self.add_raw_fragment(gap);
440            }
441        }
442
443        Ok(())
444    }
445
446    /// Scan CSS text for `/* ... */` comments that contain exactly one
447    /// handlebars expression (e.g. `/*{{{tokens.light}}}*/`). Replace
448    /// each matching comment with a signal fragment and keep everything
449    /// else as raw CSS.
450    ///
451    /// A comment is a "signal placeholder" when:
452    /// 1. Its trimmed inner text parses to exactly one signal fragment.
453    /// 2. There is no other text around the signal inside the comment.
454    ///
455    /// Non-matching comments and all non-comment CSS are emitted as raw.
456    fn extract_style_comment_signals(
457        &mut self,
458        css: &str,
459        fragments: &mut Vec<WebUIFragment>,
460    ) -> Result<()> {
461        let bytes = css.as_bytes();
462        let len = bytes.len();
463        let mut pos = 0;
464
465        while pos < len {
466            // Find the next comment opening
467            let Some(open) = css[pos..].find("/*") else {
468                // No more comments — emit remaining CSS as raw
469                self.add_raw_fragment(&css[pos..]);
470                break;
471            };
472            let open = pos + open;
473
474            // Find the matching close
475            let Some(close_offset) = css[open + 2..].find("*/") else {
476                // Unterminated comment — emit rest as raw
477                self.add_raw_fragment(&css[pos..]);
478                break;
479            };
480            let close = open + 2 + close_offset;
481            let after_close = close + 2;
482
483            // Extract and trim the inner text of the comment
484            let inner = css[open + 2..close].trim();
485
486            // Try to parse the inner text with the handlebars parser.
487            // Accept only if it produces exactly one signal fragment.
488            let is_signal = if !inner.is_empty() {
489                match self.handlebars_parser.parse(inner) {
490                    Ok(ref parsed) if parsed.len() == 1 => {
491                        matches!(parsed[0].fragment.as_ref(), Some(Fragment::Signal(_)))
492                    }
493                    _ => false,
494                }
495            } else {
496                false
497            };
498
499            if is_signal {
500                // Emit CSS before the comment as raw
501                self.add_raw_fragment(&css[pos..open]);
502
503                // Emit the signal fragment (re-parse to extract it)
504                let parsed = self.handlebars_parser.parse(inner)?;
505                self.add_fragment(parsed.into_iter().next().unwrap_or_default(), fragments);
506            } else {
507                // Not a signal placeholder — emit everything including
508                // the comment as raw CSS
509                self.add_raw_fragment(&css[pos..after_close]);
510            }
511
512            pos = after_close;
513        }
514
515        Ok(())
516    }
517
518    /// Process all non-structural child nodes inside an element-like container.
519    fn process_content_children(
520        &mut self,
521        node: Node,
522        source: &str,
523        fragments: &mut Vec<WebUIFragment>,
524    ) -> Result<()> {
525        self.process_children_with_source_gaps(node, source, fragments, true)
526    }
527    /// Process an HTML node to generate WebUI fragments.
528    fn process_html_node(
529        &mut self,
530        node: Node,
531        source: &str,
532        fragments: &mut Vec<WebUIFragment>,
533    ) -> Result<()> {
534        if node.kind() == "document" || node.kind() == "fragment" || node.kind() == "element" {
535            self.process_children_with_source_gaps(node, source, fragments, false)?;
536        } else {
537            // Add text content as raw fragment
538            let content = &source[node.start_byte()..node.end_byte()];
539            if Self::should_emit_text_content(content) {
540                self.add_raw_fragment(content);
541            }
542        }
543
544        Ok(())
545    }
546
547    /// Process a child HTML node.
548    fn process_child_node(
549        &mut self,
550        node: Node,
551        source: &str,
552        fragments: &mut Vec<WebUIFragment>,
553    ) -> Result<()> {
554        match node.kind() {
555            "element" => {
556                // Get the tag name
557                let tag_name = self.get_element_tag_name(node, source)?;
558
559                // Handle WebUI directives
560                match tag_name.as_str() {
561                    "for" => return self.process_for_directive(node, source, fragments),
562                    "if" => return self.process_if_directive(node, source, fragments),
563                    "body" => return self.process_body_element(node, source, fragments),
564                    "head" => return self.process_head_element(node, source, fragments),
565                    "route" => return self.process_route_directive(node, source, fragments),
566                    "outlet" => {
567                        self.flush_raw_buffer(fragments);
568                        fragments.push(WebUIFragment::outlet());
569                        return Ok(());
570                    }
571                    _ => {
572                        if self.component_registry.contains(tag_name.as_str()) {
573                            return self.process_component_directive(
574                                node,
575                                source,
576                                fragments,
577                                tag_name.as_str(),
578                            );
579                        }
580
581                        // Process regular HTML element with attribute-aware parsing
582                        self.process_regular_element(node, source, fragments, &tag_name)?;
583
584                        return Ok(());
585                    }
586                }
587            }
588            "style_element" => {
589                // Process inline CSS — extract tokens and recognise
590                // comment-delimited signal placeholders (e.g. /*{{{tokens.light}}}*/).
591                self.add_raw_fragment("<style>");
592                for child in node.named_children(&mut node.walk()) {
593                    if child.kind() == "raw_text" {
594                        let style_content = &source[child.start_byte()..child.end_byte()];
595
596                        // Single parse: extract both token usages and definitions.
597                        // This must run on the *original* style source, unmodified.
598                        if let Ok((tokens, defs)) = self
599                            .css_parser
600                            .extract_tokens_and_definitions(style_content)
601                        {
602                            self.token_store.extend(tokens);
603                            self.token_definitions.extend(defs);
604                        }
605
606                        // Scan for CSS comments that contain exactly one
607                        // handlebars expression. Replace those comments with
608                        // signal fragments; leave everything else as raw CSS.
609                        self.extract_style_comment_signals(style_content, fragments)?;
610                    }
611                }
612                self.add_raw_fragment("</style>");
613            }
614            "text" | "raw_text" => {
615                let content = &source[node.start_byte()..node.end_byte()];
616                if Self::should_emit_text_content(content) {
617                    let handlebars_result = self.handlebars_parser.parse(content);
618                    match handlebars_result {
619                        Ok(parsed_fragments) => {
620                            for fragment in parsed_fragments {
621                                if matches!(fragment.fragment.as_ref(), Some(Fragment::Raw(_))) {
622                                    if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
623                                        self.add_raw_fragment(&raw.value);
624                                    }
625                                } else {
626                                    self.add_fragment(fragment, fragments);
627                                }
628                            }
629                        }
630                        Err(error) => return Err(error),
631                    }
632                }
633            }
634            // Preserve <!DOCTYPE ...> as raw content. Tree-sitter parses it as a
635            // "doctype" node whose children are tokens (< ! DOCTYPE etc.), so we
636            // grab the original source text verbatim to ensure full-page HTML
637            // templates round-trip correctly.
638            "doctype" => {
639                let content = &source[node.start_byte()..node.end_byte()];
640                self.add_raw_fragment(content);
641            }
642            // HTML comment: check for handlebars bindings like <!--{{tokens}}-->
643            "comment" => {
644                let content = &source[node.start_byte()..node.end_byte()];
645                self.process_comment(content, fragments)?;
646            }
647            // For other node types (like head, body), traverse their children
648            _ => {
649                self.process_html_node(node, source, fragments)?;
650            }
651        }
652
653        Ok(())
654    }
655
656    /// Get the tag name of an element.
657    fn get_element_tag_name(&self, node: Node, source: &str) -> Result<String> {
658        let mut cursor = node.walk();
659        for child in node.named_children(&mut cursor) {
660            if child.kind() == "start_tag" || child.kind() == "self_closing_tag" {
661                let mut tag_cursor = child.walk();
662                for tag_name_node in child.named_children(&mut tag_cursor) {
663                    if tag_name_node.kind() == "tag_name" {
664                        let tag_name = source[tag_name_node.start_byte()..tag_name_node.end_byte()]
665                            .to_string();
666                        return Ok(tag_name);
667                    }
668                }
669            }
670        }
671
672        Err(ParserError::Html("Failed to extract tag name".to_string()))
673    }
674
675    fn get_element_attribute(
676        &self,
677        node: Node,
678        attr_name: &str,
679        source: &str,
680    ) -> Result<Option<String>> {
681        let query_str = r#"
682            (element
683              [
684                (start_tag
685                  (attribute
686                    (attribute_name) @name
687                    [(quoted_attribute_value (attribute_value) @value)
688                     (attribute_value) @value]))
689                (self_closing_tag
690                  (attribute
691                    (attribute_name) @name
692                    [(quoted_attribute_value (attribute_value) @value)
693                     (attribute_value) @value]))
694              ]
695            )
696        "#;
697
698        let query = Query::new(&LANGUAGE.into(), query_str)
699            .map_err(|e| ParserError::Html(format!("Failed to create attribute query: {:?}", e)))?;
700
701        let mut cursor = QueryCursor::new();
702        let mut matches = cursor.matches(&query, node, source.as_bytes());
703        while let Some(m) = matches.next_mut() {
704            let mut found_name = false;
705            let mut found_value: Option<String> = None;
706
707            for capture in m.captures.iter() {
708                let node = capture.node;
709                let capture_name = query.capture_names()[capture.index as usize];
710
711                if capture_name == "name" {
712                    let name_text = node.utf8_text(source.as_bytes()).map_err(|_| {
713                        ParserError::Html("Invalid UTF-8 for attribute name".to_string())
714                    })?;
715                    if name_text == attr_name {
716                        found_name = true;
717                    }
718                } else if capture_name == "value" {
719                    let value_text = node.utf8_text(source.as_bytes()).map_err(|_| {
720                        ParserError::Html("Invalid UTF-8 for attribute value".to_string())
721                    })?;
722                    found_value = Some(value_text.to_string());
723                }
724            }
725
726            if found_name {
727                return Ok(found_value);
728            }
729        }
730
731        Ok(None)
732    }
733
734    /// Check whether a boolean attribute (no value) exists on an element.
735    /// Returns `true` for `<el attr>` or `<el attr="...">`.
736    fn has_element_attribute(&self, node: Node, attr_name: &str, source: &str) -> Result<bool> {
737        // First check for valued attributes
738        if self
739            .get_element_attribute(node, attr_name, source)?
740            .is_some()
741        {
742            return Ok(true);
743        }
744
745        // Check for boolean (valueless) attributes via tree-sitter query.
746        // Only match attributes directly on this element's start_tag/self_closing_tag,
747        // not on nested child elements (cursor.matches descends into subtrees).
748        let query_str = r#"
749            (element
750              [
751                (start_tag
752                  (attribute
753                    (attribute_name) @name))
754                (self_closing_tag
755                  (attribute
756                    (attribute_name) @name))
757              ]
758            )
759        "#;
760
761        let query = Query::new(&LANGUAGE.into(), query_str)
762            .map_err(|e| ParserError::Html(format!("Failed to create attribute query: {:?}", e)))?;
763
764        // Find the start_tag or self_closing_tag node for this element
765        let tag_node_id = node
766            .named_children(&mut node.walk())
767            .find(|c| c.kind() == "start_tag" || c.kind() == "self_closing_tag")
768            .map(|c| c.id());
769
770        let mut cursor = QueryCursor::new();
771        let mut matches = cursor.matches(&query, node, source.as_bytes());
772        while let Some(m) = matches.next_mut() {
773            for capture in m.captures.iter() {
774                let capture_name = query.capture_names()[capture.index as usize];
775                if capture_name == "name" {
776                    // Only accept attributes whose parent tag belongs to THIS element
777                    let attr_parent = capture.node.parent().and_then(|a| a.parent());
778                    if attr_parent
779                        .map(|p| Some(p.id()) == tag_node_id)
780                        .unwrap_or(false)
781                    {
782                        let name_text =
783                            capture.node.utf8_text(source.as_bytes()).map_err(|_| {
784                                ParserError::Html("Invalid UTF-8 for attribute name".to_string())
785                            })?;
786                        if name_text == attr_name {
787                            return Ok(true);
788                        }
789                    }
790                }
791            }
792        }
793
794        Ok(false)
795    }
796
797    /// Find the start_tag or self_closing_tag child of an element node.
798    fn find_tag_node<'a>(&self, node: Node<'a>) -> Option<Node<'a>> {
799        let mut cursor = node.walk();
800        let result = node
801            .named_children(&mut cursor)
802            .find(|child| child.kind() == "start_tag" || child.kind() == "self_closing_tag");
803        result
804    }
805
806    /// Check if an attribute value is a pure handlebars expression (e.g., "{{name}}" or
807    /// "{{name}}" with quotes). Returns the inner signal name if so.
808    fn extract_single_handlebars(value: &str) -> Option<String> {
809        let trimmed = value.trim();
810        if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
811            let inner = trimmed[2..trimmed.len() - 2].trim();
812            // Verify there's no other {{ in the middle (i.e., it's truly a single expression)
813            if !inner.contains("{{") && !inner.is_empty() {
814                return Some(inner.to_string());
815            }
816        }
817        None
818    }
819
820    /// Check if an attribute value contains any handlebars expressions.
821    fn contains_handlebars(value: &str) -> bool {
822        value.contains("{{")
823    }
824
825    /// Check if an element node has an end_tag child.
826    fn has_end_tag(&self, node: Node) -> bool {
827        let mut cursor = node.walk();
828        let result = node
829            .named_children(&mut cursor)
830            .any(|child| child.kind() == "end_tag");
831        result
832    }
833
834    /// Process a regular HTML element with attribute-aware parsing.
835    fn process_regular_element(
836        &mut self,
837        node: Node,
838        source: &str,
839        fragments: &mut Vec<WebUIFragment>,
840        tag_name: &str,
841    ) -> Result<()> {
842        let tag_node = self.find_tag_node(node);
843        let is_self_closing = tag_node
844            .map(|n| n.kind() == "self_closing_tag")
845            .unwrap_or(false);
846        let has_end = self.has_end_tag(node);
847
848        if let Some(tag_node) = tag_node {
849            // Emit "<tagname" as the start of raw content
850            self.add_raw_fragment(&format!("<{}", tag_name));
851
852            // Process attributes on the tag
853            let binding_count = self.process_tag_attributes(tag_node, source, fragments, false)?;
854            if let Some(ref mut p) = self.plugin {
855                if let Some(data) = p.finish_element(binding_count) {
856                    self.add_fragment(WebUIFragment::plugin(data), fragments);
857                }
858            }
859
860            if is_self_closing {
861                // Find the /> at the end of the self-closing tag
862                let tag_text = &source[tag_node.start_byte()..tag_node.end_byte()];
863                if tag_text.ends_with("/>") {
864                    self.add_raw_fragment("/>");
865                } else {
866                    self.add_raw_fragment(">");
867                }
868            } else if !has_end {
869                // Void element (no end tag from parser) — just close the opening tag.
870                // Tree-sitter HTML may still attach following content as children
871                // of the void element (e.g., `<br>text {{binding}}`), so we must
872                // process those children to avoid dropping text and bindings.
873                self.add_raw_fragment(">");
874                self.process_content_children(node, source, fragments)?;
875            } else {
876                self.add_raw_fragment(">");
877
878                self.process_content_children(node, source, fragments)?;
879
880                // Add closing tag
881                self.add_raw_fragment(&format!("</{}>", tag_name));
882            }
883        }
884
885        Ok(())
886    }
887
888    /// Process all attributes on a start_tag or self_closing_tag node.
889    /// For component elements (`is_component = true`), all attributes become
890    /// fragments and the first non-skipped attribute is marked with `attr_start`.
891    /// For regular elements, only dynamic attributes become fragments while
892    /// static attributes are accumulated into the raw buffer.
893    /// Returns the number of binding (dynamic) attributes found.
894    fn process_tag_attributes(
895        &mut self,
896        tag_node: Node,
897        source: &str,
898        fragments: &mut Vec<WebUIFragment>,
899        is_component: bool,
900    ) -> Result<u32> {
901        let mut first_dynamic_emitted = false;
902        let mut binding_count: u32 = 0;
903        let mut cursor = tag_node.walk();
904
905        for child in tag_node.named_children(&mut cursor) {
906            if child.kind() != "attribute" {
907                continue;
908            }
909
910            let attr_name = self.get_attr_name(child, source)?;
911
912            if let Some(ref mut p) = self.plugin {
913                match p.classify_attribute(&attr_name) {
914                    AttributeAction::Keep => {}
915                    AttributeAction::Skip => continue,
916                    AttributeAction::SkipAndCountBinding => {
917                        binding_count += 1;
918                        continue;
919                    }
920                }
921            }
922
923            let attr_value = self.get_attr_value(child, source);
924
925            if let Some(bool_name) = attr_name.strip_prefix('?') {
926                // Boolean attribute: ?disabled={{isDisabled}}
927                if is_component {
928                    if let Some(condition) = self.parse_boolean_condition(attr_value.as_deref()) {
929                        let frag = Self::maybe_mark_attr_start(
930                            WebUIFragment::attribute_boolean(bool_name, condition),
931                            &mut first_dynamic_emitted,
932                        );
933                        self.add_fragment(frag, fragments);
934                        binding_count += 1;
935                    }
936                } else if self.process_boolean_attribute(
937                    bool_name,
938                    attr_value.as_deref(),
939                    fragments,
940                )? {
941                    binding_count += 1;
942                }
943            } else if let Some(prop_name) = attr_name.strip_prefix(':') {
944                // Complex attribute: :config="{{settings}}"
945                // Only valid on custom elements — passes JS values by
946                // reference (objects, arrays). Native HTML elements should
947                // use regular attribute bindings (the framework already
948                // handles value, checked, selected as DOM properties).
949                if !is_component {
950                    return Err(ParserError::Parse(format!(
951                        ":{prop_name} complex binding is only allowed on custom elements. \
952                         Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
953                    )));
954                }
955                if Self::is_blocked_complex_property(prop_name) {
956                    return Err(ParserError::Parse(format!(
957                        ":{prop_name} is not allowed as a complex attribute binding \
958                         because it enables arbitrary HTML injection. \
959                         Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
960                    )));
961                }
962                if let Some(val) = &attr_value {
963                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
964                        let frag = Self::maybe_mark_attr_start(
965                            WebUIFragment::attribute_complex(&attr_name, signal_name),
966                            &mut first_dynamic_emitted,
967                        );
968                        self.add_fragment(frag, fragments);
969                        binding_count += 1;
970                    }
971                }
972            } else if is_component && Self::is_skipped_attribute(&attr_name) {
973                // Skipped component attribute (class, style, role, data-*, aria-*)
974                if let Some(val) = &attr_value {
975                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
976                        // Pure binding: role="{{dynamicRole}}"
977                        let frag = WebUIFragment {
978                            fragment: Some(web_ui_fragment::Fragment::Attribute(
979                                WebUIFragmentAttribute {
980                                    name: attr_name,
981                                    value: signal_name,
982                                    attr_skip: true,
983                                    ..Default::default()
984                                },
985                            )),
986                        };
987                        self.add_fragment(frag, fragments);
988                        binding_count += 1;
989                    } else if Self::contains_handlebars(val) {
990                        // Embedded binding: aria-labelledby="prefix-{{group.id}}"
991                        let template_id = self.id_counter.next_id("attr");
992                        let parsed = self.handlebars_parser.parse(val)?;
993                        self.fragment_records
994                            .insert(template_id.clone(), FragmentList { fragments: parsed });
995                        let frag = WebUIFragment {
996                            fragment: Some(web_ui_fragment::Fragment::Attribute(
997                                WebUIFragmentAttribute {
998                                    name: attr_name,
999                                    template: template_id,
1000                                    attr_skip: true,
1001                                    ..Default::default()
1002                                },
1003                            )),
1004                        };
1005                        self.add_fragment(frag, fragments);
1006                        binding_count += 1;
1007                    } else {
1008                        // Static value: role="list"
1009                        let frag = WebUIFragment {
1010                            fragment: Some(web_ui_fragment::Fragment::Attribute(
1011                                WebUIFragmentAttribute {
1012                                    name: attr_name,
1013                                    value: val.clone(),
1014                                    raw_value: true,
1015                                    attr_skip: true,
1016                                    ..Default::default()
1017                                },
1018                            )),
1019                        };
1020                        self.add_fragment(frag, fragments);
1021                    }
1022                }
1023            } else if let Some(ref val) = attr_value {
1024                if Self::contains_handlebars(val) {
1025                    if is_component {
1026                        if let Some(signal_name) = Self::extract_single_handlebars(val) {
1027                            let frag = Self::maybe_mark_attr_start(
1028                                WebUIFragment::attribute(&attr_name, signal_name),
1029                                &mut first_dynamic_emitted,
1030                            );
1031                            self.add_fragment(frag, fragments);
1032                            binding_count += 1;
1033                        } else {
1034                            let template_id = self.id_counter.next_id("attr");
1035                            let parsed = self.handlebars_parser.parse(val)?;
1036                            self.fragment_records
1037                                .insert(template_id.clone(), FragmentList { fragments: parsed });
1038                            let frag = Self::maybe_mark_attr_start(
1039                                WebUIFragment::attribute_template(&attr_name, template_id),
1040                                &mut first_dynamic_emitted,
1041                            );
1042                            self.add_fragment(frag, fragments);
1043                            binding_count += 1;
1044                        }
1045                    } else {
1046                        self.process_dynamic_attribute(&attr_name, val, fragments)?;
1047                        binding_count += 1;
1048                    }
1049                } else if is_component {
1050                    // Static attribute on component → rawValue fragment.
1051                    // Not counted as a binding attribute.
1052                    let frag = Self::maybe_mark_attr_start(
1053                        WebUIFragment {
1054                            fragment: Some(web_ui_fragment::Fragment::Attribute(
1055                                WebUIFragmentAttribute {
1056                                    name: attr_name,
1057                                    value: val.clone(),
1058                                    raw_value: true,
1059                                    ..Default::default()
1060                                },
1061                            )),
1062                        },
1063                        &mut first_dynamic_emitted,
1064                    );
1065                    self.add_fragment(frag, fragments);
1066                } else {
1067                    // Static attribute on regular element → raw text
1068                    let attr_text = &source[child.start_byte()..child.end_byte()];
1069                    self.add_raw_fragment(&format!(" {}", attr_text));
1070                }
1071            } else {
1072                // Attribute without value (e.g., "disabled") — emit as raw
1073                self.add_raw_fragment(&format!(" {}", attr_name));
1074            }
1075        }
1076        Ok(binding_count)
1077    }
1078
1079    /// Set `attr_start = true` on the first non-skipped attribute fragment for
1080    /// a component element.
1081    fn maybe_mark_attr_start(
1082        mut frag: WebUIFragment,
1083        first_dynamic_emitted: &mut bool,
1084    ) -> WebUIFragment {
1085        if !*first_dynamic_emitted {
1086            if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
1087                a.attr_start = true;
1088            }
1089            *first_dynamic_emitted = true;
1090        }
1091        frag
1092    }
1093
1094    /// Extract the attribute name from an attribute node.
1095    fn get_attr_name(&self, attr_node: Node, source: &str) -> Result<String> {
1096        let mut cursor = attr_node.walk();
1097        for child in attr_node.children(&mut cursor) {
1098            if child.kind() == "attribute_name" {
1099                return Ok(source[child.start_byte()..child.end_byte()].to_string());
1100            }
1101        }
1102        Err(ParserError::Html(
1103            "Attribute node missing attribute_name".to_string(),
1104        ))
1105    }
1106
1107    /// Extract the attribute value from an attribute node (handles both quoted and
1108    /// unquoted forms). Returns None if there is no value.
1109    fn get_attr_value(&self, attr_node: Node, source: &str) -> Option<String> {
1110        let mut cursor = attr_node.walk();
1111        for child in attr_node.children(&mut cursor) {
1112            match child.kind() {
1113                "quoted_attribute_value" => {
1114                    // Find the inner attribute_value node
1115                    let mut inner_cursor = child.walk();
1116                    for inner in child.children(&mut inner_cursor) {
1117                        if inner.kind() == "attribute_value" {
1118                            return Some(source[inner.start_byte()..inner.end_byte()].to_string());
1119                        }
1120                    }
1121                    // Quoted but empty value — return empty string
1122                    return Some(String::new());
1123                }
1124                "attribute_value" => {
1125                    return Some(source[child.start_byte()..child.end_byte()].to_string());
1126                }
1127                _ => {}
1128            }
1129        }
1130        None
1131    }
1132
1133    /// Process a boolean attribute (?prefix). Silently drops if value is not a
1134    /// pure handlebars expression.
1135    fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
1136        if let Some(val) = value {
1137            if let Some(expr_str) = Self::extract_single_handlebars(val) {
1138                return Some(
1139                    self.condition_parser
1140                        .parse(&expr_str)
1141                        .unwrap_or_else(|_| ConditionExpr::identifier(&expr_str)),
1142                );
1143            }
1144        }
1145
1146        None
1147    }
1148
1149    fn process_boolean_attribute(
1150        &mut self,
1151        name: &str,
1152        value: Option<&str>,
1153        fragments: &mut Vec<WebUIFragment>,
1154    ) -> Result<bool> {
1155        if let Some(condition) = self.parse_boolean_condition(value) {
1156            self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
1157            return Ok(true);
1158        }
1159        // Invalid boolean attribute — silently drop (no output at all)
1160        Ok(false)
1161    }
1162
1163    /// Process a dynamic attribute (regular name with handlebars in value).
1164    fn process_dynamic_attribute(
1165        &mut self,
1166        name: &str,
1167        value: &str,
1168        fragments: &mut Vec<WebUIFragment>,
1169    ) -> Result<()> {
1170        if let Some(signal_name) = Self::extract_single_handlebars(value) {
1171            // Pure handlebars — simple attribute fragment
1172            self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
1173        } else {
1174            // Mixed static + dynamic — create a template sub-stream
1175            let template_id = self.id_counter.next_id("attr");
1176            let parsed = self.handlebars_parser.parse(value)?;
1177
1178            self.fragment_records
1179                .insert(template_id.clone(), FragmentList { fragments: parsed });
1180
1181            self.add_fragment(
1182                WebUIFragment::attribute_template(name, template_id),
1183                fragments,
1184            );
1185        }
1186        Ok(())
1187    }
1188
1189    /// Process a `<head>` element, injecting a head_end signal before `</head>`.
1190    fn process_head_element(
1191        &mut self,
1192        node: Node,
1193        source: &str,
1194        fragments: &mut Vec<WebUIFragment>,
1195    ) -> Result<()> {
1196        self.add_raw_fragment("<head>");
1197        self.process_content_children(node, source, fragments)?;
1198        self.flush_raw_buffer(fragments);
1199        fragments.push(WebUIFragment::signal("head_end", true));
1200        self.add_raw_fragment("</head>");
1201        Ok(())
1202    }
1203
1204    /// Process a `<body>` element, injecting body_start/body_end signals.
1205    fn process_body_element(
1206        &mut self,
1207        node: Node,
1208        source: &str,
1209        fragments: &mut Vec<WebUIFragment>,
1210    ) -> Result<()> {
1211        let tag_node = self.find_tag_node(node);
1212        if let Some(tag_node) = tag_node {
1213            self.add_raw_fragment("<body");
1214            let binding_count = self.process_tag_attributes(tag_node, source, fragments, false)?;
1215            if let Some(ref mut p) = self.plugin {
1216                if let Some(data) = p.finish_element(binding_count) {
1217                    self.add_fragment(WebUIFragment::plugin(data), fragments);
1218                }
1219            }
1220            self.add_raw_fragment(">");
1221        } else {
1222            self.add_raw_fragment("<body>");
1223        }
1224        self.flush_raw_buffer(fragments);
1225        fragments.push(WebUIFragment::signal("body_start", true));
1226        self.process_content_children(node, source, fragments)?;
1227        self.flush_raw_buffer(fragments);
1228        fragments.push(WebUIFragment::signal("body_end", true));
1229        self.add_raw_fragment("</body>");
1230        Ok(())
1231    }
1232
1233    /// Process an HTML comment node.
1234    ///
1235    /// If the comment contains handlebars expressions (e.g., `<!--{{tokens}}-->`),
1236    /// parse them as signal fragments. Otherwise, preserve the comment as raw content.
1237    fn process_comment(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
1238        // Strip <!-- and --> delimiters to get the inner text
1239        let inner = content
1240            .strip_prefix("<!--")
1241            .and_then(|s| s.strip_suffix("-->"))
1242            .unwrap_or("");
1243
1244        let trimmed = inner.trim();
1245
1246        // Quick check: if no handlebars syntax, preserve as raw comment
1247        if !trimmed.contains("{{") {
1248            self.add_raw_fragment(content);
1249            return Ok(());
1250        }
1251
1252        // Parse the inner text through the handlebars parser
1253        let parsed = self.handlebars_parser.parse(trimmed)?;
1254
1255        // Check if the result is *only* signal fragments (no raw text mixed in).
1256        // If all fragments are signals, emit them without comment delimiters.
1257        // If there's any raw text mixed in, preserve the whole comment as-is.
1258        let all_signals = parsed
1259            .iter()
1260            .all(|f| matches!(f.fragment.as_ref(), Some(Fragment::Signal(_))));
1261
1262        if all_signals && !parsed.is_empty() {
1263            for fragment in parsed {
1264                self.add_fragment(fragment, fragments);
1265            }
1266        } else {
1267            // Mixed content or parse result has raw parts — keep as raw comment
1268            self.add_raw_fragment(content);
1269        }
1270
1271        Ok(())
1272    }
1273
1274    /// Process a <for> directive.
1275    fn process_for_directive(
1276        &mut self,
1277        node: Node,
1278        source: &str,
1279        fragments: &mut Vec<WebUIFragment>,
1280    ) -> Result<()> {
1281        // Extract each attribute
1282        let each = self
1283            .get_element_attribute(node, "each", source)?
1284            .ok_or_else(|| {
1285                ParserError::Directive("Missing 'each' attribute on <for>".to_string())
1286            })?;
1287
1288        // Split the each by whitespace.
1289        let parts: Vec<&str> = each.split_whitespace().collect();
1290        if parts.len() != 3 || parts[1] != "in" {
1291            return Err(ParserError::Directive(format!(
1292                "Invalid for each: {}",
1293                each
1294            )));
1295        }
1296
1297        // Check that the first and third part contain only allowed characters.
1298        let allowed = |s: &str| {
1299            s.chars()
1300                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1301        };
1302        if !allowed(parts[0]) || !allowed(parts[2]) {
1303            return Err(ParserError::Directive(format!(
1304                "Invalid identifier in for each: {}",
1305                each
1306            )));
1307        }
1308
1309        let item = parts[0];
1310        let collection = parts[2];
1311
1312        // Use custom template attribute if provided, otherwise auto-generate
1313        let fragment_id = self
1314            .get_element_attribute(node, "template", source)?
1315            .unwrap_or_else(|| self.id_counter.next_id("for"));
1316        let mut for_fragment: Vec<WebUIFragment> = Vec::new();
1317
1318        // Create a temporary buffer for for loop content
1319        let mut temp_buffer = String::new();
1320        std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1321
1322        // Process the for loop body
1323        self.process_content_children(node, source, &mut for_fragment)?;
1324
1325        // Ensure any remaining content is flushed to the for loop's fragment
1326        self.flush_raw_buffer(&mut for_fragment);
1327
1328        // Restore the original buffer
1329        std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1330
1331        // Skip the for fragment entirely if the body is empty
1332        if for_fragment.is_empty() {
1333            return Ok(());
1334        }
1335
1336        // Store the record
1337        self.fragment_records.insert(
1338            fragment_id.clone(),
1339            FragmentList {
1340                fragments: for_fragment,
1341            },
1342        );
1343
1344        // Add the for directive fragment to the parent fragment
1345        self.add_for_fragment(
1346            item.to_string(),
1347            collection.to_string(),
1348            fragment_id,
1349            fragments,
1350        );
1351
1352        Ok(())
1353    }
1354
1355    /// Process an <if> directive.
1356    fn process_if_directive(
1357        &mut self,
1358        node: Node,
1359        source: &str,
1360        fragments: &mut Vec<WebUIFragment>,
1361    ) -> Result<()> {
1362        // Extract condition attribute
1363        let condition_str = self
1364            .get_element_attribute(node, "condition", source)?
1365            .ok_or_else(|| {
1366                ParserError::Directive("Missing 'condition' attribute on <if>".to_string())
1367            })?;
1368
1369        // Parse condition into a ConditionExpr
1370        let condition_result = self.condition_parser.parse(&condition_str);
1371        let condition = match condition_result {
1372            Ok(cond) => cond,
1373            Err(_) => {
1374                return Err(ParserError::Directive(format!(
1375                    "Invalid condition expression: {}",
1376                    condition_str
1377                )))
1378            }
1379        };
1380
1381        // Generate a unique fragment ID for the if content
1382        let fragment_id = self.id_counter.next_id("if");
1383
1384        // Flush any existing content in the parent fragment before switching context
1385        self.flush_raw_buffer(fragments);
1386
1387        // Create a separate fragment for the if condition content
1388        let mut if_fragment: Vec<WebUIFragment> = Vec::new();
1389
1390        // Save the current raw buffer and create a new one for the if condition
1391        let parent_buffer = std::mem::take(&mut self.raw_buffer);
1392
1393        // Process the if body - capture all content including closing tags
1394        self.process_content_children(node, source, &mut if_fragment)?;
1395
1396        // Make sure all content in the if buffer is flushed to the if fragment
1397        self.flush_raw_buffer(&mut if_fragment);
1398
1399        // Store the if fragment in the records
1400        self.fragment_records.insert(
1401            fragment_id.clone(),
1402            FragmentList {
1403                fragments: if_fragment,
1404            },
1405        );
1406
1407        // Restore the parent buffer - only after we've processed all if content
1408        self.raw_buffer = parent_buffer;
1409
1410        // Add the if directive to the parent fragment
1411        self.add_if_fragment(condition, fragment_id, fragments);
1412
1413        Ok(())
1414    }
1415
1416    /// Process a `<route>` directive.
1417    ///
1418    /// Emits a `Fragment::Route` protocol fragment. The handler renders
1419    /// `<webui-route>` elements with server-side route matching — matched
1420    /// routes get `active` + component content, non-matched get `display:none`.
1421    fn process_route_directive(
1422        &mut self,
1423        node: Node,
1424        source: &str,
1425        fragments: &mut Vec<WebUIFragment>,
1426    ) -> Result<()> {
1427        // Extract route attributes
1428        let path = self
1429            .get_element_attribute(node, "path", source)?
1430            .unwrap_or_default();
1431
1432        let component = self
1433            .get_element_attribute(node, "component", source)?
1434            .unwrap_or_default();
1435
1436        let exact = self.has_element_attribute(node, "exact", source)?;
1437
1438        let query = self
1439            .get_element_attribute(node, "query", source)?
1440            .unwrap_or_default();
1441
1442        let attrs = route_parser::RouteAttributes {
1443            path: path.clone(),
1444            component: component.clone(),
1445            exact,
1446            query,
1447            keep_alive: self.has_element_attribute(node, "keep-alive", source)?,
1448            cache_tags: self
1449                .get_element_attribute(node, "cache-tags", source)?
1450                .map(|v| route_parser::parse_tag_list(&v))
1451                .unwrap_or_default(),
1452            invalidates: self
1453                .get_element_attribute(node, "invalidates", source)?
1454                .map(|v| route_parser::parse_tag_list(&v))
1455                .unwrap_or_default(),
1456            pending_component: self
1457                .get_element_attribute(node, "pending", source)?
1458                .unwrap_or_default(),
1459            error_component: self
1460                .get_element_attribute(node, "error", source)?
1461                .unwrap_or_default(),
1462        };
1463
1464        // Validate attributes (component is required)
1465        route_parser::validate_attributes(&attrs)?;
1466
1467        // Extract params from path template and validate
1468        let route_params: std::collections::HashSet<String> =
1469            route_parser::extract_params(&path)?.into_iter().collect();
1470        if !attrs.cache_tags.is_empty() {
1471            route_parser::validate_tag_placeholders(
1472                &attrs.cache_tags,
1473                &route_params,
1474                "cache-tags",
1475                &path,
1476            )?;
1477        }
1478        if !attrs.invalidates.is_empty() {
1479            route_parser::validate_tag_placeholders(
1480                &attrs.invalidates,
1481                &route_params,
1482                "invalidates",
1483                &path,
1484            )?;
1485        }
1486
1487        // Validate pending component exists in the registry
1488        if !attrs.pending_component.is_empty() {
1489            self.ensure_route_component_parsed(&attrs.pending_component)?;
1490        }
1491        // Validate error component exists in the registry
1492        if !attrs.error_component.is_empty() {
1493            self.ensure_route_component_parsed(&attrs.error_component)?;
1494        }
1495
1496        // Ensure the component's template is parsed and registered
1497        self.ensure_route_component_parsed(&component)?;
1498
1499        // Recursively parse nested <route> children, passing accumulated params
1500        let mut all_params = std::collections::HashSet::new();
1501        all_params.extend(route_params);
1502        let children = self.parse_child_routes(node, source, &all_params)?;
1503
1504        // Flush any pending raw content before the route fragment
1505        self.flush_raw_buffer(fragments);
1506
1507        // Build route fragment with children
1508        let route_fragment =
1509            route_parser::build_route_fragment(&attrs, component.clone(), children);
1510
1511        // Emit Fragment::Route — the handler renders it as <webui-route>
1512        fragments.push(WebUIFragment::route_from(route_fragment));
1513
1514        Ok(())
1515    }
1516
1517    /// Parse nested `<route>` children of a route element.
1518    fn parse_child_routes(
1519        &mut self,
1520        node: Node,
1521        source: &str,
1522        ancestor_params: &std::collections::HashSet<String>,
1523    ) -> Result<Vec<WebUiFragmentRoute>> {
1524        let mut children = Vec::new();
1525        let mut cursor = node.walk();
1526
1527        for child in node.named_children(&mut cursor) {
1528            if child.kind() == "element" {
1529                if let Ok(tag) = self.get_element_tag_name(child, source) {
1530                    if tag == "route" {
1531                        let child_route =
1532                            self.parse_route_as_fragment(child, source, ancestor_params)?;
1533                        children.push(child_route);
1534                    }
1535                }
1536            }
1537        }
1538
1539        Ok(children)
1540    }
1541
1542    /// Parse a `<route>` element into a `WebUiFragmentRoute` (for nesting).
1543    fn parse_route_as_fragment(
1544        &mut self,
1545        node: Node,
1546        source: &str,
1547        ancestor_params: &std::collections::HashSet<String>,
1548    ) -> Result<WebUiFragmentRoute> {
1549        let path = self
1550            .get_element_attribute(node, "path", source)?
1551            .unwrap_or_default();
1552        let component = self
1553            .get_element_attribute(node, "component", source)?
1554            .unwrap_or_default();
1555        let exact = self.has_element_attribute(node, "exact", source)?;
1556
1557        let query = self
1558            .get_element_attribute(node, "query", source)?
1559            .unwrap_or_default();
1560
1561        let attrs = route_parser::RouteAttributes {
1562            path: path.clone(),
1563            component: component.clone(),
1564            exact,
1565            query,
1566            keep_alive: self.has_element_attribute(node, "keep-alive", source)?,
1567            cache_tags: self
1568                .get_element_attribute(node, "cache-tags", source)?
1569                .map(|v| route_parser::parse_tag_list(&v))
1570                .unwrap_or_default(),
1571            invalidates: self
1572                .get_element_attribute(node, "invalidates", source)?
1573                .map(|v| route_parser::parse_tag_list(&v))
1574                .unwrap_or_default(),
1575            pending_component: self
1576                .get_element_attribute(node, "pending", source)?
1577                .unwrap_or_default(),
1578            error_component: self
1579                .get_element_attribute(node, "error", source)?
1580                .unwrap_or_default(),
1581        };
1582
1583        route_parser::validate_attributes(&attrs)?;
1584
1585        // Extract params from path template and validate placeholders
1586        // against both this route's own params AND ancestor params
1587        let own_params: std::collections::HashSet<String> =
1588            route_parser::extract_params(&path)?.into_iter().collect();
1589        let mut all_params = ancestor_params.clone();
1590        all_params.extend(own_params.iter().cloned());
1591        if !attrs.cache_tags.is_empty() {
1592            route_parser::validate_tag_placeholders(
1593                &attrs.cache_tags,
1594                &all_params,
1595                "cache-tags",
1596                &path,
1597            )?;
1598        }
1599        if !attrs.invalidates.is_empty() {
1600            route_parser::validate_tag_placeholders(
1601                &attrs.invalidates,
1602                &all_params,
1603                "invalidates",
1604                &path,
1605            )?;
1606        }
1607
1608        // Validate pending/error components exist
1609        if !attrs.pending_component.is_empty() {
1610            self.ensure_route_component_parsed(&attrs.pending_component)?;
1611        }
1612        if !attrs.error_component.is_empty() {
1613            self.ensure_route_component_parsed(&attrs.error_component)?;
1614        }
1615
1616        // Ensure the component's template is parsed
1617        self.ensure_route_component_parsed(&component)?;
1618
1619        // Recursively parse nested children, passing accumulated params
1620        let children = self.parse_child_routes(node, source, &all_params)?;
1621
1622        Ok(route_parser::build_route_fragment(
1623            &attrs, component, children,
1624        ))
1625    }
1626
1627    /// Ensure a route-referenced component is parsed and registered.
1628    fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
1629        if component.is_empty()
1630            || !self.component_registry.contains(component)
1631            || self.fragment_records.contains_key(component)
1632        {
1633            return Ok(());
1634        }
1635
1636        let component_data = self
1637            .component_registry
1638            .get(component)
1639            .ok_or_else(|| {
1640                crate::error::ParserError::Directive(format!("Component not found: {component}"))
1641            })?
1642            .clone();
1643
1644        self.token_store
1645            .extend(component_data.css_tokens.iter().cloned());
1646
1647        let processed = self.build_component_template(
1648            component,
1649            &component_data.html_content,
1650            component_data.css_content.as_deref(),
1651        );
1652
1653        if let Some(ref mut p) = self.plugin {
1654            p.register_component_template(component, &component_data, &processed)?;
1655        }
1656
1657        let saved_buffer = std::mem::take(&mut self.raw_buffer);
1658        self.parse(component, &processed)?;
1659        self.raw_buffer = saved_buffer;
1660
1661        Ok(())
1662    }
1663
1664    /// Skipped attribute names for components.
1665    const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
1666    /// Skipped attribute prefixes for components.
1667    const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
1668
1669    fn is_skipped_attribute(name: &str) -> bool {
1670        Self::SKIPPED_ATTRIBUTES.contains(&name)
1671            || Self::SKIPPED_ATTRIBUTE_PREFIXES
1672                .iter()
1673                .any(|prefix| name.starts_with(prefix))
1674    }
1675
1676    /// Properties that must never be set via `:attr` complex bindings.
1677    /// These enable XSS (HTML injection) or arbitrary code execution.
1678    fn is_blocked_complex_property(name: &str) -> bool {
1679        matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
1680    }
1681
1682    fn process_component_directive(
1683        &mut self,
1684        node: Node,
1685        source: &str,
1686        fragments: &mut Vec<WebUIFragment>,
1687        tag_name: &str,
1688    ) -> Result<()> {
1689        let tag_node = self.find_tag_node(node);
1690        let is_self_closing = tag_node
1691            .map(|n| n.kind() == "self_closing_tag")
1692            .unwrap_or(false);
1693
1694        // Emit "<tagname"
1695        self.add_raw_fragment(&format!("<{}", tag_name));
1696
1697        // Process attributes — component-aware
1698        if let Some(tag_node) = tag_node {
1699            let binding_count = self.process_tag_attributes(tag_node, source, fragments, true)?;
1700            if let Some(ref mut p) = self.plugin {
1701                if let Some(data) = p.finish_element(binding_count) {
1702                    self.add_fragment(WebUIFragment::plugin(data), fragments);
1703                }
1704            }
1705        }
1706
1707        if is_self_closing {
1708            let tag_text = tag_node
1709                .map(|n| &source[n.start_byte()..n.end_byte()])
1710                .unwrap_or("");
1711            if tag_text.ends_with("/>") {
1712                self.add_raw_fragment("/>");
1713            } else {
1714                self.add_raw_fragment(">");
1715            }
1716        } else {
1717            self.add_raw_fragment(">");
1718        }
1719
1720        // Flush before component fragment
1721        self.flush_raw_buffer(fragments);
1722
1723        // Get component data
1724        let (html_content, css_content, css_tokens) = {
1725            let component = self.component_registry.get(tag_name).ok_or_else(|| {
1726                ParserError::Directive(format!("Component not found: {}", tag_name))
1727            })?;
1728            (
1729                component.html_content.clone(),
1730                component.css_content.clone(),
1731                component.css_tokens.clone(),
1732            )
1733        };
1734
1735        // Merge component CSS tokens into the global token store
1736        self.token_store.extend(css_tokens);
1737
1738        // Parse and register component template if not already done
1739        if !self.fragment_records.contains_key(tag_name) {
1740            let component_data = self
1741                .component_registry
1742                .get(tag_name)
1743                .ok_or_else(|| {
1744                    ParserError::Directive(format!("Component not found: {}", tag_name))
1745                })?
1746                .clone();
1747            let processed =
1748                self.build_component_template(tag_name, &html_content, css_content.as_deref());
1749
1750            // Notify plugin about the final component template (only on first encounter)
1751            if let Some(ref mut p) = self.plugin {
1752                p.register_component_template(tag_name, &component_data, &processed)?;
1753            }
1754
1755            self.parse(tag_name, &processed)?;
1756        }
1757
1758        // Emit component fragment
1759        fragments.push(WebUIFragment::component(tag_name.to_string()));
1760
1761        // Process slot content (skip start_tag/end_tag/self_closing_tag)
1762        if !is_self_closing {
1763            self.process_content_children(node, source, fragments)?;
1764        }
1765
1766        // Emit closing tag
1767        if !is_self_closing {
1768            self.add_raw_fragment(&format!("</{}>", tag_name));
1769        }
1770
1771        Ok(())
1772    }
1773
1774    /// Process component template HTML: wrap in shadow DOM template if needed,
1775    /// inject CSS snippet (link or inline style), and strip runtime-only attributes.
1776    /// When `adopted_specifier` is `Some`, it is stored in template metadata
1777    /// for the client runtime to handle document-level CSS adoption.
1778    fn build_component_template(
1779        &mut self,
1780        tag_name: &str,
1781        html: &str,
1782        css_content: Option<&str>,
1783    ) -> String {
1784        let adopted_specifier = match self.css_strategy {
1785            CssStrategy::Module if css_content.is_some() => Some(tag_name.to_string()),
1786            _ => None,
1787        };
1788        let css_injection = match self.css_strategy {
1789            CssStrategy::Link => {
1790                // In light DOM mode, CSS links go in <head> (emitted by handler),
1791                // not inside each component template.
1792                if css_content.is_some() && self.dom_strategy == DomStrategy::Shadow {
1793                    Some(format!("<link rel=\"stylesheet\" href=\"{tag_name}.css\">"))
1794                } else {
1795                    None
1796                }
1797            }
1798            CssStrategy::Style => css_content.map(|css| format!("<style>{}</style>", css.trim())),
1799            CssStrategy::Module => None,
1800        };
1801
1802        self.process_component_template(
1803            html,
1804            css_injection.as_deref(),
1805            adopted_specifier.as_deref(),
1806        )
1807    }
1808
1809    /// Process component template HTML.
1810    ///
1811    /// - **Shadow DOM** (`DomStrategy::Shadow`): wraps content in
1812    ///   `<template shadowrootmode="open">`, preserves `:host` CSS,
1813    ///   optionally adds `shadowrootadoptedstylesheets`.
1814    /// - **Light DOM** (`DomStrategy::Light`): strips any existing shadow DOM
1815    ///   wrapper, outputs plain HTML.
1816    ///
1817    /// In both modes, runtime-only attributes (`@`, `:`, `?`) are stripped
1818    /// from the opening `<template>` tag.
1819    fn process_component_template(
1820        &mut self,
1821        html: &str,
1822        css_snippet: Option<&str>,
1823        adopted_specifier: Option<&str>,
1824    ) -> String {
1825        let trimmed = html.trim();
1826        let snippet = css_snippet.unwrap_or_default();
1827
1828        // Extract inner content — strip <template shadowrootmode> wrapper if present
1829        let has_template = trimmed.starts_with("<template");
1830        let inner = if has_template {
1831            let stripped = self.strip_runtime_attrs_from_template(trimmed);
1832            if let Some(open_end) = stripped.find('>') {
1833                let inner_start = open_end + 1;
1834                let inner_end = stripped.rfind("</template>").unwrap_or(stripped.len());
1835                if inner_start < inner_end {
1836                    stripped[inner_start..inner_end].to_string()
1837                } else {
1838                    String::new()
1839                }
1840            } else {
1841                trimmed.to_string()
1842            }
1843        } else {
1844            trimmed.to_string()
1845        };
1846
1847        match self.dom_strategy {
1848            DomStrategy::Shadow => {
1849                // Re-wrap in shadow DOM template
1850                let adopted_attr = adopted_specifier.map(|spec| {
1851                    let mut s = String::with_capacity(35 + spec.len());
1852                    s.push_str(" shadowrootadoptedstylesheets=\"");
1853                    s.push_str(spec);
1854                    s.push('"');
1855                    s
1856                });
1857                let adopted_ref = adopted_attr.as_deref().unwrap_or_default();
1858
1859                let mut result =
1860                    String::with_capacity(45 + adopted_ref.len() + snippet.len() + inner.len());
1861                result.push_str("<template shadowrootmode=\"open\"");
1862                result.push_str(adopted_ref);
1863                result.push('>');
1864                result.push_str(snippet);
1865                result.push_str(&inner);
1866                result.push_str("</template>");
1867                result
1868            }
1869            DomStrategy::Light => {
1870                // Plain light-DOM output
1871                if snippet.is_empty() && adopted_specifier.is_none() {
1872                    return inner;
1873                }
1874                let mut result = String::with_capacity(snippet.len() + inner.len() + 16);
1875                result.push_str(snippet);
1876                result.push_str(&inner);
1877                result
1878            }
1879        }
1880    }
1881
1882    /// Strip attributes starting with @, :, or ? from the opening template tag.
1883    /// Uses tree-sitter to parse the tag and reconstruct it without runtime attrs.
1884    fn strip_runtime_attrs_from_template(&mut self, html: &str) -> String {
1885        let tree = match self.parser.parse(html, None) {
1886            Some(t) => t,
1887            None => return html.to_string(),
1888        };
1889
1890        // Find the first start_tag in the tree
1891        let root = tree.root_node();
1892        let start_tag = Self::find_first_node(root, "start_tag");
1893        let Some(tag) = start_tag else {
1894            return html.to_string();
1895        };
1896
1897        // Collect byte ranges of runtime attributes to remove
1898        let mut removals: Vec<(usize, usize)> = Vec::new();
1899        let mut cursor = tag.walk();
1900        for child in tag.named_children(&mut cursor) {
1901            if child.kind() == "attribute" {
1902                let name_node = child.child(0);
1903                if let Some(name) = name_node {
1904                    let attr_name = &html[name.start_byte()..name.end_byte()];
1905                    if attr_name.starts_with('@')
1906                        || attr_name.starts_with(':')
1907                        || attr_name.starts_with('?')
1908                    {
1909                        // Remove the attribute and any leading whitespace
1910                        let mut start = child.start_byte();
1911                        while start > 0 && html.as_bytes()[start - 1] == b' ' {
1912                            start -= 1;
1913                        }
1914                        removals.push((start, child.end_byte()));
1915                    }
1916                }
1917            }
1918        }
1919
1920        if removals.is_empty() {
1921            return html.to_string();
1922        }
1923
1924        // Rebuild the string, skipping removed ranges
1925        let mut result = String::with_capacity(html.len());
1926        let mut pos = 0;
1927        for (start, end) in &removals {
1928            result.push_str(&html[pos..*start]);
1929            pos = *end;
1930        }
1931        result.push_str(&html[pos..]);
1932        result
1933    }
1934
1935    /// Find the first node of a given kind in the tree (iterative BFS).
1936    fn find_first_node<'a>(root: Node<'a>, kind: &str) -> Option<Node<'a>> {
1937        let mut stack = vec![root];
1938        while let Some(node) = stack.pop() {
1939            if node.kind() == kind {
1940                return Some(node);
1941            }
1942            let mut cursor = node.walk();
1943            // Collect is required: cursor is mutably borrowed by the iterator,
1944            // so we must collect before reversing to push children in DFS order.
1945            #[allow(clippy::needless_collect)]
1946            let children: Vec<_> = node.children(&mut cursor).collect();
1947            for child in children.into_iter().rev() {
1948                stack.push(child);
1949            }
1950        }
1951        None
1952    }
1953}
1954
1955#[cfg(test)]
1956mod tests {
1957    use webui_test_utils::*;
1958
1959    use super::*;
1960
1961    #[test]
1962    fn test_plugin_display_names() {
1963        assert_eq!(Plugin::Fast.to_string(), "fast");
1964        assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
1965        assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
1966        assert_eq!(Plugin::WebUI.to_string(), "webui");
1967    }
1968
1969    #[test]
1970    fn test_plugin_from_str_names() {
1971        assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
1972        assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
1973        assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
1974        assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
1975    }
1976
1977    #[test]
1978    fn test_plugin_from_str_unknown_mentions_supported_names() {
1979        let err = "legacy".parse::<Plugin>().unwrap_err();
1980        assert!(
1981            err.contains("fast-v3"),
1982            "error should mention fast-v3: {err}"
1983        );
1984        assert!(
1985            err.contains("fast-v2"),
1986            "error should mention fast-v2: {err}"
1987        );
1988        assert!(err.contains("fast"), "error should mention fast: {err}");
1989    }
1990
1991    #[test]
1992    fn test_parse_signal() {
1993        let mut parser = HtmlParser::new();
1994        let html = "Hello, {{name}}!";
1995        let result = parser.parse("test.html", html);
1996
1997        assert!(result.is_ok());
1998        let fragment_records = parser.into_fragment_records();
1999
2000        assert_stream!(
2001            fragment_records,
2002            "test.html",
2003            [raw("Hello, "), signal("name"), raw("!"),]
2004        );
2005    }
2006
2007    #[test]
2008    fn test_parse_raw_signal() {
2009        let mut parser = HtmlParser::new();
2010        let html = "Hello, {{{html_content}}}!";
2011        let result = parser.parse("test.html", html);
2012
2013        assert!(result.is_ok());
2014        let fragment_records = parser.into_fragment_records();
2015
2016        assert_stream!(
2017            fragment_records,
2018            "test.html",
2019            [raw("Hello, "), signal_raw("html_content"), raw("!"),]
2020        );
2021    }
2022
2023    #[test]
2024    fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
2025        let mut parser = HtmlParser::new();
2026        let html = "<nav>{{sectionName}} &gt; {{topicName}}</nav>";
2027        let result = parser.parse("test.html", html);
2028
2029        assert!(result.is_ok(), "Parse error: {:?}", result.err());
2030        let fragment_records = parser.into_fragment_records();
2031
2032        assert_stream!(
2033            fragment_records,
2034            "test.html",
2035            [
2036                raw("<nav>"),
2037                signal("sectionName"),
2038                raw(" &gt; "),
2039                signal("topicName"),
2040                raw("</nav>"),
2041            ]
2042        );
2043    }
2044
2045    #[test]
2046    fn test_parse_for_directive() {
2047        let mut parser = HtmlParser::new();
2048        let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
2049
2050        let result = parser.parse("test.html", html);
2051        assert!(result.is_ok(), "Parse error: {:?}", result.err());
2052        let fragment_records = parser.into_fragment_records();
2053        println!("Fragment records: {:#?}", fragment_records);
2054
2055        assert_stream!(
2056            fragment_records,
2057            "test.html",
2058            [for_loop("item", "items", "for-1"),]
2059        );
2060
2061        // Verify the sub-fragment contains our item content
2062        assert_stream!(
2063            fragment_records,
2064            "for-1",
2065            [
2066                raw("<div class=\"item\">"),
2067                signal("item.name"),
2068                raw("</div>"),
2069            ]
2070        );
2071    }
2072
2073    #[test]
2074    fn test_parse_if_directive() {
2075        let mut parser = HtmlParser::new();
2076        let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
2077
2078        let result = parser.parse("test.html", html);
2079
2080        assert!(result.is_ok(), "Parse error: {:?}", result.err());
2081        let fragment_records = parser.into_fragment_records();
2082        println!("Fragment records: {:#?}", fragment_records);
2083
2084        assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
2085
2086        // Verify the sub-fragment contains our content
2087        assert_stream!(
2088            fragment_records,
2089            "if-1",
2090            [
2091                raw("<div>Welcome back, "),
2092                signal("username"),
2093                raw("!</div>"),
2094            ]
2095        );
2096    }
2097
2098    #[test]
2099    fn test_component_directive() {
2100        let mut parser = HtmlParser::new();
2101        parser.set_dom_strategy(DomStrategy::Light);
2102        parser
2103            .component_registry
2104            .register_component(
2105                "my-component",
2106                "<div>My Component</div>",
2107                Some("div { color: blue; }"),
2108            )
2109            .expect("Failed to register component");
2110
2111        let result = parser.parse("test.html", "<my-component></my-component>");
2112        assert!(result.is_ok(), "Parse error: {:?}", result.err());
2113        let records = parser.into_fragment_records();
2114
2115        assert_stream!(
2116            records,
2117            "test.html",
2118            [
2119                raw("<my-component>"),
2120                component("my-component"),
2121                raw("</my-component>"),
2122            ]
2123        );
2124
2125        // Component template stream should contain the component content (no shadow DOM wrapper)
2126        let comp = &records["my-component"].fragments;
2127        assert_eq!(comp.len(), 1);
2128        assert!(
2129            matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
2130                !raw.value.contains("<template shadowrootmode") && raw.value.contains("<div>My Component</div>"))
2131        );
2132    }
2133
2134    #[test]
2135    fn test_component_directive_with_slots() {
2136        let mut parser = HtmlParser::new();
2137        parser
2138            .component_registry
2139            .register_component(
2140                "my-component",
2141                "<div>My Component</div>",
2142                Some("div { color: blue; }"),
2143            )
2144            .expect("Failed to register component");
2145
2146        let result = parser.parse(
2147            "test.html",
2148            "Hello<my-component><p>World</p></my-component>",
2149        );
2150        assert!(result.is_ok(), "Parse error: {:?}", result.err());
2151        let records = parser.into_fragment_records();
2152        let fragments = &records["test.html"].fragments;
2153
2154        // Entry: raw(Hello<my-component>) + component + raw(<p>World</p></my-component>)
2155        assert!(fragments.len() >= 3);
2156        // First fragment should contain "Hello" and "<my-component>"
2157        assert!(
2158            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component>"))
2159        );
2160        // Should have component fragment
2161        assert!(fragments.iter().any(|f| matches!(
2162            f.fragment.as_ref(),
2163            Some(Fragment::Component(c)) if c.fragment_id == "my-component"
2164        )));
2165        // Should end with closing tag
2166        let last = fragments.last().unwrap();
2167        assert!(
2168            matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
2169        );
2170    }
2171
2172    #[test]
2173    fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
2174        // Reproduces commerce app: mp-app has a shadow DOM template containing
2175        // child components (mp-navbar, mp-cart-panel, mp-footer) plus an <outlet>.
2176        // The parser must emit Fragment::Component entries for ALL child components
2177        // so the inventory walk finds them.
2178        let mut parser = HtmlParser::new();
2179        parser.set_dom_strategy(DomStrategy::Shadow);
2180
2181        parser
2182            .component_registry
2183            .register_component(
2184                "app-shell",
2185                r#"<template shadowrootmode="open">
2186                  <my-navbar></my-navbar>
2187                  <main><outlet /></main>
2188                  <cart-panel></cart-panel>
2189                  <my-footer></my-footer>
2190                </template>"#,
2191                Some(":host{display:flex}"),
2192            )
2193            .expect("register app-shell");
2194        parser
2195            .component_registry
2196            .register_component("my-navbar", "<nav>Nav</nav>", Some("nav{color:red}"))
2197            .expect("register my-navbar");
2198        parser
2199            .component_registry
2200            .register_component(
2201                "cart-panel",
2202                "<aside>Cart</aside>",
2203                Some("aside{color:green}"),
2204            )
2205            .expect("register cart-panel");
2206        parser
2207            .component_registry
2208            .register_component(
2209                "my-footer",
2210                "<footer>Footer</footer>",
2211                Some("footer{color:blue}"),
2212            )
2213            .expect("register my-footer");
2214
2215        parser
2216            .parse("index.html", "<app-shell></app-shell>")
2217            .expect("parse index.html");
2218        let records = parser.into_fragment_records();
2219
2220        // app-shell's fragment list must contain Component entries for all children
2221        let app_frags = &records["app-shell"].fragments;
2222        let component_ids: Vec<&str> = app_frags
2223            .iter()
2224            .filter_map(|f| match f.fragment.as_ref() {
2225                Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
2226                _ => None,
2227            })
2228            .collect();
2229
2230        assert!(
2231            component_ids.contains(&"my-navbar"),
2232            "app-shell fragments should contain my-navbar: {component_ids:?}"
2233        );
2234        assert!(
2235            component_ids.contains(&"cart-panel"),
2236            "app-shell fragments should contain cart-panel: {component_ids:?}"
2237        );
2238        assert!(
2239            component_ids.contains(&"my-footer"),
2240            "app-shell fragments should contain my-footer: {component_ids:?}"
2241        );
2242    }
2243
2244    // ── Component template wrapping tests ────────────────────────────
2245
2246    #[test]
2247    fn test_component_no_double_wrap_template() {
2248        let mut parser = HtmlParser::new();
2249        parser.set_dom_strategy(DomStrategy::Light);
2250        parser
2251            .component_registry
2252            .register_component(
2253                "custom-element",
2254                r#"<template foo="bar"><slot></slot></template>"#,
2255                None,
2256            )
2257            .expect("register");
2258        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2259        assert!(result.is_ok());
2260        let records = parser.into_fragment_records();
2261
2262        assert_fragments!(
2263            records["index.html"].fragments,
2264            [
2265                raw("<custom-element>"),
2266                component("custom-element"),
2267                raw("Hello</custom-element>"),
2268            ]
2269        );
2270
2271        assert_stream!(records, "custom-element", [raw("<slot></slot>"),]);
2272    }
2273
2274    #[test]
2275    fn test_component_styled_no_double_wrap() {
2276        let mut parser = HtmlParser::new();
2277        parser.set_dom_strategy(DomStrategy::Light);
2278        parser
2279            .component_registry
2280            .register_component(
2281                "custom-element",
2282                r#"<template foo="bar"><slot></slot></template>"#,
2283                Some("div { color: red; }"),
2284            )
2285            .expect("register");
2286        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2287        assert!(result.is_ok());
2288        let records = parser.into_fragment_records();
2289
2290        assert_stream!(records, "custom-element", [raw(r#"<slot></slot>"#),]);
2291    }
2292
2293    #[test]
2294    fn test_component_strip_runtime_attrs() {
2295        let mut parser = HtmlParser::new();
2296        parser.set_dom_strategy(DomStrategy::Light);
2297        parser
2298            .component_registry
2299            .register_component(
2300                "custom-element",
2301                r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
2302                None,
2303            )
2304            .expect("register");
2305        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2306        assert!(result.is_ok());
2307        let records = parser.into_fragment_records();
2308
2309        assert_stream!(records, "custom-element", [raw("<slot></slot>"),]);
2310    }
2311
2312    #[test]
2313    fn test_component_with_slots_and_attrs() {
2314        let mut parser = HtmlParser::new();
2315        parser
2316            .component_registry
2317            .register_component("custom-element", "<slot></slot>", None)
2318            .expect("register");
2319        let result = parser.parse(
2320            "index.html",
2321            r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
2322        );
2323        assert!(result.is_ok());
2324        let records = parser.into_fragment_records();
2325        assert_fragments!(
2326            records["index.html"].fragments,
2327            [
2328                raw("<custom-element"),
2329                attr_raw_start("appearance", "subtle"),
2330                raw(">"),
2331                component("custom-element"),
2332                raw("Hello World</custom-element>"),
2333            ]
2334        );
2335    }
2336
2337    #[test]
2338    fn test_component_legacy_no_styles() {
2339        let mut parser = HtmlParser::new();
2340        parser.set_dom_strategy(DomStrategy::Light);
2341        parser
2342            .component_registry
2343            .register_component("custom-element", "<div>Custom Element</div>", None)
2344            .expect("register");
2345        let result = parser.parse("index.html", "<custom-element></custom-element>");
2346        assert!(result.is_ok());
2347        let records = parser.into_fragment_records();
2348
2349        assert_fragments!(
2350            records["index.html"].fragments,
2351            [
2352                raw("<custom-element>"),
2353                component("custom-element"),
2354                raw("</custom-element>"),
2355            ]
2356        );
2357
2358        assert_stream!(
2359            records,
2360            "custom-element",
2361            [raw("<div>Custom Element</div>"),]
2362        );
2363    }
2364
2365    #[test]
2366    fn test_component_self_closing() {
2367        let mut parser = HtmlParser::new();
2368        parser
2369            .component_registry
2370            .register_component("custom-widget", "<div>Widget Content</div>", None)
2371            .expect("register");
2372        let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
2373        assert!(result.is_ok());
2374        let records = parser.into_fragment_records();
2375
2376        assert_fragments!(
2377            records["index.html"].fragments,
2378            [
2379                raw("<custom-widget"),
2380                attr_start("config", "settings"),
2381                raw("/>"),
2382                component("custom-widget"),
2383            ]
2384        );
2385    }
2386
2387    #[test]
2388    fn test_component_nested_self_closing_in_slot() {
2389        let mut parser = HtmlParser::new();
2390        parser.set_dom_strategy(DomStrategy::Light);
2391        parser
2392            .component_registry
2393            .register_component("custom-icon", "<svg><slot></slot></svg>", None)
2394            .expect("register");
2395        let result = parser.parse(
2396            "index.html",
2397            r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
2398        );
2399        assert!(result.is_ok());
2400        let records = parser.into_fragment_records();
2401
2402        assert_fragments!(
2403            records["index.html"].fragments,
2404            [
2405                raw("<custom-icon>"),
2406                component("custom-icon"),
2407                raw("<use"),
2408                attr_template("href", "attr-1"),
2409                raw("/></custom-icon>"),
2410            ]
2411        );
2412
2413        assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
2414    }
2415
2416    #[test]
2417    fn test_component_leading_boolean_attr_start() {
2418        let mut parser = HtmlParser::new();
2419        parser
2420            .component_registry
2421            .register_component("custom-element", "<slot></slot>", None)
2422            .expect("register");
2423        let result = parser.parse(
2424            "index.html",
2425            r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
2426        );
2427        assert!(result.is_ok());
2428        let records = parser.into_fragment_records();
2429
2430        assert_fragments!(
2431            records["index.html"].fragments,
2432            [
2433                raw("<custom-element"),
2434                // First dynamic attr: boolean with attrStart
2435                bool_attr_start("disabled", "isDisabled"),
2436                // Static attr after dynamic: rawValue
2437                attr_raw("title", "Hello"),
2438                raw(">"),
2439                component("custom-element"),
2440                raw("</custom-element>"),
2441            ]
2442        );
2443    }
2444
2445    #[test]
2446    fn test_component_boolean_predicate_preserves_condition_tree() {
2447        let mut parser = HtmlParser::new();
2448        parser
2449            .component_registry
2450            .register_component("custom-element", "<slot></slot>", None)
2451            .expect("register");
2452        let result = parser.parse(
2453            "index.html",
2454            r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
2455        );
2456        assert!(result.is_ok());
2457
2458        let records = parser.into_fragment_records();
2459        let fragments = &records["index.html"].fragments;
2460        match fragments
2461            .get(1)
2462            .and_then(|fragment| fragment.fragment.as_ref())
2463        {
2464            Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
2465                assert_eq!(attr.name, "disabled");
2466                assert!(attr.attr_start);
2467                match attr
2468                    .condition_tree
2469                    .as_ref()
2470                    .and_then(|condition| condition.expr.as_ref())
2471                {
2472                    Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
2473                        assert_eq!(pred.left, "page");
2474                        assert_eq!(pred.operator, 3);
2475                        assert_eq!(pred.right, "'dashboard'");
2476                    }
2477                    other => panic!("expected predicate condition tree, got {:?}", other),
2478                }
2479            }
2480            other => panic!("expected attribute fragment, got {:?}", other),
2481        }
2482    }
2483
2484    #[test]
2485    fn test_component_meta_link_tags() {
2486        let (fragments, _) = parse_and_get_fragments(
2487            r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
2488        );
2489        assert!(fragments.len() >= 5);
2490        assert!(
2491            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<head><meta charset=\"utf-8\"") && raw.value.contains("<link"))
2492        );
2493        assert!(
2494            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
2495        );
2496        assert!(
2497            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
2498        );
2499        assert!(
2500            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if s.value == "head_end" && s.raw)
2501        );
2502        assert!(
2503            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
2504        );
2505    }
2506
2507    #[test]
2508    fn test_nested_directives() {
2509        let mut parser = HtmlParser::new();
2510        let html = r#"<for each="category in categories">
2511            <if condition="category.hasItems">
2512                <for each="item in category.items">
2513                   {{item.title}}
2514                </for>
2515            </if>
2516        </for>"#;
2517
2518        let result = parser.parse("test.html", html);
2519
2520        assert!(result.is_ok(), "Parse error: {:?}", result.err());
2521        let fragment_records = parser.into_fragment_records();
2522
2523        assert_stream!(
2524            fragment_records,
2525            "test.html",
2526            [for_loop("category", "categories", "for-1"),]
2527        );
2528
2529        assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
2530
2531        assert_stream!(
2532            fragment_records,
2533            "if-1",
2534            [for_loop("item", "category.items", "for-2"),]
2535        );
2536
2537        assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
2538    }
2539
2540    #[test]
2541    fn test_complex_directives() {
2542        let mut parser = HtmlParser::new();
2543        let html = r#"<for each="category in categories">
2544            <div class="category">
2545                <h2>{{category.name}}</h2>
2546                <if condition="category.hasItems">
2547                    <ul>
2548                        <for each="item in category.items">
2549                            <li>{{item.title}}</li>
2550                        </for>
2551                    </ul>
2552                </if>
2553            </div>
2554        </for>"#;
2555
2556        let result = parser.parse("test.html", html);
2557
2558        assert!(result.is_ok(), "Parse error: {:?}", result.err());
2559        let fragment_records = parser.into_fragment_records();
2560
2561        assert_stream!(
2562            fragment_records,
2563            "test.html",
2564            [for_loop("category", "categories", "for-1"),]
2565        );
2566
2567        // Verify for fragments contains the category.name signal
2568        assert_stream!(
2569            fragment_records,
2570            "for-1",
2571            [
2572                raw("<div class=\"category\"><h2>"),
2573                signal("category.name"),
2574                raw("</h2>"),
2575                if_cond("if-1"),
2576                raw("</div>"),
2577            ]
2578        );
2579
2580        // Verify nested if condition.
2581        assert_stream!(
2582            fragment_records,
2583            "if-1",
2584            [
2585                raw("<ul>"),
2586                for_loop("item", "category.items", "for-2"),
2587                raw("</ul>"),
2588            ]
2589        );
2590
2591        // Verify nested for each.
2592        assert_stream!(
2593            fragment_records,
2594            "for-2",
2595            [raw("<li>"), signal("item.title"), raw("</li>"),]
2596        );
2597    }
2598
2599    // ── Attribute fragment tests ─────────────────────────────────────────
2600
2601    /// Helper to parse HTML and return the fragments for the entry stream.
2602    fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2603        let mut parser = HtmlParser::new();
2604        let result = parser.parse("index.html", html);
2605        assert!(result.is_ok(), "Parse error: {:?}", result.err());
2606        let records = parser.into_fragment_records();
2607        let fragments = records
2608            .get("index.html")
2609            .expect("Failed to get index.html fragment")
2610            .fragments
2611            .clone();
2612        (fragments, records)
2613    }
2614
2615    /// Helper to parse HTML with a pre-registered component.
2616    fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2617        let mut parser = HtmlParser::new();
2618        parser
2619            .component_registry
2620            .register_component(tag, "<div></div>", None)
2621            .expect("register");
2622        let result = parser.parse("index.html", html);
2623        assert!(result.is_ok(), "Parse error: {:?}", result.err());
2624        let records = parser.into_fragment_records();
2625        let fragments = records
2626            .get("index.html")
2627            .expect("Failed to get index.html fragment")
2628            .fragments
2629            .clone();
2630        (fragments, records)
2631    }
2632
2633    #[test]
2634    fn test_attribute_handlebars_in_href() {
2635        // Port of: 'should process handlebars from attributes as signals'
2636        let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
2637        assert_fragments!(
2638            fragments,
2639            [
2640                raw("<a"),
2641                attr("href", "url"),
2642                raw(">"),
2643                signal("name"),
2644                raw("</a>"),
2645            ]
2646        );
2647    }
2648
2649    #[test]
2650    fn test_attribute_boolean_with_handlebars() {
2651        // Port of: 'should process boolean attribute with handlebars expression'
2652        let (fragments, _) =
2653            parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
2654        assert_fragments!(
2655            fragments,
2656            [
2657                raw("<button"),
2658                bool_attr("disabled", "isDisabled"),
2659                raw(">Click</button>"),
2660            ]
2661        );
2662    }
2663
2664    #[test]
2665    fn test_attribute_multiple_boolean() {
2666        // Port of: 'should process multiple boolean attributes'
2667        // <input ?checked={{isChecked}} ?disabled={{isDisabled}} />
2668        let (fragments, _) =
2669            parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
2670
2671        assert_fragments!(
2672            fragments,
2673            [
2674                raw("<input"),
2675                bool_attr("checked", "isChecked"),
2676                bool_attr("disabled", "isDisabled"),
2677                raw("/>"),
2678            ]
2679        );
2680    }
2681
2682    #[test]
2683    fn test_attribute_boolean_and_regular_together() {
2684        // Port of: 'should process a boolean attribute and a regular attribute together'
2685        // <input ?checked="{{isChecked}}" type="checkbox">Hi</input>
2686        let (fragments, _) = parse_and_get_fragments(
2687            r#"<input ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2688        );
2689
2690        assert_fragments!(
2691            fragments,
2692            [
2693                raw("<input"),
2694                bool_attr("checked", "isChecked"),
2695                raw(" type=\"checkbox\">Hi</input>"),
2696            ]
2697        );
2698    }
2699
2700    #[test]
2701    fn test_attribute_boolean_sandwiched() {
2702        // Port of: 'should process a boolean attribute sandwiched between regular attributes'
2703        // <input version={{edition}} ?checked="{{isChecked}}" type="checkbox">Hi</input>
2704        let (fragments, _) = parse_and_get_fragments(
2705            r#"<input version={{edition}} ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2706        );
2707
2708        assert_fragments!(
2709            fragments,
2710            [
2711                raw("<input"),
2712                attr("version", "edition"),
2713                bool_attr("checked", "isChecked"),
2714                raw(" type=\"checkbox\">Hi</input>"),
2715            ]
2716        );
2717    }
2718
2719    #[test]
2720    fn test_attribute_boolean_ending() {
2721        // Port of: 'should process html ending with boolean attribute correctly'
2722        // <input version={{edition}} ?checked="{{isChecked}}">Hi</input>
2723        let (fragments, _) = parse_and_get_fragments(
2724            r#"<input version={{edition}} ?checked="{{isChecked}}">Hi</input>"#,
2725        );
2726
2727        assert_fragments!(
2728            fragments,
2729            [
2730                raw("<input"),
2731                attr("version", "edition"),
2732                bool_attr("checked", "isChecked"),
2733                raw(">Hi</input>"),
2734            ]
2735        );
2736    }
2737
2738    #[test]
2739    fn test_attribute_boolean_dotted_path() {
2740        // Port of: 'should process boolean attribute with dotted path'
2741        // <div ?checked={{layout.isPinned}}>Content</div>
2742        let (fragments, _) =
2743            parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
2744
2745        assert_fragments!(
2746            fragments,
2747            [
2748                raw("<div"),
2749                bool_attr("checked", "layout.isPinned"),
2750                raw(">Content</div>"),
2751            ]
2752        );
2753    }
2754
2755    #[test]
2756    fn test_attribute_colon_prefixed_complex() {
2757        // Port of: 'should process colon-prefixed attribute with handlebars'
2758        // <my-component :config="{{settings}}"></my-component>
2759        let (fragments, _) = parse_with_component(
2760            "my-component",
2761            r#"<my-component :config="{{settings}}"></my-component>"#,
2762        );
2763
2764        assert_fragments!(
2765            fragments,
2766            [
2767                raw("<my-component"),
2768                attr_complex_start(":config", "settings"),
2769                raw(">"),
2770                component("my-component"),
2771                raw("</my-component>"),
2772            ]
2773        );
2774    }
2775
2776    #[test]
2777    fn test_attribute_multiple_colon_prefixed() {
2778        // Port of: 'should process multiple colon-prefixed complex attributes'
2779        // <my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>
2780        let (fragments, _) = parse_with_component(
2781            "my-component",
2782            r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
2783        );
2784
2785        assert_fragments!(
2786            fragments,
2787            [
2788                raw("<my-component"),
2789                attr_complex_start(":prop1", "val1"),
2790                attr_complex(":prop2", "val2"),
2791                raw(">"),
2792                component("my-component"),
2793                raw("</my-component>"),
2794            ]
2795        );
2796    }
2797
2798    #[test]
2799    fn test_blocked_complex_property_innerhtml() {
2800        let mut parser = HtmlParser::new();
2801        let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
2802        assert!(
2803            result.is_err(),
2804            "Expected error for :innerHTML on native element"
2805        );
2806        let err = result.unwrap_err().to_string();
2807        assert!(
2808            err.contains("only allowed on custom elements"),
2809            "Error: {err}"
2810        );
2811    }
2812
2813    #[test]
2814    fn test_blocked_complex_on_native_element() {
2815        let mut parser = HtmlParser::new();
2816        let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
2817        assert!(
2818            result.is_err(),
2819            "Expected error for :data on native element"
2820        );
2821        let err = result.unwrap_err().to_string();
2822        assert!(
2823            err.contains("only allowed on custom elements"),
2824            "Error: {err}"
2825        );
2826    }
2827
2828    #[test]
2829    fn test_blocked_complex_property_on_component() {
2830        let mut parser = HtmlParser::new();
2831        parser
2832            .component_registry
2833            .register_component("my-widget", "<div></div>", None)
2834            .expect("register");
2835        let result = parser.parse(
2836            "index.html",
2837            r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
2838        );
2839        assert!(
2840            result.is_err(),
2841            "Expected error for :innerHTML on component"
2842        );
2843        let err = result.unwrap_err().to_string();
2844        assert!(err.contains("HTML injection"), "Error: {err}");
2845    }
2846
2847    #[test]
2848    fn test_allowed_complex_property() {
2849        // :config on a component should still work
2850        let (fragments, _) = parse_with_component(
2851            "my-component",
2852            r#"<my-component :config="{{settings}}"></my-component>"#,
2853        );
2854        assert_fragments!(
2855            fragments,
2856            [
2857                raw("<my-component"),
2858                attr_complex_start(":config", "settings"),
2859                raw(">"),
2860                component("my-component"),
2861                raw("</my-component>"),
2862            ]
2863        );
2864    }
2865
2866    #[test]
2867    fn test_attribute_mixed_normal_boolean_colon() {
2868        // Port of: 'should process mixed normal, boolean, and colon-prefixed attributes'
2869        // <my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>
2870        let (fragments, _) = parse_with_component(
2871            "my-component",
2872            r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
2873        );
2874
2875        assert_fragments!(
2876            fragments,
2877            [
2878                raw("<my-component"),
2879                attr_raw_start("id", "comp"),
2880                attr_complex(":config", "settings"),
2881                bool_attr("enabled", "isEnabled"),
2882                raw(">"),
2883                component("my-component"),
2884                raw("</my-component>"),
2885            ]
2886        );
2887    }
2888
2889    #[test]
2890    fn test_attribute_reject_boolean_without_handlebars() {
2891        // Port of: 'should reject boolean attribute without handlebars'
2892        // <input ?checked="name"></input>
2893        let (fragments, _) = parse_and_get_fragments(r#"<input ?checked="name"></input>"#);
2894
2895        // Boolean attribute is silently dropped
2896        assert_fragments!(fragments, [raw("<input></input>"),]);
2897    }
2898
2899    #[test]
2900    fn test_attribute_reject_boolean_with_partial_handlebars() {
2901        // Port of: 'should reject boolean attribute with partial handlebars'
2902        // <input ?checked="Hello {{name}}"></input>
2903        let (fragments, _) =
2904            parse_and_get_fragments(r#"<input ?checked="Hello {{name}}"></input>"#);
2905
2906        // Boolean attribute is silently dropped
2907        assert_fragments!(fragments, [raw("<input></input>"),]);
2908    }
2909
2910    #[test]
2911    fn test_attribute_reject_boolean_with_plain_value() {
2912        // Port of: 'should reject boolean attribute with plain value'
2913        // <button ?disabled="true">Click</button>
2914        let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
2915
2916        // Boolean attribute is silently dropped
2917        assert_fragments!(fragments, [raw("<button>Click</button>"),]);
2918    }
2919
2920    #[test]
2921    fn test_attribute_boolean_predicate_equal() {
2922        // Boolean attribute with == predicate expression
2923        let (fragments, _) =
2924            parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
2925        assert_fragments!(
2926            fragments,
2927            [
2928                raw("<div"),
2929                bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
2930                raw(">X</div>"),
2931            ]
2932        );
2933    }
2934
2935    #[test]
2936    fn test_attribute_boolean_predicate_greater_than() {
2937        // Boolean attribute with > predicate expression
2938        let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
2939        assert_fragments!(
2940            fragments,
2941            [
2942                raw("<span"),
2943                bool_attr_predicate("hidden", "num", 1, "9"),
2944                raw(">X</span>"),
2945            ]
2946        );
2947    }
2948
2949    #[test]
2950    fn test_attribute_boolean_predicate_not_equal() {
2951        // Boolean attribute with != predicate expression
2952        let (fragments, _) =
2953            parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
2954        assert_fragments!(
2955            fragments,
2956            [
2957                raw("<a"),
2958                bool_attr_predicate("data-active", "status", 4, "'inactive'"),
2959                raw(">X</a>"),
2960            ]
2961        );
2962    }
2963
2964    #[test]
2965    fn test_attribute_boolean_negation() {
2966        // Boolean attribute with negated expression
2967        let (fragments, _) =
2968            parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
2969        assert_fragments!(
2970            fragments,
2971            [
2972                raw("<button"),
2973                bool_attr_not("disabled", "isReady"),
2974                raw(">X</button>"),
2975            ]
2976        );
2977    }
2978
2979    #[test]
2980    fn test_attribute_mixed_static_dynamic() {
2981        // Port of: 'should process mixed attributes correctly'
2982        // <input value="hello {{world}}">Hi</input>
2983        let (fragments, records) =
2984            parse_and_get_fragments(r#"<input value="hello {{world}}">Hi</input>"#);
2985
2986        assert_fragments!(
2987            fragments,
2988            [
2989                raw("<input"),
2990                attr_template("value", "attr-1"),
2991                raw(">Hi</input>"),
2992            ]
2993        );
2994
2995        // Verify the template sub-stream
2996        assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
2997    }
2998
2999    // ── Body signal tests ─────────────────────────────────────────────
3000
3001    #[test]
3002    fn test_body_signals() {
3003        let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
3004        assert_fragments!(
3005            fragments,
3006            [
3007                raw("<body>"),
3008                signal_raw("body_start"),
3009                raw("<app-shell></app-shell>"),
3010                signal_raw("body_end"),
3011                raw("</body>"),
3012            ]
3013        );
3014    }
3015
3016    // ── Empty for handling tests ──────────────────────────────────────
3017
3018    #[test]
3019    fn test_empty_for_produces_nothing() {
3020        let (fragments, records) =
3021            parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
3022        assert_fragments!(fragments, [raw("<div></div>"),]);
3023        assert!(!records.contains_key("for-1"));
3024    }
3025
3026    // ── Self-closing / void element tests ─────────────────────────────
3027
3028    #[test]
3029    fn test_self_closing_svg_path() {
3030        let (fragments, _) =
3031            parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
3032        assert_fragments!(
3033            fragments,
3034            [raw(
3035                r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
3036            ),]
3037        );
3038    }
3039
3040    #[test]
3041    fn test_html5_void_elements() {
3042        let (fragments, _) = parse_and_get_fragments(
3043            r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
3044        );
3045        assert_fragments!(
3046            fragments,
3047            [raw(
3048                r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
3049            ),]
3050        );
3051    }
3052
3053    #[test]
3054    fn test_self_closing_with_dynamic_attributes() {
3055        let (fragments, _) =
3056            parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
3057        assert_fragments!(
3058            fragments,
3059            [
3060                raw("<img"),
3061                attr("src", "imageUrl"),
3062                attr("alt", "imageAlt"),
3063                raw("/>"),
3064            ]
3065        );
3066    }
3067
3068    #[test]
3069    fn test_self_closing_with_boolean_attributes() {
3070        let (fragments, _) = parse_and_get_fragments(
3071            r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
3072        );
3073        assert_fragments!(
3074            fragments,
3075            [
3076                raw("<input type=\"checkbox\""),
3077                bool_attr("checked", "isSelected"),
3078                bool_attr("disabled", "isDisabled"),
3079                raw("/>"),
3080            ]
3081        );
3082    }
3083
3084    #[test]
3085    fn test_multiple_self_closing_in_sequence() {
3086        let (fragments, _) =
3087            parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
3088        assert_fragments!(
3089            fragments,
3090            [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
3091        );
3092    }
3093
3094    #[test]
3095    fn test_self_closing_with_mixed_content() {
3096        let (fragments, _) =
3097            parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
3098        assert_fragments!(
3099            fragments,
3100            [
3101                raw("<div>Text before<img"),
3102                attr("src", "url"),
3103                raw("/>Text after</div>"),
3104            ]
3105        );
3106    }
3107
3108    #[test]
3109    fn test_self_closing_svg_elements() {
3110        let (fragments, _) = parse_and_get_fragments(
3111            r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
3112        );
3113        assert_fragments!(
3114            fragments,
3115            [
3116                raw("<svg><circle"),
3117                attr("cx", "x"),
3118                attr("cy", "y"),
3119                raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
3120            ]
3121        );
3122    }
3123
3124    #[test]
3125    fn test_self_closing_inside_for_loop() {
3126        let (fragments, records) = parse_and_get_fragments(
3127            r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
3128        );
3129        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
3130        assert_stream!(
3131            records,
3132            "for-1",
3133            [raw("<img"), attr("src", "item.url"), raw("/>"),]
3134        );
3135    }
3136
3137    #[test]
3138    fn test_self_closing_whitespace_variations() {
3139        let (fragments, _) =
3140            parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
3141        assert_fragments!(
3142            fragments,
3143            [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
3144        );
3145    }
3146
3147    #[test]
3148    fn test_deeply_nested_self_closing() {
3149        let (fragments, _) = parse_and_get_fragments(
3150            r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
3151        );
3152        assert_fragments!(
3153            fragments,
3154            [raw(
3155                r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
3156            ),]
3157        );
3158    }
3159
3160    #[test]
3161    fn test_self_closing_vs_empty_regular_tags() {
3162        let (fragments, _) =
3163            parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
3164        assert_fragments!(
3165            fragments,
3166            [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
3167        );
3168    }
3169
3170    // ── Feature 1: Custom template attribute on <for> ────────────────────
3171
3172    #[test]
3173    fn test_for_custom_template_attribute() {
3174        // Port of: 'should process transient node for with template'
3175        let (fragments, records) = parse_and_get_fragments(
3176            r#"<for each="item in items" template="static"><span>Item</span></for>"#,
3177        );
3178        assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
3179        assert_stream!(records, "static", [raw("<span>Item</span>"),]);
3180    }
3181
3182    #[test]
3183    fn test_for_recursive_template() {
3184        // Port of: 'should process recursive transient nodes'
3185        let mut parser = HtmlParser::new();
3186        let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
3187        let result = parser.parse("index.html", html);
3188        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3189        let records = parser.into_fragment_records();
3190
3191        assert_fragments!(
3192            records["index.html"].fragments,
3193            [for_loop("outerItem", "outerItems", "static"),]
3194        );
3195
3196        assert_stream!(
3197            records,
3198            "static",
3199            [
3200                raw("<div><span>"),
3201                signal("outerItem.name"),
3202                raw("</span>"),
3203                for_loop("innerItem", "innerItems", "static"),
3204                raw("</div>"),
3205            ]
3206        );
3207    }
3208
3209    // ── Feature 2: <if> / <for> with multiple children ──────────────────
3210
3211    #[test]
3212    fn test_if_multiple_children() {
3213        // Port of: 'should handle <if> with multiple children'
3214        let (fragments, records) =
3215            parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
3216        assert_fragments!(fragments, [if_cond("if-1"),]);
3217        assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
3218    }
3219
3220    #[test]
3221    fn test_for_multiple_children() {
3222        // Port of: 'should handle <for> with multiple children'
3223        let (fragments, records) =
3224            parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
3225        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
3226        assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
3227    }
3228
3229    // ── Feature 3: Handlebars at beginning/end of text ──────────────────
3230
3231    #[test]
3232    fn test_handlebars_at_beginning() {
3233        // Port of: 'should process handlebars from text at beginning'
3234        let (fragments, _) = parse_and_get_fragments("{{first}}");
3235        assert_fragments!(fragments, [signal("first"),]);
3236    }
3237
3238    #[test]
3239    fn test_handlebars_at_beginning_and_raw() {
3240        // Port of: 'should process handlebars from text at beginning and raw'
3241        let (fragments, _) = parse_and_get_fragments("{{first}}test");
3242        assert_fragments!(fragments, [signal("first"), raw("test"),]);
3243    }
3244
3245    #[test]
3246    fn test_handlebars_raw_and_end() {
3247        // Port of: 'should process handlebars from text at raw and end'
3248        let (fragments, _) = parse_and_get_fragments("test{{first}}");
3249        assert_fragments!(fragments, [raw("test"), signal("first"),]);
3250    }
3251
3252    // ── Feature 4: Handlebars edge cases ────────────────────────────────
3253
3254    #[test]
3255    fn test_handlebars_invalid_triple_open() {
3256        // Port of: 'should not process handlebars when invalid'
3257        let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
3258        assert_fragments!(fragments, [raw("{{{invalid}}"),]);
3259    }
3260
3261    #[test]
3262    fn test_handlebars_four_open_braces() {
3263        // Port of: 'should not process handlebars when invalid since triple exists'
3264        let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
3265        assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
3266    }
3267
3268    #[test]
3269    fn test_handlebars_five_open_with_valid_double() {
3270        // Port of: 'should not process handlebars when invalid but with valid triple'
3271        let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
3272        assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
3273    }
3274
3275    #[test]
3276    fn test_entities_preserved() {
3277        // Port of: 'should process entities correctly'
3278        let (fragments, _) = parse_and_get_fragments("<p>Hello&#125;World</p>");
3279        assert_fragments!(fragments, [raw("<p>Hello&#125;World</p>"),]);
3280    }
3281
3282    // ── Feature 5: DOCTYPE handling ─────────────────────────────────────
3283
3284    #[test]
3285    fn test_doctype_preserved() {
3286        // DOCTYPE should be preserved as raw content
3287        let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
3288        assert!(!fragments.is_empty());
3289        assert!(
3290            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
3291        );
3292    }
3293
3294    // ── Feature 6: Component attribute skip / multiple nested ───────────
3295
3296    #[test]
3297    fn test_component_attr_skip() {
3298        // Port of: 'should set attrSkip for skipped component attributes'
3299        let mut parser = HtmlParser::new();
3300        parser
3301            .component_registry
3302            .register_component("custom-element", "<slot></slot>", None)
3303            .expect("register");
3304        let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
3305        let result = parser.parse("index.html", html);
3306        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3307        let records = parser.into_fragment_records();
3308
3309        // <custom-element, :config(attrStart), class(attrSkip), style(attrSkip),
3310        // role(attrSkip), data-test(attrSkip), aria-test(attrSkip), >, component, </custom-element>
3311        assert_fragments!(
3312            records["index.html"].fragments,
3313            [
3314                raw("<custom-element"),
3315                // :config with attrStart
3316                attr_complex_start(":config", "config"),
3317                // Skipped attrs
3318                attr_skip("class", "value0"),
3319                attr_skip("style", "value1"),
3320                attr_skip("role", "value2"),
3321                attr_skip("data-test", "value3"),
3322                attr_skip("aria-test", "value4"),
3323                raw(">"),
3324                component("custom-element"),
3325                raw("</custom-element>"),
3326            ]
3327        );
3328    }
3329
3330    #[test]
3331    fn test_component_attr_skip_static_and_embedded() {
3332        // Skipped attrs with static values and embedded bindings should
3333        // emit fragments (not be silently dropped).
3334        let mut parser = HtmlParser::new();
3335        parser
3336            .component_registry
3337            .register_component("item-group", "<slot></slot>", None)
3338            .expect("register");
3339
3340        let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
3341        let result = parser.parse("index.html", html);
3342        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3343        let records = parser.into_fragment_records();
3344
3345        assert_fragments!(
3346            records["index.html"].fragments,
3347            [
3348                raw("<item-group"),
3349                attr_skip_raw("role", "list"),
3350                attr_skip_template("aria-labelledby", "attr-1"),
3351                attr_skip_template("data-testid", "attr-2"),
3352                attr_skip_raw("class", "fixed-class"),
3353                raw(">"),
3354                component("item-group"),
3355                raw("</item-group>"),
3356            ]
3357        );
3358
3359        // Verify the embedded-binding template sub-streams exist and
3360        // contain the expected static + signal fragments.
3361        assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
3362        assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
3363    }
3364
3365    #[test]
3366    fn test_component_multiple_nested() {
3367        // Port of: 'handle multiple nested web components'
3368        let mut parser = HtmlParser::new();
3369        parser.set_dom_strategy(DomStrategy::Light);
3370        parser
3371            .component_registry
3372            .register_component(
3373                "custom-element",
3374                "<custom-child></custom-child><slot></slot>",
3375                None,
3376            )
3377            .expect("register");
3378        parser
3379            .component_registry
3380            .register_component("custom-button", "<slot></slot>", None)
3381            .expect("register");
3382        parser
3383            .component_registry
3384            .register_component("custom-child", "<h1>Hello World!</h1>", None)
3385            .expect("register");
3386
3387        let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
3388        let result = parser.parse("index.html", html);
3389        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3390        let records = parser.into_fragment_records();
3391
3392        // Entry stream
3393        assert_fragments!(
3394            records["index.html"].fragments,
3395            [for_loop("item", "items", "for-1"),]
3396        );
3397
3398        // For stream
3399        assert_stream!(
3400            records,
3401            "for-1",
3402            [
3403                raw("<custom-element>"),
3404                component("custom-element"),
3405                raw("<custom-button>"),
3406                component("custom-button"),
3407                raw("Ok</custom-button></custom-element>"),
3408            ]
3409        );
3410
3411        // Component streams — custom-element has contains() checks, keep manual
3412        let ce = &records["custom-element"].fragments;
3413        assert_eq!(ce.len(), 3);
3414        assert!(
3415            matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child>"))
3416        );
3417        assert!(
3418            matches!(ce[1].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
3419        );
3420        assert!(
3421            matches!(ce[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
3422        );
3423
3424        assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
3425
3426        assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
3427    }
3428
3429    // ── Error handling tests ──────────────────────────────────────────
3430
3431    #[test]
3432    fn test_invalid_markup_returns_error() {
3433        // Port of: 'should fail with invalid markup'
3434        // tree-sitter is lenient — it recovers from unclosed tags
3435        let mut parser = HtmlParser::new();
3436        let result = parser.parse("index.html", "<div><span>Unclosed div");
3437        assert!(result.is_ok());
3438    }
3439
3440    // ── Integration tests ─────────────────────────────────────────────
3441
3442    #[test]
3443    fn test_complex_raw_text_full_page() {
3444        // Port of: 'should process a complex raw text'
3445        let html = r#"<!DOCTYPE HTML><html dir="auto" lang="en"><head><meta charset="utf-8"><title>Test</title><style>html { margin: 0; }</style></head><body><app-shell></app-shell><script type="module" src="./index.js"></script></body></html>"#;
3446        let (fragments, _) = parse_and_get_fragments(html);
3447
3448        // DOCTYPE + head content, head_end, </head><body>, body_start, body content, body_end, </body></html>
3449        assert!(fragments.len() >= 7);
3450        assert!(
3451            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3452                raw.value.contains("<!DOCTYPE HTML>") && raw.value.contains("<title>Test</title>"))
3453        );
3454        assert!(
3455            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
3456                s.value == "head_end" && s.raw)
3457        );
3458        assert!(
3459            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3460                raw.value.contains("</head>") && raw.value.ends_with("<body>"))
3461        );
3462        assert!(
3463            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
3464                s.value == "body_start" && s.raw)
3465        );
3466        assert!(
3467            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3468                raw.value.contains("<app-shell>"))
3469        );
3470        assert!(
3471            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
3472                s.value == "body_end" && s.raw)
3473        );
3474        assert!(
3475            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3476                raw.value.contains("</body>") && raw.value.contains("</html>"))
3477        );
3478    }
3479
3480    #[test]
3481    fn test_css_strategy_external_emits_link_tag() {
3482        let mut parser = HtmlParser::new();
3483        parser
3484            .component_registry_mut()
3485            .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3486            .ok();
3487        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3488        let records = parser.into_fragment_records();
3489        let my_card = &records["my-card"].fragments;
3490        let raw_text: String = my_card
3491            .iter()
3492            .filter_map(|f| match &f.fragment {
3493                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3494                _ => None,
3495            })
3496            .collect();
3497        assert!(
3498            raw_text.contains(r#"<link rel="stylesheet" href="my-card.css">"#),
3499            "Expected external <link> tag in: {}",
3500            raw_text
3501        );
3502    }
3503
3504    #[test]
3505    fn test_css_strategy_inline_emits_style_tag() {
3506        let mut parser = HtmlParser::new();
3507        parser.set_css_strategy(CssStrategy::Style);
3508        parser
3509            .component_registry_mut()
3510            .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3511            .ok();
3512        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3513        let records = parser.into_fragment_records();
3514        let my_card = &records["my-card"].fragments;
3515        let raw_text: String = my_card
3516            .iter()
3517            .filter_map(|f| match &f.fragment {
3518                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3519                _ => None,
3520            })
3521            .collect();
3522        assert!(
3523            raw_text.contains("<style>p { color: red; }</style>"),
3524            "Expected inline <style> tag in: {}",
3525            raw_text
3526        );
3527        assert!(
3528            !raw_text.contains("<link"),
3529            "Should not have <link> tag in inline mode: {}",
3530            raw_text
3531        );
3532    }
3533
3534    #[test]
3535    fn test_css_strategy_module_emits_adopted_stylesheets() {
3536        let mut parser = HtmlParser::new();
3537        parser.set_dom_strategy(DomStrategy::Light);
3538        parser.set_css_strategy(CssStrategy::Module);
3539        parser
3540            .component_registry_mut()
3541            .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3542            .ok();
3543        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3544        let records = parser.into_fragment_records();
3545
3546        // Component template should have shadowrootadoptedstylesheets, no CSS
3547        // module in raw fragments (CSS lives on the component fragment's css field)
3548        let my_card = &records["my-card"].fragments;
3549        let template_text: String = my_card
3550            .iter()
3551            .filter_map(|f| match &f.fragment {
3552                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3553                _ => None,
3554            })
3555            .collect();
3556        assert!(
3557            !template_text.contains("shadowrootadoptedstylesheets"),
3558            "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
3559        );
3560        assert!(
3561            !template_text.contains("<link"),
3562            "Should not have <link> in module mode: {template_text}"
3563        );
3564        // No CSS module baked into raw fragments — CSS is stored in
3565        // protocol.components, populated by the build system.
3566        assert!(
3567            !template_text.contains(r#"<style type="module""#),
3568            "CSS module should NOT be in raw fragments: {template_text}"
3569        );
3570    }
3571
3572    #[test]
3573    fn test_css_strategy_module_no_css_no_adopted_attr() {
3574        let mut parser = HtmlParser::new();
3575        parser.set_css_strategy(CssStrategy::Module);
3576        parser
3577            .component_registry_mut()
3578            .register_component("my-card", "<p><slot></slot></p>", None)
3579            .ok();
3580        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3581        let records = parser.into_fragment_records();
3582        let my_card = &records["my-card"].fragments;
3583        let template_text: String = my_card
3584            .iter()
3585            .filter_map(|f| match &f.fragment {
3586                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3587                _ => None,
3588            })
3589            .collect();
3590        // No CSS → no shadowrootadoptedstylesheets attribute
3591        assert!(
3592            !template_text.contains("shadowrootadoptedstylesheets"),
3593            "Should not have adopted attr without CSS: {template_text}"
3594        );
3595    }
3596
3597    // ── Ported from NodeJS generator.test.js ─────────────────────────
3598
3599    // test_signal_with_default_value — SKIPPED
3600    // The NodeJS `<f-signal value="testSignal">Default Text</f-signal>` feature
3601    // is not supported in the Rust parser. There is no `f-signal` element
3602    // handling in HtmlParser and no corresponding fragment type in
3603    // webui_protocol.
3604
3605    // test_estimated_buffer_size — SKIPPED
3606    // The NodeJS `estimatedBufferSize` field does not exist in the Rust
3607    // WebUIFragmentRecords / WebUIProtocol types. Buffer size estimation is
3608    // not part of the Rust parser output.
3609
3610    #[test]
3611    fn test_body_start_end_injection() {
3612        // Port of: 'should inject body_start and body_end signals around body content'
3613        // Verifies body_start appears immediately after <body> and body_end
3614        // appears immediately before </body> in a full HTML page.
3615        let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
3616        let (fragments, _) = parse_and_get_fragments(html);
3617
3618        assert_fragments!(
3619            fragments,
3620            [
3621                raw("<html><head><title>Test</title>"),
3622                signal_raw("head_end"),
3623                raw("</head><body>"),
3624                signal_raw("body_start"),
3625                raw("<div>Content</div><p>More</p>"),
3626                signal_raw("body_end"),
3627                raw("</body></html>"),
3628            ]
3629        );
3630    }
3631
3632    #[test]
3633    fn test_body_preserves_static_attributes() {
3634        // The <body> tag's attributes must be carried through to the rendered
3635        // HTML, otherwise host frames (e.g. webui-press's `<body data-layout="…">`)
3636        // lose their styling hooks. Earlier versions of `process_body_with_signals`
3637        // emitted a bare `<body>` and dropped every attribute on the element.
3638        let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
3639        let (fragments, _) = parse_and_get_fragments(html);
3640
3641        assert_fragments!(
3642            fragments,
3643            [
3644                raw("<html><head><title>T</title>"),
3645                signal_raw("head_end"),
3646                raw(r#"</head><body data-layout="doc" class="page">"#),
3647                signal_raw("body_start"),
3648                raw("<p>x</p>"),
3649                signal_raw("body_end"),
3650                raw("</body></html>"),
3651            ]
3652        );
3653    }
3654
3655    #[test]
3656    fn test_body_preserves_dynamic_attributes() {
3657        // Dynamic attributes on <body> (e.g. `data-layout="{{layout}}"`) must
3658        // emit attribute fragments rather than being inlined as literal text,
3659        // so the handler binds them to runtime state at render time.
3660        let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
3661        let (fragments, _) = parse_and_get_fragments(html);
3662
3663        assert_fragments!(
3664            fragments,
3665            [
3666                raw("<html><head><title>T</title>"),
3667                signal_raw("head_end"),
3668                raw("</head><body"),
3669                attr("data-layout", "layout"),
3670                raw(">"),
3671                signal_raw("body_start"),
3672                raw("<p>x</p>"),
3673                signal_raw("body_end"),
3674                raw("</body></html>"),
3675            ]
3676        );
3677    }
3678
3679    #[test]
3680    fn test_fail_with_invalid_markup() {
3681        // Port of: 'should fail with invalid markup'
3682        // tree-sitter HTML grammar is lenient — it recovers from overlapping /
3683        // misnested tags rather than returning a parse error. This test
3684        // documents that behavior: deliberately overlapping tags do NOT
3685        // produce an error.
3686        let mut parser = HtmlParser::new();
3687        let result = parser.parse("index.html", "<div><span></div></span>");
3688
3689        // tree-sitter recovers gracefully; the parse succeeds
3690        assert!(
3691            result.is_ok(),
3692            "tree-sitter is lenient and recovers from overlapping tags"
3693        );
3694    }
3695
3696    #[test]
3697    fn test_complex_raw_text_page() {
3698        // Port of: 'should process a complex raw text page with DOCTYPE,
3699        // meta tags, styles, and scripts'
3700        let html = concat!(
3701            "<!DOCTYPE html>",
3702            "<html lang=\"en\">",
3703            "<head>",
3704            "<meta charset=\"utf-8\">",
3705            "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
3706            "<title>Complex Page</title>",
3707            "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
3708            "<link rel=\"stylesheet\" href=\"styles.css\">",
3709            "</head>",
3710            "<body>",
3711            "<h1>Hello World</h1>",
3712            "<script type=\"module\" src=\"./app.js\"></script>",
3713            "</body>",
3714            "</html>",
3715        );
3716        let (fragments, _) = parse_and_get_fragments(html);
3717
3718        // Should have: raw(DOCTYPE+head content), head_end, raw(</head><body>),
3719        // body_start, raw(body content), body_end, raw(</body></html>)
3720        assert!(
3721            fragments.len() >= 7,
3722            "Expected at least 7 fragments, got {}",
3723            fragments.len()
3724        );
3725
3726        // First fragment: DOCTYPE through head content (before </head>)
3727        assert!(
3728            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3729                raw.value.contains("<!DOCTYPE html>") &&
3730                raw.value.contains("<meta charset=\"utf-8\">") &&
3731                raw.value.contains("<meta name=\"viewport\"") &&
3732                raw.value.contains("<title>Complex Page</title>") &&
3733                raw.value.contains("<style>") &&
3734                raw.value.contains("body { margin: 0; padding: 0; }")),
3735            "First fragment should contain all head content, got: {:?}",
3736            fragments[0]
3737        );
3738
3739        // head_end signal
3740        assert!(
3741            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
3742                s.value == "head_end" && s.raw),
3743            "Second fragment should be head_end signal"
3744        );
3745
3746        // </head><body>
3747        assert!(
3748            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3749                raw.value.contains("</head>") && raw.value.ends_with("<body>")),
3750            "Third fragment should contain </head><body>"
3751        );
3752
3753        // body_start signal
3754        assert!(
3755            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
3756                s.value == "body_start" && s.raw),
3757            "Fourth fragment should be body_start signal"
3758        );
3759
3760        // Body content (h1 and script)
3761        assert!(
3762            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3763                raw.value.contains("<h1>Hello World</h1>") &&
3764                raw.value.contains("<script")),
3765            "Fifth fragment should contain body content"
3766        );
3767
3768        // body_end signal
3769        assert!(
3770            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
3771                s.value == "body_end" && s.raw),
3772            "Sixth fragment should be body_end signal"
3773        );
3774
3775        // Closing tags
3776        assert!(
3777            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3778                raw.value.contains("</body>") && raw.value.contains("</html>")),
3779            "Seventh fragment should contain closing tags"
3780        );
3781    }
3782
3783    // --- Binding count tests (with mock plugin) ---
3784
3785    /// Mock plugin that records the binding attribute count for each element.
3786    struct BindingCountPlugin {
3787        counts: Vec<u32>,
3788    }
3789
3790    impl BindingCountPlugin {
3791        fn new() -> Self {
3792            Self { counts: Vec::new() }
3793        }
3794    }
3795
3796    impl crate::plugin::ParserPlugin for BindingCountPlugin {
3797        fn register_component_template(
3798            &mut self,
3799            _tag_name: &str,
3800            _component: &Component,
3801            _processed_template: &str,
3802        ) -> Result<()> {
3803            Ok(())
3804        }
3805
3806        fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
3807            if attr_name.starts_with('@') || attr_name == "f-ref" {
3808                AttributeAction::SkipAndCountBinding
3809            } else {
3810                AttributeAction::Keep
3811            }
3812        }
3813
3814        fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
3815            self.counts.push(binding_attribute_count);
3816            if binding_attribute_count > 0 {
3817                Some(binding_attribute_count.to_le_bytes().to_vec())
3818            } else {
3819                None
3820            }
3821        }
3822    }
3823
3824    #[test]
3825    fn test_component_static_attrs_not_counted_as_bindings() {
3826        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3827        parser
3828            .component_registry
3829            .register_component("my-btn", "<button><slot></slot></button>", None)
3830            .expect("register");
3831
3832        // All attributes are static — binding count should be 0
3833        let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
3834        let result = parser.parse("index.html", html);
3835        assert!(result.is_ok());
3836
3837        let records = parser.into_fragment_records();
3838
3839        // No Plugin fragment should appear (binding count = 0)
3840        let frags = &records["index.html"].fragments;
3841        let plugin_count = frags
3842            .iter()
3843            .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
3844            .count();
3845        assert_eq!(
3846            plugin_count, 0,
3847            "Static-only component attributes should not emit a Plugin fragment"
3848        );
3849    }
3850
3851    #[test]
3852    fn test_component_dynamic_attr_counted_as_binding() {
3853        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3854        parser
3855            .component_registry
3856            .register_component("my-btn", "<button><slot></slot></button>", None)
3857            .expect("register");
3858
3859        // One dynamic attribute ({{...}}) — binding count should be 1
3860        let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
3861        let result = parser.parse("index.html", html);
3862        assert!(result.is_ok());
3863
3864        let records = parser.into_fragment_records();
3865        let frags = &records["index.html"].fragments;
3866
3867        // Exactly one Plugin fragment with count = 1
3868        let plugin_frags: Vec<_> = frags
3869            .iter()
3870            .filter_map(|f| match f.fragment.as_ref() {
3871                Some(Fragment::Plugin(p)) => Some(&p.data),
3872                _ => None,
3873            })
3874            .collect();
3875        assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
3876        let count = u32::from_le_bytes([
3877            plugin_frags[0][0],
3878            plugin_frags[0][1],
3879            plugin_frags[0][2],
3880            plugin_frags[0][3],
3881        ]);
3882        assert_eq!(count, 1, "Binding attribute count should be 1");
3883    }
3884
3885    #[test]
3886    fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
3887        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3888        parser
3889            .component_registry
3890            .register_component("my-btn", "<button><slot></slot></button>", None)
3891            .expect("register");
3892
3893        // 2 static, 1 dynamic, 1 skipped-with-plugin (@click) — only dynamic + skipped counted
3894        let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
3895        let result = parser.parse("index.html", html);
3896        assert!(result.is_ok());
3897
3898        let records = parser.into_fragment_records();
3899        let frags = &records["index.html"].fragments;
3900
3901        let plugin_frags: Vec<_> = frags
3902            .iter()
3903            .filter_map(|f| match f.fragment.as_ref() {
3904                Some(Fragment::Plugin(p)) => Some(&p.data),
3905                _ => None,
3906            })
3907            .collect();
3908        assert_eq!(plugin_frags.len(), 1);
3909        let count = u32::from_le_bytes([
3910            plugin_frags[0][0],
3911            plugin_frags[0][1],
3912            plugin_frags[0][2],
3913            plugin_frags[0][3],
3914        ]);
3915        // 1 dynamic (appearance) + 1 skipped-but-counted (@click) = 2
3916        assert_eq!(
3917            count, 2,
3918            "Binding count should include dynamic + plugin-skipped attrs, not static"
3919        );
3920    }
3921
3922    #[test]
3923    fn test_component_only_skipped_plugin_attrs_counted() {
3924        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3925        parser
3926            .component_registry
3927            .register_component("my-btn", "<button><slot></slot></button>", None)
3928            .expect("register");
3929
3930        // Only plugin-skipped attrs (@click, f-ref) plus static — only skipped counted
3931        let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
3932        let result = parser.parse("index.html", html);
3933        assert!(result.is_ok());
3934
3935        let records = parser.into_fragment_records();
3936        let frags = &records["index.html"].fragments;
3937
3938        let plugin_frags: Vec<_> = frags
3939            .iter()
3940            .filter_map(|f| match f.fragment.as_ref() {
3941                Some(Fragment::Plugin(p)) => Some(&p.data),
3942                _ => None,
3943            })
3944            .collect();
3945        assert_eq!(plugin_frags.len(), 1);
3946        let count = u32::from_le_bytes([
3947            plugin_frags[0][0],
3948            plugin_frags[0][1],
3949            plugin_frags[0][2],
3950            plugin_frags[0][3],
3951        ]);
3952        // @click + f-ref = 2, title is static = not counted
3953        assert_eq!(
3954            count, 2,
3955            "Only plugin-skipped attrs should be counted, not static title"
3956        );
3957    }
3958
3959    // ── Comment binding tests ────────────────────────────────────────
3960
3961    #[test]
3962    fn test_comment_handlebars_signal() {
3963        let mut parser = HtmlParser::new();
3964        let html = "<!--{{tokens}}-->";
3965        parser.parse("test.html", html).expect("parse failed");
3966        let records = parser.into_fragment_records();
3967
3968        assert_stream!(records, "test.html", [signal("tokens")]);
3969    }
3970
3971    #[test]
3972    fn test_comment_triple_brace_raw_signal() {
3973        let mut parser = HtmlParser::new();
3974        let html = "<!--{{{tokens}}}-->";
3975        parser.parse("test.html", html).expect("parse failed");
3976        let records = parser.into_fragment_records();
3977
3978        assert_stream!(records, "test.html", [signal_raw("tokens")]);
3979    }
3980
3981    #[test]
3982    fn test_comment_regular_preserved() {
3983        let mut parser = HtmlParser::new();
3984        let html = "<!-- regular comment -->";
3985        parser.parse("test.html", html).expect("parse failed");
3986        let records = parser.into_fragment_records();
3987
3988        assert_stream!(records, "test.html", [raw("<!-- regular comment -->")]);
3989    }
3990
3991    #[test]
3992    fn test_comment_dotted_signal() {
3993        let mut parser = HtmlParser::new();
3994        let html = "<!--{{tokens.light}}-->";
3995        parser.parse("test.html", html).expect("parse failed");
3996        let records = parser.into_fragment_records();
3997
3998        assert_stream!(records, "test.html", [signal("tokens.light")]);
3999    }
4000
4001    #[test]
4002    fn test_comment_arbitrary_identifier() {
4003        let mut parser = HtmlParser::new();
4004        let html = "<!--{{someOtherBinding}}-->";
4005        parser.parse("test.html", html).expect("parse failed");
4006        let records = parser.into_fragment_records();
4007
4008        assert_stream!(records, "test.html", [signal("someOtherBinding")]);
4009    }
4010
4011    #[test]
4012    fn test_comment_with_surrounding_content() {
4013        let mut parser = HtmlParser::new();
4014        let html = "<div><!--{{tokens}}--></div>";
4015        parser.parse("test.html", html).expect("parse failed");
4016        let records = parser.into_fragment_records();
4017
4018        assert_stream!(
4019            records,
4020            "test.html",
4021            [raw("<div>"), signal("tokens"), raw("</div>")]
4022        );
4023    }
4024
4025    // ── Token collection tests ───────────────────────────────────────
4026
4027    #[test]
4028    fn test_tokens_from_style_tag() {
4029        let mut parser = HtmlParser::new();
4030        let html = r#"<style>
4031            .btn { color: var(--colorPrimary); background: var(--bgColor); }
4032        </style>"#;
4033        parser.parse("test.html", html).expect("parse failed");
4034        let tokens = parser.take_tokens();
4035
4036        assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
4037    }
4038
4039    #[test]
4040    fn test_tokens_from_component_css() {
4041        let mut parser = HtmlParser::new();
4042        parser
4043            .component_registry_mut()
4044            .register_component(
4045                "my-button",
4046                "<button>Click</button>",
4047                Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
4048            )
4049            .expect("register failed");
4050
4051        let html = "<my-button></my-button>";
4052        parser.parse("test.html", html).expect("parse failed");
4053        let tokens = parser.take_tokens();
4054
4055        assert_eq!(tokens, vec!["borderWidth", "textColor"]);
4056    }
4057
4058    #[test]
4059    fn test_tokens_merged_from_style_and_components() {
4060        let mut parser = HtmlParser::new();
4061        parser
4062            .component_registry_mut()
4063            .register_component(
4064                "my-widget",
4065                "<div>Widget</div>",
4066                Some(".w { padding: var(--spacingM); }"),
4067            )
4068            .expect("register failed");
4069
4070        let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
4071        parser.parse("test.html", html).expect("parse failed");
4072        let tokens = parser.take_tokens();
4073
4074        assert_eq!(tokens, vec!["spacingM", "textColor"]);
4075    }
4076
4077    #[test]
4078    fn test_tokens_deduplicated() {
4079        let mut parser = HtmlParser::new();
4080        parser
4081            .component_registry_mut()
4082            .register_component(
4083                "my-btn",
4084                "<button>B</button>",
4085                Some(".b { color: var(--shared); }"),
4086            )
4087            .expect("register failed");
4088
4089        let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
4090        parser.parse("test.html", html).expect("parse failed");
4091        let tokens = parser.take_tokens();
4092
4093        assert_eq!(tokens, vec!["shared"]);
4094    }
4095
4096    #[test]
4097    fn test_tokens_empty_when_no_vars() {
4098        let mut parser = HtmlParser::new();
4099        let html = "<div>Hello</div>";
4100        parser.parse("test.html", html).expect("parse failed");
4101        let tokens = parser.take_tokens();
4102
4103        assert!(tokens.is_empty());
4104    }
4105
4106    #[test]
4107    fn test_tokens_exclude_locally_defined_in_component() {
4108        let mut parser = HtmlParser::new();
4109        parser
4110            .component_registry_mut()
4111            .register_component(
4112                "my-card",
4113                "<div>Card</div>",
4114                Some(":host { --local: 5px; width: var(--external); }"),
4115            )
4116            .expect("register failed");
4117
4118        let html = "<my-card></my-card>";
4119        parser.parse("test.html", html).expect("parse failed");
4120        let tokens = parser.take_tokens();
4121
4122        assert_eq!(tokens, vec!["external"]);
4123    }
4124
4125    #[test]
4126    fn test_tokens_exclude_entry_root_definitions() {
4127        let mut parser = HtmlParser::new();
4128        parser
4129            .component_registry_mut()
4130            .register_component(
4131                "my-btn",
4132                "<button>B</button>",
4133                Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
4134            )
4135            .expect("register failed");
4136
4137        // Entry HTML defines --color-primary and --radius-m in :root
4138        // Components use them — they should NOT appear in hoisted tokens
4139        let html = r#"<style>
4140            :root {
4141                --color-primary: #0078d4;
4142                --radius-m: 6px;
4143            }
4144            body { color: var(--color-primary); }
4145        </style>
4146        <my-btn></my-btn>"#;
4147        parser.parse("test.html", html).expect("parse failed");
4148        let tokens = parser.take_tokens();
4149
4150        // Both tokens are defined in entry :root, so neither should be hoisted
4151        assert!(
4152            tokens.is_empty(),
4153            "Tokens defined in entry :root should be excluded: {tokens:?}"
4154        );
4155    }
4156
4157    #[test]
4158    fn test_tokens_entry_defs_exclude_but_external_kept() {
4159        let mut parser = HtmlParser::new();
4160        parser
4161            .component_registry_mut()
4162            .register_component(
4163                "my-card",
4164                "<div>Card</div>",
4165                Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
4166            )
4167            .expect("register failed");
4168
4169        // Entry defines --color-primary but NOT --external-spacing
4170        let html = r#"<style>
4171            :root { --color-primary: #0078d4; }
4172        </style>
4173        <my-card></my-card>"#;
4174        parser.parse("test.html", html).expect("parse failed");
4175        let tokens = parser.take_tokens();
4176
4177        // Only --external-spacing should be hoisted (not defined in entry)
4178        assert_eq!(tokens, vec!["external-spacing"]);
4179    }
4180
4181    // ── Route parsing tests ─────────────────────────────────────────────
4182
4183    #[test]
4184    fn test_parse_simple_route() {
4185        let mut parser = HtmlParser::new();
4186        let html = r#"<route path="/profile" component="profile-page" exact />"#;
4187        parser.parse("test.html", html).expect("parse failed");
4188
4189        // Routes are emitted as Fragment::Route
4190        let records = parser.into_fragment_records();
4191        let frags = &records["test.html"].fragments;
4192        assert_eq!(frags.len(), 1);
4193        match frags[0].fragment.as_ref() {
4194            Some(web_ui_fragment::Fragment::Route(r)) => {
4195                assert_eq!(r.path, "/profile");
4196                assert_eq!(r.fragment_id, "profile-page");
4197                assert!(r.exact);
4198            }
4199            other => panic!("expected Fragment::Route, got {:?}", other),
4200        }
4201    }
4202
4203    #[test]
4204    fn test_parse_route_with_params() {
4205        let mut parser = HtmlParser::new();
4206        let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
4207        parser.parse("test.html", html).expect("parse failed");
4208
4209        // Route is emitted as Fragment::Route with correct path
4210        let records = parser.into_fragment_records();
4211        let frags = &records["test.html"].fragments;
4212        match frags[0].fragment.as_ref() {
4213            Some(web_ui_fragment::Fragment::Route(r)) => {
4214                assert_eq!(r.path, "/profile/:id/view/:section");
4215            }
4216            other => panic!("expected Fragment::Route, got {:?}", other),
4217        }
4218    }
4219
4220    #[test]
4221    fn test_parse_route_requires_component() {
4222        let mut parser = HtmlParser::new();
4223        let html = r#"<route path="/old" />"#;
4224        let result = parser.parse("test.html", html);
4225        assert!(result.is_err());
4226    }
4227
4228    #[test]
4229    fn test_parse_multiple_routes() {
4230        let mut parser = HtmlParser::new();
4231        let html = r#"<route path="/" component="app-layout" />
4232            <route path="/dashboard" component="dash-page" exact />
4233            <route path="/contacts" component="contacts-page" exact />"#;
4234        parser.parse("test.html", html).expect("parse failed");
4235
4236        let records = parser.into_fragment_records();
4237        let frags = &records["test.html"].fragments;
4238        assert_eq!(frags.len(), 3);
4239        // All should be Fragment::Route
4240        for frag in frags {
4241            assert!(matches!(
4242                frag.fragment.as_ref(),
4243                Some(web_ui_fragment::Fragment::Route(_))
4244            ));
4245        }
4246    }
4247
4248    #[test]
4249    fn test_parse_route_requires_component_with_body() {
4250        let mut parser = HtmlParser::new();
4251        let html = r#"<route path="/error">
4252            <div class="error"><h1>Not Found</h1></div>
4253        </route>"#;
4254        let result = parser.parse("test.html", html);
4255        assert!(
4256            result.is_err(),
4257            "Route without component attribute should fail"
4258        );
4259    }
4260
4261    #[test]
4262    fn test_parse_multiple_routes_with_fragments() {
4263        let mut parser = HtmlParser::new();
4264        let html = r#"<route path="/" component="home-page" exact />
4265            <route path="/about" component="about-page" exact />
4266            <route path="/contact/:id" component="contact-page" />"#;
4267        parser.parse("test.html", html).expect("parse failed");
4268
4269        let records = parser.into_fragment_records();
4270        let frags = &records["test.html"].fragments;
4271        assert_eq!(frags.len(), 3);
4272
4273        // Verify individual routes
4274        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
4275            assert_eq!(r.path, "/");
4276        }
4277        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
4278            assert_eq!(r.path, "/about");
4279        }
4280        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
4281            assert_eq!(r.path, "/contact/:id");
4282        }
4283    }
4284
4285    #[test]
4286    fn test_parse_route_with_query_allowlist() {
4287        let mut parser = HtmlParser::new();
4288        let html =
4289            r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
4290        parser.parse("test.html", html).expect("parse failed");
4291
4292        let records = parser.into_fragment_records();
4293        let frags = &records["test.html"].fragments;
4294        assert_eq!(frags.len(), 1);
4295        match frags[0].fragment.as_ref() {
4296            Some(web_ui_fragment::Fragment::Route(r)) => {
4297                assert_eq!(r.path, "/compose");
4298                assert_eq!(r.fragment_id, "compose-page");
4299                assert!(r.exact);
4300                assert_eq!(r.allowed_query, "action,to,subject");
4301            }
4302            other => panic!("expected Fragment::Route, got {:?}", other),
4303        }
4304    }
4305
4306    #[test]
4307    fn test_parse_route_without_query_has_empty_allowed_query() {
4308        let mut parser = HtmlParser::new();
4309        let html = r#"<route path="/profile" component="profile-page" exact />"#;
4310        parser.parse("test.html", html).expect("parse failed");
4311
4312        let records = parser.into_fragment_records();
4313        let frags = &records["test.html"].fragments;
4314        match frags[0].fragment.as_ref() {
4315            Some(web_ui_fragment::Fragment::Route(r)) => {
4316                assert!(r.allowed_query.is_empty());
4317            }
4318            other => panic!("expected Fragment::Route, got {:?}", other),
4319        }
4320    }
4321
4322    #[test]
4323    fn test_outlet_not_captured_by_for_loop() {
4324        let mut parser = HtmlParser::new();
4325        let html = r#"<template shadowrootmode="open">
4326  <ul>
4327    <for each="item in items">
4328      <li>{{item.name}}</li>
4329    </for>
4330  </ul>
4331  <main>
4332    <outlet />
4333  </main>
4334</template>"#;
4335        parser.parse("comp.html", html).expect("parse failed");
4336
4337        let records = parser.into_fragment_records();
4338        let frags = &records["comp.html"].fragments;
4339
4340        // Print fragment types for debugging
4341        let frag_types: Vec<String> = frags
4342            .iter()
4343            .map(|f| match f.fragment.as_ref() {
4344                Some(web_ui_fragment::Fragment::Raw(r)) => {
4345                    format!("Raw({:?})", &r.value[..r.value.len().min(40)])
4346                }
4347                Some(web_ui_fragment::Fragment::ForLoop(fl)) => {
4348                    format!("ForLoop({})", fl.fragment_id)
4349                }
4350                Some(web_ui_fragment::Fragment::Outlet(_)) => "Outlet".to_string(),
4351                other => format!("{:?}", other),
4352            })
4353            .collect();
4354        eprintln!("Fragment order: {:#?}", frag_types);
4355
4356        // The outlet should be a top-level fragment, NOT inside the for-loop's body
4357        let outlet_count = frags
4358            .iter()
4359            .filter(|f| {
4360                matches!(
4361                    f.fragment.as_ref(),
4362                    Some(web_ui_fragment::Fragment::Outlet(_))
4363                )
4364            })
4365            .count();
4366        assert_eq!(
4367            outlet_count, 1,
4368            "expected exactly 1 outlet in top-level fragments, got {outlet_count}. Fragments: {frag_types:?}"
4369        );
4370
4371        // Verify outlet comes AFTER the raw "</ul>" text
4372        let outlet_idx = frags
4373            .iter()
4374            .position(|f| {
4375                matches!(
4376                    f.fragment.as_ref(),
4377                    Some(web_ui_fragment::Fragment::Outlet(_))
4378                )
4379            })
4380            .expect("no outlet found");
4381        let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
4382            Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
4383            _ => false,
4384        });
4385        if let Some(ul_idx) = close_ul_idx {
4386            assert!(
4387                outlet_idx > ul_idx,
4388                "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx}). Fragments: {frag_types:?}"
4389            );
4390        }
4391    }
4392
4393    #[test]
4394    fn test_outlet_position_after_for_not_inside() {
4395        let mut parser = HtmlParser::new();
4396        let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
4397        parser.parse("test.html", html).expect("parse failed");
4398
4399        let records = parser.into_fragment_records();
4400        let frags = &records["test.html"].fragments;
4401
4402        // Outlet should be its own fragment at top level
4403        let has_outlet = frags.iter().any(|f| {
4404            matches!(
4405                f.fragment.as_ref(),
4406                Some(web_ui_fragment::Fragment::Outlet(_))
4407            )
4408        });
4409        assert!(
4410            has_outlet,
4411            "outlet should be in top-level fragments: {frags:?}"
4412        );
4413
4414        // The for-loop body should NOT contain the outlet
4415        let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
4416            Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
4417            _ => None,
4418        });
4419        if let Some(id) = for_id {
4420            let for_frags = &records[&id].fragments;
4421            let outlet_in_for = for_frags.iter().any(|f| {
4422                matches!(
4423                    f.fragment.as_ref(),
4424                    Some(web_ui_fragment::Fragment::Outlet(_))
4425                )
4426            });
4427            assert!(
4428                !outlet_in_for,
4429                "outlet should NOT be inside for-loop body: {for_frags:?}"
4430            );
4431        }
4432    }
4433
4434    #[test]
4435    fn test_style_element_with_handlebars_signal() {
4436        let mut parser = HtmlParser::new();
4437        let html = r#"<html><head><style>
4438:root {
4439    /*{{{tokens.light}}}*/
4440}
4441</style></head><body></body></html>"#;
4442
4443        let result = parser.parse("test.html", html);
4444        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4445        let fragment_records = parser.into_fragment_records();
4446
4447        // Comment-delimited signal: the /* */ are stripped, signal emitted.
4448        assert_stream!(
4449            fragment_records,
4450            "test.html",
4451            [
4452                raw("<html><head><style>\n:root {\n    "),
4453                signal_raw("tokens.light"),
4454                raw("\n}\n</style>"),
4455                signal_raw("head_end"),
4456                raw("</head><body>"),
4457                signal_raw("body_start"),
4458                signal_raw("body_end"),
4459                raw("</body></html>"),
4460            ]
4461        );
4462    }
4463
4464    #[test]
4465    fn test_style_comment_signal_with_spaces() {
4466        let mut parser = HtmlParser::new();
4467        let html = r#"<html><head><style>
4468:root {
4469    /* {{{tokens.light}}} */
4470}
4471</style></head><body></body></html>"#;
4472
4473        let result = parser.parse("test.html", html);
4474        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4475        let fragment_records = parser.into_fragment_records();
4476
4477        // Spaces inside comment are trimmed before parsing.
4478        assert_stream!(
4479            fragment_records,
4480            "test.html",
4481            [
4482                raw("<html><head><style>\n:root {\n    "),
4483                signal_raw("tokens.light"),
4484                raw("\n}\n</style>"),
4485                signal_raw("head_end"),
4486                raw("</head><body>"),
4487                signal_raw("body_start"),
4488                signal_raw("body_end"),
4489                raw("</body></html>"),
4490            ]
4491        );
4492    }
4493
4494    #[test]
4495    fn test_style_comment_signal_double_brace() {
4496        let mut parser = HtmlParser::new();
4497        let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
4498
4499        let result = parser.parse("test.html", html);
4500        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4501        let fragment_records = parser.into_fragment_records();
4502
4503        // Double-brace in a comment is also valid.
4504        assert_stream!(
4505            fragment_records,
4506            "test.html",
4507            [
4508                raw("<html><head><style>"),
4509                signal("themeCss"),
4510                raw("</style>"),
4511                signal_raw("head_end"),
4512                raw("</head><body>"),
4513                signal_raw("body_start"),
4514                signal_raw("body_end"),
4515                raw("</body></html>"),
4516            ]
4517        );
4518    }
4519
4520    #[test]
4521    fn test_style_comment_with_extra_text_stays_raw() {
4522        let mut parser = HtmlParser::new();
4523        // Extra text around the signal inside the comment → not a placeholder
4524        let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
4525
4526        let result = parser.parse("test.html", html);
4527        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4528        let fragment_records = parser.into_fragment_records();
4529
4530        assert_stream!(
4531            fragment_records,
4532            "test.html",
4533            [
4534                raw("<html><head><style>/* theme: {{token}} */</style>"),
4535                signal_raw("head_end"),
4536                raw("</head><body>"),
4537                signal_raw("body_start"),
4538                signal_raw("body_end"),
4539                raw("</body></html>"),
4540            ]
4541        );
4542    }
4543
4544    #[test]
4545    fn test_style_comment_with_multiple_signals_stays_raw() {
4546        let mut parser = HtmlParser::new();
4547        // Two signals in one comment → not a placeholder
4548        let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
4549
4550        let result = parser.parse("test.html", html);
4551        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4552        let fragment_records = parser.into_fragment_records();
4553
4554        assert_stream!(
4555            fragment_records,
4556            "test.html",
4557            [
4558                raw("<html><head><style>/*{{a}}{{b}}*/</style>"),
4559                signal_raw("head_end"),
4560                raw("</head><body>"),
4561                signal_raw("body_start"),
4562                signal_raw("body_end"),
4563                raw("</body></html>"),
4564            ]
4565        );
4566    }
4567
4568    #[test]
4569    fn test_style_bare_handlebars_stays_raw() {
4570        let mut parser = HtmlParser::new();
4571        // Handlebars outside a CSS comment must NOT be parsed as signals.
4572        let html =
4573            "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
4574
4575        let result = parser.parse("test.html", html);
4576        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4577        let fragment_records = parser.into_fragment_records();
4578
4579        assert_stream!(
4580            fragment_records,
4581            "test.html",
4582            [
4583                raw("<html><head><style>body { color: {{textColor}}; }</style>"),
4584                signal_raw("head_end"),
4585                raw("</head><body>"),
4586                signal_raw("body_start"),
4587                signal_raw("body_end"),
4588                raw("</body></html>"),
4589            ]
4590        );
4591    }
4592
4593    #[test]
4594    fn test_style_mixed_css_and_comment_signal() {
4595        let mut parser = HtmlParser::new();
4596        let html = r#"<html><head><style>
4597  .a { color: red; }
4598  /*{{themeCss}}*/
4599  .b { color: blue; }
4600</style></head><body></body></html>"#;
4601
4602        let result = parser.parse("test.html", html);
4603        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4604        let fragment_records = parser.into_fragment_records();
4605
4606        assert_stream!(
4607            fragment_records,
4608            "test.html",
4609            [
4610                raw("<html><head><style>\n  .a { color: red; }\n  "),
4611                signal("themeCss"),
4612                raw("\n  .b { color: blue; }\n</style>"),
4613                signal_raw("head_end"),
4614                raw("</head><body>"),
4615                signal_raw("body_start"),
4616                signal_raw("body_end"),
4617                raw("</body></html>"),
4618            ]
4619        );
4620    }
4621
4622    #[test]
4623    fn test_style_element_plain_css_unchanged() {
4624        let mut parser = HtmlParser::new();
4625        let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
4626
4627        let result = parser.parse("test.html", html);
4628        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4629        let fragment_records = parser.into_fragment_records();
4630
4631        // Plain CSS with no handlebars should remain as a single raw fragment.
4632        assert_stream!(
4633            fragment_records,
4634            "test.html",
4635            [
4636                raw("<html><head><style>body { margin: 0; }</style>"),
4637                signal_raw("head_end"),
4638                raw("</head><body>"),
4639                signal_raw("body_start"),
4640                signal_raw("body_end"),
4641                raw("</body></html>"),
4642            ]
4643        );
4644    }
4645
4646    #[test]
4647    fn test_text_with_binding_after_void_element() {
4648        // Text nodes containing {{binding}} after void elements like <br>
4649        // must be preserved. Regression test for content being dropped.
4650        let (fragments, _) = parse_and_get_fragments(
4651            "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
4652        );
4653        assert_fragments!(
4654            fragments,
4655            [
4656                raw("Part A: "),
4657                signal("value1"),
4658                raw("<button>Click</button><br>Part B: "),
4659                signal("value2"),
4660                raw("<button>Click2</button>"),
4661            ]
4662        );
4663    }
4664}