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