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