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