Skip to main content

webui_parser/
lib.rs

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