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