Skip to main content

webui_parser/
lib.rs

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