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