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