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