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