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