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