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, 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(
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("mp-button", "<button>b</button>", None, true)
3091            .expect("Failed to register component");
3092
3093        // `<mp-buton>` is a same-namespace one-character typo of `mp-button`.
3094        let err = parser
3095            .parse("test.html", "<mp-buton></mp-buton>")
3096            .expect_err("a near-typo component tag should error");
3097        let ParserError::Template(diag) = err else {
3098            panic!("expected ParserError::Template, got {err:?}");
3099        };
3100        assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
3101        let help = diag
3102            .help_text()
3103            .expect("diagnostic should carry a help line");
3104        assert!(
3105            help.contains("did you mean <mp-button>?"),
3106            "expected a suggestion, got: {help}"
3107        );
3108    }
3109
3110    #[test]
3111    fn external_custom_element_in_other_namespace_passes_through() {
3112        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3113        parser
3114            .component_registry
3115            .register_component("mp-button", "<button>b</button>", None, true)
3116            .expect("Failed to register component");
3117
3118        // `<md-button>` is a different namespace (md- vs mp-): a genuine
3119        // third-party custom element must pass through as raw HTML, not error.
3120        let result = parser.parse("test.html", "<md-button></md-button>");
3121        assert!(
3122            result.is_ok(),
3123            "external custom element should pass through, got: {:?}",
3124            result.err()
3125        );
3126    }
3127
3128    #[test]
3129    fn for_missing_each_suggests_typoed_attribute() {
3130        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3131        // `eahc` is a transposition of the required `each` attribute.
3132        let err = parser
3133            .parse("test.html", "<for eahc=\"todo in todos\"></for>")
3134            .expect_err("a <for> without `each` should error");
3135        let ParserError::Template(diag) = err else {
3136            panic!("expected ParserError::Template, got {err:?}");
3137        };
3138        assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
3139        let help = diag
3140            .help_text()
3141            .expect("diagnostic should carry a help line");
3142        assert!(
3143            help.contains("found `eahc`") && help.contains("did you mean"),
3144            "expected an attribute-typo suggestion, got: {help}"
3145        );
3146    }
3147
3148    #[test]
3149    fn parent_diagnostic_owner_survives_nested_component_parse() {
3150        // Regression: a component parse recurses through `self.parse(child, …)`,
3151        // which used to leave `current_fragment_id` pointing at the child. A
3152        // later error in the PARENT template must still be attributed to the
3153        // parent (here `index.html`), not the nested component.
3154        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3155        parser
3156            .component_registry
3157            .register_component("my-card", "<div>card</div>", None, true)
3158            .expect("register");
3159
3160        // A registered component first, then a broken <for> in the same template.
3161        let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
3162        let err = parser
3163            .parse("index.html", html)
3164            .expect_err("the malformed <for> should error");
3165        let ParserError::Template(diag) = err else {
3166            panic!("expected ParserError::Template, got {err:?}");
3167        };
3168        assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
3169        assert_eq!(
3170            diag.component_name(),
3171            Some("index.html"),
3172            "parent diagnostic must be owned by index.html, not the nested component"
3173        );
3174    }
3175
3176    #[test]
3177    fn test_component_directive_with_slots() {
3178        let mut parser = HtmlParser::new();
3179        parser
3180            .component_registry
3181            .register_component(
3182                "my-component",
3183                "<div>My Component</div>",
3184                Some("div { color: blue; }"),
3185                true,
3186            )
3187            .expect("Failed to register component");
3188
3189        let result = parser.parse(
3190            "test.html",
3191            "Hello<my-component><p>World</p></my-component>",
3192        );
3193        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3194        let records = parser.into_fragment_records();
3195        let fragments = &records["test.html"].fragments;
3196
3197        // Entry: raw(Hello<my-component>) + component + raw(<p>World</p></my-component>)
3198        assert!(fragments.len() >= 3);
3199        // First fragment should contain "Hello" and "<my-component>"
3200        assert!(
3201            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component>"))
3202        );
3203        // Should have component fragment
3204        assert!(fragments.iter().any(|f| matches!(
3205            f.fragment.as_ref(),
3206            Some(Fragment::Component(c)) if c.fragment_id == "my-component"
3207        )));
3208        // Should end with closing tag
3209        let last = fragments.last().unwrap();
3210        assert!(
3211            matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
3212        );
3213    }
3214
3215    #[test]
3216    fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
3217        // Reproduces commerce app: mp-app has a shadow DOM template containing
3218        // child components (mp-navbar, mp-cart-panel, mp-footer) plus an <outlet>.
3219        // The parser must emit Fragment::Component entries for ALL child components
3220        // so the inventory walk finds them.
3221        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
3222
3223        parser
3224            .component_registry
3225            .register_component(
3226                "app-shell",
3227                r#"<template shadowrootmode="open">
3228                  <my-navbar></my-navbar>
3229                  <main><outlet /></main>
3230                  <cart-panel></cart-panel>
3231                  <my-footer></my-footer>
3232                </template>"#,
3233                Some(":host{display:flex}"),
3234                true,
3235            )
3236            .expect("register app-shell");
3237        parser
3238            .component_registry
3239            .register_component("my-navbar", "<nav>Nav</nav>", Some("nav{color:red}"), true)
3240            .expect("register my-navbar");
3241        parser
3242            .component_registry
3243            .register_component(
3244                "cart-panel",
3245                "<aside>Cart</aside>",
3246                Some("aside{color:green}"),
3247                true,
3248            )
3249            .expect("register cart-panel");
3250        parser
3251            .component_registry
3252            .register_component(
3253                "my-footer",
3254                "<footer>Footer</footer>",
3255                Some("footer{color:blue}"),
3256                true,
3257            )
3258            .expect("register my-footer");
3259
3260        parser
3261            .parse("index.html", "<app-shell></app-shell>")
3262            .expect("parse index.html");
3263        let records = parser.into_fragment_records();
3264
3265        // app-shell's fragment list must contain Component entries for all children
3266        let app_frags = &records["app-shell"].fragments;
3267        let component_ids: Vec<&str> = app_frags
3268            .iter()
3269            .filter_map(|f| match f.fragment.as_ref() {
3270                Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
3271                _ => None,
3272            })
3273            .collect();
3274
3275        assert!(
3276            component_ids.contains(&"my-navbar"),
3277            "app-shell fragments should contain my-navbar: {component_ids:?}"
3278        );
3279        assert!(
3280            component_ids.contains(&"cart-panel"),
3281            "app-shell fragments should contain cart-panel: {component_ids:?}"
3282        );
3283        assert!(
3284            component_ids.contains(&"my-footer"),
3285            "app-shell fragments should contain my-footer: {component_ids:?}"
3286        );
3287    }
3288
3289    // ── Component template wrapping tests ────────────────────────────
3290
3291    #[test]
3292    fn test_component_no_double_wrap_template() {
3293        // Developer-authored <template foo="bar"> must be preserved verbatim
3294        // in --dom=light. The framework only strips runtime-only attrs;
3295        // every other attribute is the developer's responsibility.
3296        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3297        parser
3298            .component_registry
3299            .register_component(
3300                "custom-element",
3301                r#"<template foo="bar"><slot></slot></template>"#,
3302                None,
3303                true,
3304            )
3305            .expect("register");
3306        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3307        assert!(result.is_ok());
3308        let records = parser.into_fragment_records();
3309
3310        assert_fragments!(
3311            records["index.html"].fragments,
3312            [
3313                raw("<custom-element>"),
3314                component("custom-element"),
3315                raw("Hello</custom-element>"),
3316            ]
3317        );
3318
3319        assert_stream!(
3320            records,
3321            "custom-element",
3322            [raw(r#"<template foo="bar"><slot></slot></template>"#),]
3323        );
3324    }
3325
3326    #[test]
3327    fn test_component_styled_no_double_wrap() {
3328        // --dom=light with a developer-supplied <template> wrapper preserves
3329        // the wrapper verbatim. CSS is the default `Link` strategy which
3330        // injects only in shadow DOM, so in light mode there is no CSS
3331        // snippet to splice into the wrapper.
3332        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3333        parser
3334            .component_registry
3335            .register_component(
3336                "custom-element",
3337                r#"<template foo="bar"><slot></slot></template>"#,
3338                Some("div { color: red; }"),
3339                true,
3340            )
3341            .expect("register");
3342        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3343        assert!(result.is_ok());
3344        let records = parser.into_fragment_records();
3345
3346        assert_stream!(
3347            records,
3348            "custom-element",
3349            [raw(r#"<template foo="bar"><slot></slot></template>"#),]
3350        );
3351    }
3352
3353    #[test]
3354    fn test_component_strip_runtime_attrs() {
3355        // Runtime-only attributes (`@event`, `:bind`, `?cond`) are stripped
3356        // from the opening <template> tag, but the wrapper itself is
3357        // preserved. After stripping in this case the wrapper becomes
3358        // `<template>` with no attributes.
3359        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3360        parser
3361            .component_registry
3362            .register_component(
3363                "custom-element",
3364                r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
3365                None,
3366                true,
3367            )
3368            .expect("register");
3369        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3370        assert!(result.is_ok());
3371        let records = parser.into_fragment_records();
3372
3373        assert_stream!(
3374            records,
3375            "custom-element",
3376            [raw("<template><slot></slot></template>"),]
3377        );
3378    }
3379
3380    #[test]
3381    fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
3382        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3383        parser
3384            .component_registry
3385            .register_component(
3386                "custom-element",
3387                r#"<template data-note="@click={foo}" @click={foo}><slot></slot></template>"#,
3388                None,
3389                true,
3390            )
3391            .expect("register");
3392        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3393        assert!(result.is_ok());
3394        let records = parser.into_fragment_records();
3395
3396        assert_stream!(
3397            records,
3398            "custom-element",
3399            [raw(
3400                r#"<template data-note="@click={foo}"><slot></slot></template>"#
3401            ),]
3402        );
3403    }
3404
3405    #[test]
3406    fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
3407        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3408        parser
3409            .component_registry
3410            .register_component(
3411                "custom-element",
3412                r#"<template @click={foo} @click={bar}><slot></slot></template>"#,
3413                None,
3414                true,
3415            )
3416            .expect("register");
3417        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3418        assert!(result.is_ok());
3419        let records = parser.into_fragment_records();
3420
3421        assert_stream!(
3422            records,
3423            "custom-element",
3424            [raw("<template><slot></slot></template>"),]
3425        );
3426    }
3427
3428    #[test]
3429    fn test_component_with_slots_and_attrs() {
3430        let mut parser = HtmlParser::new();
3431        parser
3432            .component_registry
3433            .register_component("custom-element", "<slot></slot>", None, true)
3434            .expect("register");
3435        let result = parser.parse(
3436            "index.html",
3437            r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
3438        );
3439        assert!(result.is_ok());
3440        let records = parser.into_fragment_records();
3441        assert_fragments!(
3442            records["index.html"].fragments,
3443            [
3444                raw("<custom-element"),
3445                attr_raw_start("appearance", "subtle"),
3446                raw(">"),
3447                component("custom-element"),
3448                raw("Hello World</custom-element>"),
3449            ]
3450        );
3451    }
3452
3453    #[test]
3454    fn test_component_legacy_no_styles() {
3455        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3456        parser
3457            .component_registry
3458            .register_component("custom-element", "<div>Custom Element</div>", None, true)
3459            .expect("register");
3460        let result = parser.parse("index.html", "<custom-element></custom-element>");
3461        assert!(result.is_ok());
3462        let records = parser.into_fragment_records();
3463
3464        assert_fragments!(
3465            records["index.html"].fragments,
3466            [
3467                raw("<custom-element>"),
3468                component("custom-element"),
3469                raw("</custom-element>"),
3470            ]
3471        );
3472
3473        assert_stream!(
3474            records,
3475            "custom-element",
3476            [raw("<div>Custom Element</div>"),]
3477        );
3478    }
3479
3480    #[test]
3481    fn test_component_self_closing() {
3482        let mut parser = HtmlParser::new();
3483        parser
3484            .component_registry
3485            .register_component("custom-widget", "<div>Widget Content</div>", None, true)
3486            .expect("register");
3487        let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
3488        assert!(result.is_ok());
3489        let records = parser.into_fragment_records();
3490
3491        assert_fragments!(
3492            records["index.html"].fragments,
3493            [
3494                raw("<custom-widget"),
3495                attr_start("config", "settings"),
3496                raw("/>"),
3497                component("custom-widget"),
3498            ]
3499        );
3500    }
3501
3502    #[test]
3503    fn test_component_nested_self_closing_in_slot() {
3504        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3505        parser
3506            .component_registry
3507            .register_component("custom-icon", "<svg><slot></slot></svg>", None, true)
3508            .expect("register");
3509        let result = parser.parse(
3510            "index.html",
3511            r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
3512        );
3513        assert!(result.is_ok());
3514        let records = parser.into_fragment_records();
3515
3516        assert_fragments!(
3517            records["index.html"].fragments,
3518            [
3519                raw("<custom-icon>"),
3520                component("custom-icon"),
3521                raw("<use"),
3522                attr_template("href", "attr-1"),
3523                raw("/></custom-icon>"),
3524            ]
3525        );
3526
3527        assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
3528    }
3529
3530    #[test]
3531    fn test_component_leading_boolean_attr_start() {
3532        let mut parser = HtmlParser::new();
3533        parser
3534            .component_registry
3535            .register_component("custom-element", "<slot></slot>", None, true)
3536            .expect("register");
3537        let result = parser.parse(
3538            "index.html",
3539            r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
3540        );
3541        assert!(result.is_ok());
3542        let records = parser.into_fragment_records();
3543
3544        assert_fragments!(
3545            records["index.html"].fragments,
3546            [
3547                raw("<custom-element"),
3548                // First dynamic attr: boolean with attrStart
3549                bool_attr_start("disabled", "isDisabled"),
3550                // Static attr after dynamic: rawValue
3551                attr_raw("title", "Hello"),
3552                raw(">"),
3553                component("custom-element"),
3554                raw("</custom-element>"),
3555            ]
3556        );
3557    }
3558
3559    #[test]
3560    fn test_component_boolean_predicate_preserves_condition_tree() {
3561        let mut parser = HtmlParser::new();
3562        parser
3563            .component_registry
3564            .register_component("custom-element", "<slot></slot>", None, true)
3565            .expect("register");
3566        let result = parser.parse(
3567            "index.html",
3568            r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
3569        );
3570        assert!(result.is_ok());
3571
3572        let records = parser.into_fragment_records();
3573        let fragments = &records["index.html"].fragments;
3574        match fragments
3575            .get(1)
3576            .and_then(|fragment| fragment.fragment.as_ref())
3577        {
3578            Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
3579                assert_eq!(attr.name, "disabled");
3580                assert!(attr.attr_start);
3581                match attr
3582                    .condition_tree
3583                    .as_ref()
3584                    .and_then(|condition| condition.expr.as_ref())
3585                {
3586                    Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
3587                        assert_eq!(pred.left, "page");
3588                        assert_eq!(pred.operator, 3);
3589                        assert_eq!(pred.right, "'dashboard'");
3590                    }
3591                    other => panic!("expected predicate condition tree, got {:?}", other),
3592                }
3593            }
3594            other => panic!("expected attribute fragment, got {:?}", other),
3595        }
3596    }
3597
3598    #[test]
3599    fn test_component_meta_link_tags() {
3600        let (fragments, _) = parse_and_get_fragments(
3601            r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
3602        );
3603        assert!(fragments.len() >= 5);
3604        assert!(
3605            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<head><meta charset=\"utf-8\"") && raw.value.contains("<link"))
3606        );
3607        assert!(
3608            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
3609        );
3610        assert!(
3611            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
3612        );
3613        assert!(
3614            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if s.value == "head_end" && s.raw)
3615        );
3616        assert!(
3617            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
3618        );
3619    }
3620
3621    #[test]
3622    fn test_nested_directives() {
3623        let mut parser = HtmlParser::new();
3624        let html = r#"<for each="category in categories">
3625            <if condition="category.hasItems">
3626                <for each="item in category.items">
3627                   {{item.title}}
3628                </for>
3629            </if>
3630        </for>"#;
3631
3632        let result = parser.parse("test.html", html);
3633
3634        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3635        let fragment_records = parser.into_fragment_records();
3636
3637        assert_stream!(
3638            fragment_records,
3639            "test.html",
3640            [for_loop("category", "categories", "for-1"),]
3641        );
3642
3643        assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
3644
3645        assert_stream!(
3646            fragment_records,
3647            "if-1",
3648            [for_loop("item", "category.items", "for-2"),]
3649        );
3650
3651        assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
3652    }
3653
3654    #[test]
3655    fn test_complex_directives() {
3656        let mut parser = HtmlParser::new();
3657        let html = r#"<for each="category in categories">
3658            <div class="category">
3659                <h2>{{category.name}}</h2>
3660                <if condition="category.hasItems">
3661                    <ul>
3662                        <for each="item in category.items">
3663                            <li>{{item.title}}</li>
3664                        </for>
3665                    </ul>
3666                </if>
3667            </div>
3668        </for>"#;
3669
3670        let result = parser.parse("test.html", html);
3671
3672        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3673        let fragment_records = parser.into_fragment_records();
3674
3675        assert_stream!(
3676            fragment_records,
3677            "test.html",
3678            [for_loop("category", "categories", "for-1"),]
3679        );
3680
3681        // Verify for fragments contains the category.name signal
3682        assert_stream!(
3683            fragment_records,
3684            "for-1",
3685            [
3686                raw("<div class=\"category\"><h2>"),
3687                signal("category.name"),
3688                raw("</h2>"),
3689                if_cond("if-1"),
3690                raw("</div>"),
3691            ]
3692        );
3693
3694        // Verify nested if condition.
3695        assert_stream!(
3696            fragment_records,
3697            "if-1",
3698            [
3699                raw("<ul>"),
3700                for_loop("item", "category.items", "for-2"),
3701                raw("</ul>"),
3702            ]
3703        );
3704
3705        // Verify nested for each.
3706        assert_stream!(
3707            fragment_records,
3708            "for-2",
3709            [raw("<li>"), signal("item.title"), raw("</li>"),]
3710        );
3711    }
3712
3713    // ── Attribute fragment tests ─────────────────────────────────────────
3714
3715    /// Helper to parse HTML and return the fragments for the entry stream.
3716    fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
3717        let mut parser = HtmlParser::new();
3718        let result = parser.parse("index.html", html);
3719        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3720        let records = parser.into_fragment_records();
3721        let fragments = records
3722            .get("index.html")
3723            .expect("Failed to get index.html fragment")
3724            .fragments
3725            .clone();
3726        (fragments, records)
3727    }
3728
3729    /// Helper to parse HTML with a pre-registered component.
3730    fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
3731        let mut parser = HtmlParser::new();
3732        parser
3733            .component_registry
3734            .register_component(tag, "<div></div>", None, true)
3735            .expect("register");
3736        let result = parser.parse("index.html", html);
3737        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3738        let records = parser.into_fragment_records();
3739        let fragments = records
3740            .get("index.html")
3741            .expect("Failed to get index.html fragment")
3742            .fragments
3743            .clone();
3744        (fragments, records)
3745    }
3746
3747    #[test]
3748    fn test_attribute_handlebars_in_href() {
3749        // Port of: 'should process handlebars from attributes as signals'
3750        let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
3751        assert_fragments!(
3752            fragments,
3753            [
3754                raw("<a"),
3755                attr("href", "url"),
3756                raw(">"),
3757                signal("name"),
3758                raw("</a>"),
3759            ]
3760        );
3761    }
3762
3763    #[test]
3764    fn test_attribute_boolean_with_handlebars() {
3765        // Port of: 'should process boolean attribute with handlebars expression'
3766        let (fragments, _) =
3767            parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
3768        assert_fragments!(
3769            fragments,
3770            [
3771                raw("<button"),
3772                bool_attr("disabled", "isDisabled"),
3773                raw(">Click</button>"),
3774            ]
3775        );
3776    }
3777
3778    #[test]
3779    fn test_attribute_multiple_boolean() {
3780        // Port of: 'should process multiple boolean attributes'
3781        // <input ?checked={{isChecked}} ?disabled={{isDisabled}} />
3782        let (fragments, _) =
3783            parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
3784
3785        assert_fragments!(
3786            fragments,
3787            [
3788                raw("<input"),
3789                bool_attr("checked", "isChecked"),
3790                bool_attr("disabled", "isDisabled"),
3791                raw("/>"),
3792            ]
3793        );
3794    }
3795
3796    #[test]
3797    fn test_attribute_boolean_and_regular_together() {
3798        // Port of: 'should process a boolean attribute and a regular attribute together'
3799        // <button ?disabled="{{isDisabled}}" type="button">Hi</button>
3800        let (fragments, _) = parse_and_get_fragments(
3801            r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
3802        );
3803
3804        assert_fragments!(
3805            fragments,
3806            [
3807                raw("<button"),
3808                bool_attr("disabled", "isDisabled"),
3809                raw(" type=\"button\">Hi</button>"),
3810            ]
3811        );
3812    }
3813
3814    #[test]
3815    fn test_attribute_boolean_sandwiched() {
3816        // Port of: 'should process a boolean attribute sandwiched between regular attributes'
3817        // <button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>
3818        let (fragments, _) = parse_and_get_fragments(
3819            r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
3820        );
3821
3822        assert_fragments!(
3823            fragments,
3824            [
3825                raw("<button"),
3826                attr("version", "edition"),
3827                bool_attr("disabled", "isDisabled"),
3828                raw(" type=\"button\">Hi</button>"),
3829            ]
3830        );
3831    }
3832
3833    #[test]
3834    fn test_attribute_boolean_ending() {
3835        // Port of: 'should process html ending with boolean attribute correctly'
3836        // <button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>
3837        let (fragments, _) = parse_and_get_fragments(
3838            r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
3839        );
3840
3841        assert_fragments!(
3842            fragments,
3843            [
3844                raw("<button"),
3845                attr("version", "edition"),
3846                bool_attr("disabled", "isDisabled"),
3847                raw(">Hi</button>"),
3848            ]
3849        );
3850    }
3851
3852    #[test]
3853    fn test_attribute_boolean_dotted_path() {
3854        // Port of: 'should process boolean attribute with dotted path'
3855        // <div ?checked={{layout.isPinned}}>Content</div>
3856        let (fragments, _) =
3857            parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
3858
3859        assert_fragments!(
3860            fragments,
3861            [
3862                raw("<div"),
3863                bool_attr("checked", "layout.isPinned"),
3864                raw(">Content</div>"),
3865            ]
3866        );
3867    }
3868
3869    #[test]
3870    fn test_attribute_colon_prefixed_complex() {
3871        // Port of: 'should process colon-prefixed attribute with handlebars'
3872        // <my-component :config="{{settings}}"></my-component>
3873        let (fragments, _) = parse_with_component(
3874            "my-component",
3875            r#"<my-component :config="{{settings}}"></my-component>"#,
3876        );
3877
3878        assert_fragments!(
3879            fragments,
3880            [
3881                raw("<my-component"),
3882                attr_complex_start(":config", "settings"),
3883                raw(">"),
3884                component("my-component"),
3885                raw("</my-component>"),
3886            ]
3887        );
3888    }
3889
3890    #[test]
3891    fn test_attribute_multiple_colon_prefixed() {
3892        // Port of: 'should process multiple colon-prefixed complex attributes'
3893        // <my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>
3894        let (fragments, _) = parse_with_component(
3895            "my-component",
3896            r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
3897        );
3898
3899        assert_fragments!(
3900            fragments,
3901            [
3902                raw("<my-component"),
3903                attr_complex_start(":prop1", "val1"),
3904                attr_complex(":prop2", "val2"),
3905                raw(">"),
3906                component("my-component"),
3907                raw("</my-component>"),
3908            ]
3909        );
3910    }
3911
3912    #[test]
3913    fn test_blocked_complex_property_innerhtml() {
3914        let mut parser = HtmlParser::new();
3915        let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
3916        assert!(
3917            result.is_err(),
3918            "Expected error for :innerHTML on native element"
3919        );
3920        let err = result.unwrap_err().to_string();
3921        assert!(
3922            err.contains("only allowed on custom elements"),
3923            "Error: {err}"
3924        );
3925    }
3926
3927    #[test]
3928    fn test_blocked_complex_on_native_element() {
3929        let mut parser = HtmlParser::new();
3930        let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
3931        assert!(
3932            result.is_err(),
3933            "Expected error for :data on native element"
3934        );
3935        let err = result.unwrap_err().to_string();
3936        assert!(
3937            err.contains("only allowed on custom elements"),
3938            "Error: {err}"
3939        );
3940    }
3941
3942    #[test]
3943    fn test_blocked_complex_property_on_component() {
3944        let mut parser = HtmlParser::new();
3945        parser
3946            .component_registry
3947            .register_component("my-widget", "<div></div>", None, true)
3948            .expect("register");
3949        let result = parser.parse(
3950            "index.html",
3951            r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
3952        );
3953        assert!(
3954            result.is_err(),
3955            "Expected error for :innerHTML on component"
3956        );
3957        let err = result.unwrap_err().to_string();
3958        assert!(err.contains("HTML injection"), "Error: {err}");
3959    }
3960
3961    #[test]
3962    fn test_allowed_complex_property() {
3963        // :config on a component should still work
3964        let (fragments, _) = parse_with_component(
3965            "my-component",
3966            r#"<my-component :config="{{settings}}"></my-component>"#,
3967        );
3968        assert_fragments!(
3969            fragments,
3970            [
3971                raw("<my-component"),
3972                attr_complex_start(":config", "settings"),
3973                raw(">"),
3974                component("my-component"),
3975                raw("</my-component>"),
3976            ]
3977        );
3978    }
3979
3980    #[test]
3981    fn test_attribute_mixed_normal_boolean_colon() {
3982        // Port of: 'should process mixed normal, boolean, and colon-prefixed attributes'
3983        // <my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>
3984        let (fragments, _) = parse_with_component(
3985            "my-component",
3986            r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
3987        );
3988
3989        assert_fragments!(
3990            fragments,
3991            [
3992                raw("<my-component"),
3993                attr_raw_start("id", "comp"),
3994                attr_complex(":config", "settings"),
3995                bool_attr("enabled", "isEnabled"),
3996                raw(">"),
3997                component("my-component"),
3998                raw("</my-component>"),
3999            ]
4000        );
4001    }
4002
4003    #[test]
4004    fn test_attribute_reject_boolean_without_handlebars() {
4005        // Port of: 'should reject boolean attribute without handlebars'
4006        // <button ?checked="name"></button>
4007        let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
4008
4009        // Boolean attribute is silently dropped
4010        assert_fragments!(fragments, [raw("<button></button>"),]);
4011    }
4012
4013    #[test]
4014    fn test_attribute_reject_boolean_with_partial_handlebars() {
4015        // Port of: 'should reject boolean attribute with partial handlebars'
4016        // <button ?checked="Hello {{name}}"></button>
4017        let (fragments, _) =
4018            parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
4019
4020        // Boolean attribute is silently dropped
4021        assert_fragments!(fragments, [raw("<button></button>"),]);
4022    }
4023
4024    #[test]
4025    fn test_attribute_reject_boolean_with_plain_value() {
4026        // Port of: 'should reject boolean attribute with plain value'
4027        // <button ?disabled="true">Click</button>
4028        let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
4029
4030        // Boolean attribute is silently dropped
4031        assert_fragments!(fragments, [raw("<button>Click</button>"),]);
4032    }
4033
4034    #[test]
4035    fn test_attribute_boolean_predicate_equal() {
4036        // Boolean attribute with == predicate expression
4037        let (fragments, _) =
4038            parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
4039        assert_fragments!(
4040            fragments,
4041            [
4042                raw("<div"),
4043                bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
4044                raw(">X</div>"),
4045            ]
4046        );
4047    }
4048
4049    #[test]
4050    fn test_attribute_boolean_predicate_greater_than() {
4051        // Boolean attribute with > predicate expression
4052        let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
4053        assert_fragments!(
4054            fragments,
4055            [
4056                raw("<span"),
4057                bool_attr_predicate("hidden", "num", 1, "9"),
4058                raw(">X</span>"),
4059            ]
4060        );
4061    }
4062
4063    #[test]
4064    fn test_attribute_boolean_predicate_not_equal() {
4065        // Boolean attribute with != predicate expression
4066        let (fragments, _) =
4067            parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
4068        assert_fragments!(
4069            fragments,
4070            [
4071                raw("<a"),
4072                bool_attr_predicate("data-active", "status", 4, "'inactive'"),
4073                raw(">X</a>"),
4074            ]
4075        );
4076    }
4077
4078    #[test]
4079    fn test_attribute_boolean_negation() {
4080        // Boolean attribute with negated expression
4081        let (fragments, _) =
4082            parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
4083        assert_fragments!(
4084            fragments,
4085            [
4086                raw("<button"),
4087                bool_attr_not("disabled", "isReady"),
4088                raw(">X</button>"),
4089            ]
4090        );
4091    }
4092
4093    #[test]
4094    fn test_attribute_mixed_static_dynamic() {
4095        // Port of: 'should process mixed attributes correctly'
4096        // <textarea value="hello {{world}}">Hi</textarea>
4097        let (fragments, records) =
4098            parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
4099
4100        assert_fragments!(
4101            fragments,
4102            [
4103                raw("<textarea"),
4104                attr_template("value", "attr-1"),
4105                raw(">Hi</textarea>"),
4106            ]
4107        );
4108
4109        // Verify the template sub-stream
4110        assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
4111    }
4112
4113    // ── Body signal tests ─────────────────────────────────────────────
4114
4115    #[test]
4116    fn test_body_signals() {
4117        let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
4118        assert_fragments!(
4119            fragments,
4120            [
4121                raw("<body>"),
4122                signal_raw("body_start"),
4123                raw("<app-shell></app-shell>"),
4124                signal_raw("body_end"),
4125                raw("</body>"),
4126            ]
4127        );
4128    }
4129
4130    // ── Empty for handling tests ──────────────────────────────────────
4131
4132    #[test]
4133    fn test_empty_for_produces_nothing() {
4134        let (fragments, records) =
4135            parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
4136        assert_fragments!(fragments, [raw("<div></div>"),]);
4137        assert!(!records.contains_key("for-1"));
4138    }
4139
4140    // ── Self-closing / void element tests ─────────────────────────────
4141
4142    #[test]
4143    fn test_self_closing_svg_path() {
4144        let (fragments, _) =
4145            parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
4146        assert_fragments!(
4147            fragments,
4148            [raw(
4149                r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
4150            ),]
4151        );
4152    }
4153
4154    #[test]
4155    fn test_html5_void_elements() {
4156        let (fragments, _) = parse_and_get_fragments(
4157            r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
4158        );
4159        assert_fragments!(
4160            fragments,
4161            [raw(
4162                r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
4163            ),]
4164        );
4165    }
4166
4167    #[test]
4168    fn test_self_closing_with_dynamic_attributes() {
4169        let (fragments, _) =
4170            parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
4171        assert_fragments!(
4172            fragments,
4173            [
4174                raw("<img"),
4175                attr("src", "imageUrl"),
4176                attr("alt", "imageAlt"),
4177                raw("/>"),
4178            ]
4179        );
4180    }
4181
4182    #[test]
4183    fn test_self_closing_with_boolean_attributes() {
4184        let (fragments, _) = parse_and_get_fragments(
4185            r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
4186        );
4187        assert_fragments!(
4188            fragments,
4189            [
4190                raw("<input type=\"checkbox\""),
4191                bool_attr("checked", "isSelected"),
4192                bool_attr("disabled", "isDisabled"),
4193                raw("/>"),
4194            ]
4195        );
4196    }
4197
4198    #[test]
4199    fn test_multiple_self_closing_in_sequence() {
4200        let (fragments, _) =
4201            parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
4202        assert_fragments!(
4203            fragments,
4204            [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
4205        );
4206    }
4207
4208    #[test]
4209    fn test_self_closing_with_mixed_content() {
4210        let (fragments, _) =
4211            parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
4212        assert_fragments!(
4213            fragments,
4214            [
4215                raw("<div>Text before<img"),
4216                attr("src", "url"),
4217                raw("/>Text after</div>"),
4218            ]
4219        );
4220    }
4221
4222    #[test]
4223    fn test_self_closing_svg_elements() {
4224        let (fragments, _) = parse_and_get_fragments(
4225            r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
4226        );
4227        assert_fragments!(
4228            fragments,
4229            [
4230                raw("<svg><circle"),
4231                attr("cx", "x"),
4232                attr("cy", "y"),
4233                raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
4234            ]
4235        );
4236    }
4237
4238    #[test]
4239    fn test_self_closing_inside_for_loop() {
4240        let (fragments, records) = parse_and_get_fragments(
4241            r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
4242        );
4243        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
4244        assert_stream!(
4245            records,
4246            "for-1",
4247            [raw("<img"), attr("src", "item.url"), raw("/>"),]
4248        );
4249    }
4250
4251    #[test]
4252    fn test_self_closing_whitespace_variations() {
4253        let (fragments, _) =
4254            parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
4255        assert_fragments!(
4256            fragments,
4257            [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
4258        );
4259    }
4260
4261    #[test]
4262    fn test_deeply_nested_self_closing() {
4263        let (fragments, _) = parse_and_get_fragments(
4264            r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
4265        );
4266        assert_fragments!(
4267            fragments,
4268            [raw(
4269                r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
4270            ),]
4271        );
4272    }
4273
4274    #[test]
4275    fn test_self_closing_vs_empty_regular_tags() {
4276        let (fragments, _) =
4277            parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
4278        assert_fragments!(
4279            fragments,
4280            [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
4281        );
4282    }
4283
4284    // ── Feature 1: Custom template attribute on <for> ────────────────────
4285
4286    #[test]
4287    fn test_for_custom_template_attribute() {
4288        // Port of: 'should process transient node for with template'
4289        let (fragments, records) = parse_and_get_fragments(
4290            r#"<for each="item in items" template="static"><span>Item</span></for>"#,
4291        );
4292        assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
4293        assert_stream!(records, "static", [raw("<span>Item</span>"),]);
4294    }
4295
4296    #[test]
4297    fn test_for_recursive_template() {
4298        // Port of: 'should process recursive transient nodes'
4299        let mut parser = HtmlParser::new();
4300        let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
4301        let result = parser.parse("index.html", html);
4302        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4303        let records = parser.into_fragment_records();
4304
4305        assert_fragments!(
4306            records["index.html"].fragments,
4307            [for_loop("outerItem", "outerItems", "static"),]
4308        );
4309
4310        assert_stream!(
4311            records,
4312            "static",
4313            [
4314                raw("<div><span>"),
4315                signal("outerItem.name"),
4316                raw("</span>"),
4317                for_loop("innerItem", "innerItems", "static"),
4318                raw("</div>"),
4319            ]
4320        );
4321    }
4322
4323    // ── Feature 2: <if> / <for> with multiple children ──────────────────
4324
4325    #[test]
4326    fn test_if_multiple_children() {
4327        // Port of: 'should handle <if> with multiple children'
4328        let (fragments, records) =
4329            parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
4330        assert_fragments!(fragments, [if_cond("if-1"),]);
4331        assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
4332    }
4333
4334    #[test]
4335    fn test_for_multiple_children() {
4336        // Port of: 'should handle <for> with multiple children'
4337        let (fragments, records) =
4338            parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
4339        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
4340        assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
4341    }
4342
4343    // ── Feature 3: Handlebars at beginning/end of text ──────────────────
4344
4345    #[test]
4346    fn test_handlebars_at_beginning() {
4347        // Port of: 'should process handlebars from text at beginning'
4348        let (fragments, _) = parse_and_get_fragments("{{first}}");
4349        assert_fragments!(fragments, [signal("first"),]);
4350    }
4351
4352    #[test]
4353    fn test_handlebars_at_beginning_and_raw() {
4354        // Port of: 'should process handlebars from text at beginning and raw'
4355        let (fragments, _) = parse_and_get_fragments("{{first}}test");
4356        assert_fragments!(fragments, [signal("first"), raw("test"),]);
4357    }
4358
4359    #[test]
4360    fn test_handlebars_raw_and_end() {
4361        // Port of: 'should process handlebars from text at raw and end'
4362        let (fragments, _) = parse_and_get_fragments("test{{first}}");
4363        assert_fragments!(fragments, [raw("test"), signal("first"),]);
4364    }
4365
4366    // ── Feature 4: Handlebars edge cases ────────────────────────────────
4367
4368    #[test]
4369    fn test_handlebars_invalid_triple_open() {
4370        // Port of: 'should not process handlebars when invalid'
4371        let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
4372        assert_fragments!(fragments, [raw("{{{invalid}}"),]);
4373    }
4374
4375    #[test]
4376    fn test_handlebars_four_open_braces() {
4377        // Port of: 'should not process handlebars when invalid since triple exists'
4378        let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
4379        assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
4380    }
4381
4382    #[test]
4383    fn test_handlebars_five_open_with_valid_double() {
4384        // Port of: 'should not process handlebars when invalid but with valid triple'
4385        let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
4386        assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
4387    }
4388
4389    #[test]
4390    fn test_entities_preserved() {
4391        // Port of: 'should process entities correctly'
4392        let (fragments, _) = parse_and_get_fragments("<p>Hello&#125;World</p>");
4393        assert_fragments!(fragments, [raw("<p>Hello&#125;World</p>"),]);
4394    }
4395
4396    // ── Feature 5: DOCTYPE handling ─────────────────────────────────────
4397
4398    #[test]
4399    fn test_doctype_preserved() {
4400        // DOCTYPE should be preserved as raw content
4401        let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
4402        assert!(!fragments.is_empty());
4403        assert!(
4404            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
4405        );
4406    }
4407
4408    // ── Feature 6: Component attribute skip / multiple nested ───────────
4409
4410    #[test]
4411    fn test_component_attr_skip() {
4412        // Port of: 'should set attrSkip for skipped component attributes'
4413        let mut parser = HtmlParser::new();
4414        parser
4415            .component_registry
4416            .register_component("custom-element", "<slot></slot>", None, true)
4417            .expect("register");
4418        let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
4419        let result = parser.parse("index.html", html);
4420        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4421        let records = parser.into_fragment_records();
4422
4423        // <custom-element, :config(attrStart), class(attrSkip), style(attrSkip),
4424        // role(attrSkip), data-test(attrSkip), aria-test(attrSkip), >, component, </custom-element>
4425        assert_fragments!(
4426            records["index.html"].fragments,
4427            [
4428                raw("<custom-element"),
4429                // :config with attrStart
4430                attr_complex_start(":config", "config"),
4431                // Skipped attrs
4432                attr_skip("class", "value0"),
4433                attr_skip("style", "value1"),
4434                attr_skip("role", "value2"),
4435                attr_skip("data-test", "value3"),
4436                attr_skip("aria-test", "value4"),
4437                raw(">"),
4438                component("custom-element"),
4439                raw("</custom-element>"),
4440            ]
4441        );
4442    }
4443
4444    #[test]
4445    fn test_component_attr_skip_static_and_embedded() {
4446        // Skipped attrs with static values and embedded bindings should
4447        // emit fragments (not be silently dropped).
4448        let mut parser = HtmlParser::new();
4449        parser
4450            .component_registry
4451            .register_component("item-group", "<slot></slot>", None, true)
4452            .expect("register");
4453
4454        let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
4455        let result = parser.parse("index.html", html);
4456        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4457        let records = parser.into_fragment_records();
4458
4459        assert_fragments!(
4460            records["index.html"].fragments,
4461            [
4462                raw("<item-group"),
4463                attr_skip_raw("role", "list"),
4464                attr_skip_template("aria-labelledby", "attr-1"),
4465                attr_skip_template("data-testid", "attr-2"),
4466                attr_skip_raw("class", "fixed-class"),
4467                raw(">"),
4468                component("item-group"),
4469                raw("</item-group>"),
4470            ]
4471        );
4472
4473        // Verify the embedded-binding template sub-streams exist and
4474        // contain the expected static + signal fragments.
4475        assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
4476        assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
4477    }
4478
4479    #[test]
4480    fn test_component_multiple_nested() {
4481        // Port of: 'handle multiple nested web components'
4482        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4483        parser
4484            .component_registry
4485            .register_component(
4486                "custom-element",
4487                "<custom-child></custom-child><slot></slot>",
4488                None,
4489                true,
4490            )
4491            .expect("register");
4492        parser
4493            .component_registry
4494            .register_component("custom-button", "<slot></slot>", None, true)
4495            .expect("register");
4496        parser
4497            .component_registry
4498            .register_component("custom-child", "<h1>Hello World!</h1>", None, true)
4499            .expect("register");
4500
4501        let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
4502        let result = parser.parse("index.html", html);
4503        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4504        let records = parser.into_fragment_records();
4505
4506        // Entry stream
4507        assert_fragments!(
4508            records["index.html"].fragments,
4509            [for_loop("item", "items", "for-1"),]
4510        );
4511
4512        // For stream
4513        assert_stream!(
4514            records,
4515            "for-1",
4516            [
4517                raw("<custom-element>"),
4518                component("custom-element"),
4519                raw("<custom-button>"),
4520                component("custom-button"),
4521                raw("Ok</custom-button></custom-element>"),
4522            ]
4523        );
4524
4525        // Component streams — custom-element has contains() checks, keep manual
4526        let ce = &records["custom-element"].fragments;
4527        assert_eq!(ce.len(), 3);
4528        assert!(
4529            matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child>"))
4530        );
4531        assert!(
4532            matches!(ce[1].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
4533        );
4534        assert!(
4535            matches!(ce[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
4536        );
4537
4538        assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
4539
4540        assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
4541    }
4542
4543    // ── Error handling tests ──────────────────────────────────────────
4544
4545    #[test]
4546    fn test_invalid_markup_returns_error() {
4547        // Port of: 'should fail with invalid markup'
4548        let mut parser = HtmlParser::new();
4549        let result = parser.parse("index.html", "<div><span>Unclosed div");
4550        assert!(matches!(result, Err(ParserError::Template(diag)) if
4551            diag.to_string().contains("unclosed <div> tag")
4552        ));
4553    }
4554
4555    #[test]
4556    fn test_unterminated_opening_tag_returns_error() {
4557        let mut parser = HtmlParser::new();
4558        let result = parser.parse("index.html", "before <div");
4559        assert!(matches!(result, Err(ParserError::Template(diag)) if
4560            diag.to_string().contains("malformed HTML tag")
4561        ));
4562    }
4563
4564    #[test]
4565    fn test_unterminated_html_comment_returns_error() {
4566        let mut parser = HtmlParser::new();
4567        let result = parser.parse("index.html", "<div><!-- missing close</div>");
4568        assert!(matches!(result, Err(ParserError::Template(diag)) if
4569            diag.to_string().contains("unclosed <div> tag")
4570                || diag.to_string().contains("unterminated HTML comment")
4571        ));
4572    }
4573
4574    #[test]
4575    fn test_unexpected_closing_tag_returns_error() {
4576        let mut parser = HtmlParser::new();
4577        let result = parser.parse("index.html", "</span>");
4578        assert!(matches!(result, Err(ParserError::Template(diag)) if
4579            diag.to_string().contains("unexpected closing tag </span>")
4580        ));
4581    }
4582
4583    #[test]
4584    fn test_rejects_templates_over_size_limit() {
4585        let mut parser = HtmlParser::new();
4586        let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
4587        let result = parser.parse("index.html", &html);
4588
4589        assert!(matches!(result, Err(ParserError::Html(message)) if
4590            message.contains("exceeds") && message.contains("parser limit")
4591        ));
4592    }
4593
4594    #[test]
4595    fn test_rejects_templates_over_depth_limit() {
4596        let mut parser = HtmlParser::new();
4597        let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
4598        for _ in 0..=MAX_TEMPLATE_DEPTH {
4599            html.push_str("<div>");
4600        }
4601        for _ in 0..=MAX_TEMPLATE_DEPTH {
4602            html.push_str("</div>");
4603        }
4604
4605        let result = parser.parse("index.html", &html);
4606
4607        assert!(matches!(result, Err(ParserError::Template(diag)) if
4608            diag.to_string().contains("nesting exceeds")
4609        ));
4610    }
4611
4612    #[test]
4613    fn test_rejects_routes_over_depth_limit() {
4614        let mut parser = HtmlParser::new();
4615        parser
4616            .component_registry
4617            .register_component("route-page", "<slot></slot>", None, true)
4618            .expect("register");
4619
4620        let depth = MAX_TEMPLATE_DEPTH + 2;
4621        let mut html = String::with_capacity(depth * 48);
4622        for _ in 0..depth {
4623            html.push_str(r#"<route path="x" component="route-page">"#);
4624        }
4625        for _ in 0..depth {
4626            html.push_str("</route>");
4627        }
4628
4629        let result = parser.parse("index.html", &html);
4630
4631        assert!(matches!(result, Err(ParserError::Template(diag)) if
4632            diag.error_code() == Some(codes::EXCESSIVE_NESTING)
4633                && diag.to_string().contains("route nesting exceeds")
4634        ));
4635    }
4636
4637    #[test]
4638    fn test_rejects_recursive_component_templates() {
4639        let mut parser = HtmlParser::new();
4640        parser
4641            .component_registry
4642            .register_component("self-card", "<self-card></self-card>", None, true)
4643            .expect("register");
4644
4645        let result = parser.parse("index.html", "<self-card></self-card>");
4646
4647        assert!(matches!(result, Err(ParserError::Template(diag)) if
4648            diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
4649                && diag.to_string().contains("recursive template reference")
4650        ));
4651    }
4652
4653    // ── Integration tests ─────────────────────────────────────────────
4654
4655    #[test]
4656    fn test_complex_raw_text_full_page() {
4657        // Port of: 'should process a complex raw text'
4658        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>"#;
4659        let (fragments, _) = parse_and_get_fragments(html);
4660
4661        // DOCTYPE + head content, head_end, </head><body>, body_start, body content, body_end, </body></html>
4662        assert!(fragments.len() >= 7);
4663        assert!(
4664            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4665                raw.value.contains("<!DOCTYPE HTML>") && raw.value.contains("<title>Test</title>"))
4666        );
4667        assert!(
4668            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
4669                s.value == "head_end" && s.raw)
4670        );
4671        assert!(
4672            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4673                raw.value.contains("</head>") && raw.value.ends_with("<body>"))
4674        );
4675        assert!(
4676            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
4677                s.value == "body_start" && s.raw)
4678        );
4679        assert!(
4680            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4681                raw.value.contains("<app-shell>"))
4682        );
4683        assert!(
4684            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
4685                s.value == "body_end" && s.raw)
4686        );
4687        assert!(
4688            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4689                raw.value.contains("</body>") && raw.value.contains("</html>"))
4690        );
4691    }
4692
4693    #[test]
4694    fn test_css_strategy_external_emits_link_tag() {
4695        let mut parser = HtmlParser::new();
4696        parser
4697            .component_registry_mut()
4698            .register_component(
4699                "my-card",
4700                "<p><slot></slot></p>",
4701                Some("p { color: red; }"),
4702                true,
4703            )
4704            .ok();
4705        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4706        let records = parser.into_fragment_records();
4707        let my_card = &records["my-card"].fragments;
4708        let raw_text: String = my_card
4709            .iter()
4710            .filter_map(|f| match &f.fragment {
4711                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4712                _ => None,
4713            })
4714            .collect();
4715        assert!(
4716            raw_text.contains(r#"<link rel="stylesheet" href="my-card.css">"#),
4717            "Expected external <link> tag in: {}",
4718            raw_text
4719        );
4720    }
4721
4722    #[test]
4723    fn test_uppercase_style_element_is_processed_as_css() {
4724        // HTML tag names are ASCII case-insensitive: an uppercase `<STYLE>`
4725        // block must be CSS-processed exactly like `<style>`, so a CSS signal
4726        // comment becomes a Signal fragment (not inert raw text) and the
4727        // reconstructed closing tag mirrors the source case.
4728        let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
4729        assert!(
4730            fragments.iter().any(|f| matches!(
4731                f.fragment.as_ref(),
4732                Some(Fragment::Signal(s)) if s.value == "tokens" && !s.raw
4733            )),
4734            "uppercase <STYLE> should be CSS-processed, got: {:?}",
4735            fragments
4736        );
4737        let raw: String = fragments
4738            .iter()
4739            .filter_map(|f| match f.fragment.as_ref() {
4740                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4741                _ => None,
4742            })
4743            .collect();
4744        assert!(
4745            raw.contains("</STYLE>"),
4746            "closing tag should preserve source case, got: {raw}"
4747        );
4748    }
4749
4750    #[test]
4751    fn test_css_strategy_inline_emits_style_tag() {
4752        let mut parser = HtmlParser::with_options(CssStrategy::Style);
4753        parser
4754            .component_registry_mut()
4755            .register_component(
4756                "my-card",
4757                "<p><slot></slot></p>",
4758                Some("p { color: red; }"),
4759                true,
4760            )
4761            .ok();
4762        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4763        let records = parser.into_fragment_records();
4764        let my_card = &records["my-card"].fragments;
4765        let raw_text: String = my_card
4766            .iter()
4767            .filter_map(|f| match &f.fragment {
4768                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4769                _ => None,
4770            })
4771            .collect();
4772        assert!(
4773            raw_text.contains("<style>p { color: red; }</style>"),
4774            "Expected inline <style> tag in: {}",
4775            raw_text
4776        );
4777        assert!(
4778            !raw_text.contains("<link"),
4779            "Should not have <link> tag in inline mode: {}",
4780            raw_text
4781        );
4782    }
4783
4784    #[test]
4785    fn test_css_strategy_module_emits_adopted_stylesheets() {
4786        let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
4787        parser
4788            .component_registry_mut()
4789            .register_component(
4790                "my-card",
4791                "<p><slot></slot></p>",
4792                Some("p { color: red; }"),
4793                true,
4794            )
4795            .ok();
4796        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4797        let records = parser.into_fragment_records();
4798
4799        // Component template should have shadowrootadoptedstylesheets, no CSS
4800        // module in raw fragments (CSS lives on the component fragment's css field)
4801        let my_card = &records["my-card"].fragments;
4802        let template_text: String = my_card
4803            .iter()
4804            .filter_map(|f| match &f.fragment {
4805                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4806                _ => None,
4807            })
4808            .collect();
4809        assert!(
4810            !template_text.contains("shadowrootadoptedstylesheets"),
4811            "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
4812        );
4813        assert!(
4814            !template_text.contains("<link"),
4815            "Should not have <link> in module mode: {template_text}"
4816        );
4817        // No CSS module baked into raw fragments — CSS is stored in
4818        // protocol.components, populated by the build system, and emitted
4819        // by the handler (SSR inline + SPA partials) as importmap scripts.
4820        assert!(
4821            !template_text.contains(r#"<style type="module""#),
4822            "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
4823        );
4824        assert!(
4825            !template_text.contains(r#"<script type="importmap""#),
4826            "CSS module importmap should NOT be in raw fragments: {template_text}"
4827        );
4828    }
4829
4830    #[test]
4831    fn test_css_strategy_module_no_css_no_adopted_attr() {
4832        let mut parser = HtmlParser::with_options(CssStrategy::Module);
4833        parser
4834            .component_registry_mut()
4835            .register_component("my-card", "<p><slot></slot></p>", None, true)
4836            .ok();
4837        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4838        let records = parser.into_fragment_records();
4839        let my_card = &records["my-card"].fragments;
4840        let template_text: String = my_card
4841            .iter()
4842            .filter_map(|f| match &f.fragment {
4843                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4844                _ => None,
4845            })
4846            .collect();
4847        // No CSS → no shadowrootadoptedstylesheets attribute
4848        assert!(
4849            !template_text.contains("shadowrootadoptedstylesheets"),
4850            "Should not have adopted attr without CSS: {template_text}"
4851        );
4852    }
4853
4854    // ── Dev-authored <template> wrapper handling ────────────────────
4855    //
4856    // These tests verify that when a developer includes a `<template>`
4857    // wrapper in their component HTML, the framework respects it instead
4858    // of stripping/normalizing it:
4859    //
4860    //   --dom=light  : dev `<template ...>` preserved verbatim (including
4861    //                  signal-fragment attrs like `foo="{{foo}}"`); no
4862    //                  wrapper added when dev omits one.
4863    //   --dom=shadow : dev `<template ...>` preserved verbatim (framework
4864    //                  does NOT inject `shadowrootmode="open"` or overwrite
4865    //                  a dev-supplied `shadowrootmode="closed"`); wrapper
4866    //                  added only when dev omits one.
4867    //
4868    // Calls `process_component_template` directly so the assertions observe
4869    // the exact HTML string the framework emits for the component template
4870    // (this is what gets handed to the inner parse + plugin hooks).
4871
4872    #[test]
4873    fn light_preserves_dev_template_with_static_attrs() {
4874        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4875        let processed = parser
4876            .process_component_template(
4877                r#"<template foo="bar"><div>hi</div></template>"#,
4878                None,
4879                None,
4880            )
4881            .expect("process failed");
4882        assert!(
4883            processed.contains(r#"<template foo="bar">"#),
4884            "[--dom=light] expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
4885        );
4886        assert!(
4887            processed.contains("</template>"),
4888            "[--dom=light] expected closing </template> preserved, got: {processed}"
4889        );
4890    }
4891
4892    #[test]
4893    fn light_preserves_dev_template_with_signal_fragment_attrs() {
4894        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4895        let processed = parser
4896            .process_component_template(
4897                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
4898                None,
4899                None,
4900            )
4901            .expect("process failed");
4902        assert!(
4903            processed.contains(r#"<template foo="{{foo}}">"#),
4904            "[--dom=light] expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
4905        );
4906    }
4907
4908    #[test]
4909    fn light_preserves_dev_template_with_multiple_attrs() {
4910        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4911        let processed = parser.process_component_template(
4912            r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
4913            None,
4914            None,
4915        )
4916        .expect("process failed");
4917        assert!(
4918            processed.contains("autofocus")
4919                && processed.contains(r#"tabindex="0""#)
4920                && processed.contains(r#"role="region""#)
4921                && processed.contains(r#"data-x="y""#),
4922            "[--dom=light] expected ALL dev template attrs preserved, got: {processed}"
4923        );
4924    }
4925
4926    #[test]
4927    fn light_no_template_no_wrapper_added() {
4928        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4929        let processed = parser
4930            .process_component_template("<div>hi</div>", None, None)
4931            .expect("process failed");
4932        assert!(
4933            !processed.contains("<template"),
4934            "[--dom=light] framework must NOT add <template> wrapper when dev omits one, got: {processed}"
4935        );
4936        assert!(
4937            processed.contains("<div>hi</div>"),
4938            "[--dom=light] expected inner content emitted as-is, got: {processed}"
4939        );
4940    }
4941
4942    #[test]
4943    fn shadow_preserves_dev_template_with_static_attrs() {
4944        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4945        let processed = parser
4946            .process_component_template(
4947                r#"<template foo="bar"><div>hi</div></template>"#,
4948                None,
4949                None,
4950            )
4951            .expect("process failed");
4952        assert!(
4953            processed.contains(r#"<template foo="bar">"#),
4954            "[--dom=shadow] dev <template foo=\"bar\"> must be preserved verbatim, got: {processed}"
4955        );
4956        assert!(
4957            !processed.contains(r#"shadowrootmode="open""#),
4958            "[--dom=shadow] framework must NOT inject shadowrootmode when dev already supplied a <template>, got: {processed}"
4959        );
4960    }
4961
4962    #[test]
4963    fn shadow_preserves_dev_template_with_shadowrootmode_closed() {
4964        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4965        let processed = parser
4966            .process_component_template(
4967                r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
4968                None,
4969                None,
4970            )
4971            .expect("process failed");
4972        assert!(
4973            processed.contains(r#"shadowrootmode="closed""#),
4974            "[--dom=shadow] framework must respect dev's shadowrootmode=\"closed\" (developer is managing), got: {processed}"
4975        );
4976        assert!(
4977            !processed.contains(r#"shadowrootmode="open""#),
4978            "[--dom=shadow] framework must not overwrite dev's shadowrootmode with \"open\", got: {processed}"
4979        );
4980    }
4981
4982    #[test]
4983    fn shadow_preserves_dev_template_with_signal_fragment_attrs() {
4984        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
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=shadow] dev <template> with signal-fragment attr must be preserved, got: {processed}"
4995        );
4996    }
4997
4998    #[test]
4999    fn shadow_adds_template_wrapper_when_dev_omits_it() {
5000        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5001        let processed = parser
5002            .process_component_template("<div>hi</div>", None, None)
5003            .expect("process failed");
5004        assert!(
5005            processed.contains(r#"<template shadowrootmode="open""#),
5006            "[--dom=shadow] framework MUST add <template shadowrootmode=\"open\"> when dev omits a wrapper, got: {processed}"
5007        );
5008        assert!(
5009            processed.contains("<div>hi</div>"),
5010            "[--dom=shadow] inner content must survive wrapping, got: {processed}"
5011        );
5012        assert!(
5013            processed.contains("</template>"),
5014            "[--dom=shadow] framework-added wrapper must be closed, got: {processed}"
5015        );
5016    }
5017
5018    // ── CSS-module adoption on dev-authored <template> wrappers ────────
5019    //
5020    // Regression guard: under `CssStrategy::Module`, the
5021    // `shadowrootadoptedstylesheets` attribute is the only wire between
5022    // the framework-emitted importmap/module-script and the shadow root.
5023    // When the developer supplies their own `<template>` wrapper (e.g.
5024    // to attach `@event` handlers), the framework preserves that wrapper
5025    // and appends the CSS-module adoption attribute when it is missing.
5026
5027    #[test]
5028    fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
5029        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5030        let processed = parser
5031            .process_component_template(
5032                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5033                None,
5034                Some("my-comp"),
5035            )
5036            .expect("process failed");
5037        assert!(
5038            processed.contains(r#"shadowrootmode="open""#),
5039            "dev-authored shadowrootmode must be preserved, got: {processed}"
5040        );
5041        assert!(
5042            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5043            "module CSS should append adopted stylesheets, got: {processed}"
5044        );
5045    }
5046
5047    #[test]
5048    fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
5049        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5050        let processed = parser
5051            .process_component_template(
5052                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
5053                None,
5054                Some("my-comp"),
5055            )
5056            .unwrap();
5057        assert!(
5058            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5059            "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
5060        );
5061        assert_eq!(
5062            processed.matches("shadowrootadoptedstylesheets").count(),
5063            1,
5064            "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
5065        );
5066    }
5067
5068    #[test]
5069    fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
5070        for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
5071            let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
5072            let built = match parser.build_component_templates(
5073                "my-comp",
5074                r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
5075                Some(":host { color: red; }"),
5076                true,
5077            ) {
5078                Ok(built) => built,
5079                Err(err) => panic!(
5080                    "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
5081                ),
5082            };
5083            let artifact = built.artifact();
5084
5085            assert!(
5086                artifact.contains(r#"shadowrootmode="open""#),
5087                "dev-authored shadowrootmode must be preserved under {dom_strategy:?}, got: {artifact}"
5088            );
5089            assert!(
5090                artifact.contains(r#"@click="{onClick()}""#),
5091                "dev-authored root event must be preserved under {dom_strategy:?}, got: {artifact}"
5092            );
5093            assert!(
5094                artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5095                "module CSS should append adopted stylesheets under {dom_strategy:?}, got: {artifact}"
5096            );
5097            assert_eq!(
5098                artifact.matches("shadowrootadoptedstylesheets").count(),
5099                1,
5100                "module CSS should append adopted stylesheets once under {dom_strategy:?}, got: {artifact}"
5101            );
5102            assert!(
5103                !built.ssr.contains("@click"),
5104                "SSR template must still strip runtime attrs, got: {}",
5105                built.ssr
5106            );
5107        }
5108    }
5109
5110    #[test]
5111    fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
5112        // Multi-specifier scenarios: the dev wants to adopt extra sheets
5113        // alongside their own component's module. Honored verbatim — the
5114        // framework's only job is to validate that *some*
5115        // `shadowrootadoptedstylesheets` is present.
5116        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5117        let processed = parser
5118            .process_component_template(
5119                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
5120                None,
5121                Some("my-comp"),
5122            )
5123            .unwrap();
5124        assert!(
5125            processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
5126            "dev's multi-specifier list must be preserved verbatim, got: {processed}"
5127        );
5128    }
5129
5130    #[test]
5131    fn dev_template_no_adopted_specifier_does_not_validate() {
5132        // CssStrategy::Link or CssStrategy::Style pass `adopted_specifier=None`.
5133        // Dev's <template> must be preserved verbatim and the validation
5134        // must not fire.
5135        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5136        let processed = parser
5137            .process_component_template(
5138                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5139                None,
5140                None,
5141            )
5142            .unwrap();
5143        assert!(
5144            !processed.contains("shadowrootadoptedstylesheets"),
5145            "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
5146        );
5147    }
5148
5149    // test_signal_with_default_value — SKIPPED
5150    // The NodeJS `<f-signal value="testSignal">Default Text</f-signal>` feature
5151    // is not supported in the Rust parser. There is no `f-signal` element
5152    // handling in HtmlParser and no corresponding fragment type in
5153    // webui_protocol.
5154
5155    // test_estimated_buffer_size — SKIPPED
5156    // The NodeJS `estimatedBufferSize` field does not exist in the Rust
5157    // WebUIFragmentRecords / WebUIProtocol types. Buffer size estimation is
5158    // not part of the Rust parser output.
5159
5160    #[test]
5161    fn test_body_start_end_injection() {
5162        // Port of: 'should inject body_start and body_end signals around body content'
5163        // Verifies body_start appears immediately after <body> and body_end
5164        // appears immediately before </body> in a full HTML page.
5165        let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
5166        let (fragments, _) = parse_and_get_fragments(html);
5167
5168        assert_fragments!(
5169            fragments,
5170            [
5171                raw("<html><head><title>Test</title>"),
5172                signal_raw("head_end"),
5173                raw("</head><body>"),
5174                signal_raw("body_start"),
5175                raw("<div>Content</div><p>More</p>"),
5176                signal_raw("body_end"),
5177                raw("</body></html>"),
5178            ]
5179        );
5180    }
5181
5182    #[test]
5183    fn test_body_preserves_static_attributes() {
5184        // The <body> tag's attributes must be carried through to the rendered
5185        // HTML, otherwise host frames (e.g. webui-press's `<body data-layout="…">`)
5186        // lose their styling hooks. Earlier versions of `process_body_with_signals`
5187        // emitted a bare `<body>` and dropped every attribute on the element.
5188        let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
5189        let (fragments, _) = parse_and_get_fragments(html);
5190
5191        assert_fragments!(
5192            fragments,
5193            [
5194                raw("<html><head><title>T</title>"),
5195                signal_raw("head_end"),
5196                raw(r#"</head><body data-layout="doc" class="page">"#),
5197                signal_raw("body_start"),
5198                raw("<p>x</p>"),
5199                signal_raw("body_end"),
5200                raw("</body></html>"),
5201            ]
5202        );
5203    }
5204
5205    #[test]
5206    fn test_body_preserves_dynamic_attributes() {
5207        // Dynamic attributes on <body> (e.g. `data-layout="{{layout}}"`) must
5208        // emit attribute fragments rather than being inlined as literal text,
5209        // so the handler binds them to runtime state at render time.
5210        let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
5211        let (fragments, _) = parse_and_get_fragments(html);
5212
5213        assert_fragments!(
5214            fragments,
5215            [
5216                raw("<html><head><title>T</title>"),
5217                signal_raw("head_end"),
5218                raw("</head><body"),
5219                attr("data-layout", "layout"),
5220                raw(">"),
5221                signal_raw("body_start"),
5222                raw("<p>x</p>"),
5223                signal_raw("body_end"),
5224                raw("</body></html>"),
5225            ]
5226        );
5227    }
5228
5229    #[test]
5230    fn test_fail_with_invalid_markup() {
5231        // Port of: 'should fail with invalid markup'
5232        let mut parser = HtmlParser::new();
5233        let result = parser.parse("index.html", "<div><span></div></span>");
5234
5235        assert!(matches!(result, Err(ParserError::Template(diag)) if
5236            diag.to_string().contains("unclosed <span> tag")
5237        ));
5238    }
5239
5240    #[test]
5241    fn test_complex_raw_text_page() {
5242        // Port of: 'should process a complex raw text page with DOCTYPE,
5243        // meta tags, styles, and scripts'
5244        let html = concat!(
5245            "<!DOCTYPE html>",
5246            "<html lang=\"en\">",
5247            "<head>",
5248            "<meta charset=\"utf-8\">",
5249            "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
5250            "<title>Complex Page</title>",
5251            "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
5252            "<link rel=\"stylesheet\" href=\"styles.css\">",
5253            "</head>",
5254            "<body>",
5255            "<h1>Hello World</h1>",
5256            "<script type=\"module\" src=\"./app.js\"></script>",
5257            "</body>",
5258            "</html>",
5259        );
5260        let (fragments, _) = parse_and_get_fragments(html);
5261
5262        // Should have: raw(DOCTYPE+head content), head_end, raw(</head><body>),
5263        // body_start, raw(body content), body_end, raw(</body></html>)
5264        assert!(
5265            fragments.len() >= 7,
5266            "Expected at least 7 fragments, got {}",
5267            fragments.len()
5268        );
5269
5270        // First fragment: DOCTYPE through head content (before </head>)
5271        assert!(
5272            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5273                raw.value.contains("<!DOCTYPE html>") &&
5274                raw.value.contains("<meta charset=\"utf-8\">") &&
5275                raw.value.contains("<meta name=\"viewport\"") &&
5276                raw.value.contains("<title>Complex Page</title>") &&
5277                raw.value.contains("<style>") &&
5278                raw.value.contains("body { margin: 0; padding: 0; }")),
5279            "First fragment should contain all head content, got: {:?}",
5280            fragments[0]
5281        );
5282
5283        // head_end signal
5284        assert!(
5285            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
5286                s.value == "head_end" && s.raw),
5287            "Second fragment should be head_end signal"
5288        );
5289
5290        // </head><body>
5291        assert!(
5292            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5293                raw.value.contains("</head>") && raw.value.ends_with("<body>")),
5294            "Third fragment should contain </head><body>"
5295        );
5296
5297        // body_start signal
5298        assert!(
5299            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
5300                s.value == "body_start" && s.raw),
5301            "Fourth fragment should be body_start signal"
5302        );
5303
5304        // Body content (h1 and script)
5305        assert!(
5306            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5307                raw.value.contains("<h1>Hello World</h1>") &&
5308                raw.value.contains("<script")),
5309            "Fifth fragment should contain body content"
5310        );
5311
5312        // body_end signal
5313        assert!(
5314            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
5315                s.value == "body_end" && s.raw),
5316            "Sixth fragment should be body_end signal"
5317        );
5318
5319        // Closing tags
5320        assert!(
5321            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5322                raw.value.contains("</body>") && raw.value.contains("</html>")),
5323            "Seventh fragment should contain closing tags"
5324        );
5325    }
5326
5327    // --- Binding count tests (with mock plugin) ---
5328
5329    /// Mock plugin that records the binding attribute count for each element.
5330    struct BindingCountPlugin {
5331        counts: Vec<u32>,
5332    }
5333
5334    impl BindingCountPlugin {
5335        fn new() -> Self {
5336            Self { counts: Vec::new() }
5337        }
5338    }
5339
5340    impl crate::plugin::ParserPlugin for BindingCountPlugin {
5341        fn register_component_template(
5342            &mut self,
5343            _tag_name: &str,
5344            _component: &Component,
5345            _processed_template: &str,
5346        ) -> Result<()> {
5347            Ok(())
5348        }
5349
5350        fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
5351            if attr_name.starts_with('@') || attr_name == "f-ref" {
5352                AttributeAction::SkipAndCountBinding
5353            } else {
5354                AttributeAction::Keep
5355            }
5356        }
5357
5358        fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
5359            self.counts.push(binding_attribute_count);
5360            if binding_attribute_count > 0 {
5361                Some(binding_attribute_count.to_le_bytes().to_vec())
5362            } else {
5363                None
5364            }
5365        }
5366    }
5367
5368    struct TemplateCapturePlugin {
5369        template: Option<String>,
5370    }
5371
5372    impl TemplateCapturePlugin {
5373        fn new() -> Self {
5374            Self { template: None }
5375        }
5376    }
5377
5378    impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
5379        fn register_component_template(
5380            &mut self,
5381            _tag_name: &str,
5382            _component: &Component,
5383            processed_template: &str,
5384        ) -> Result<()> {
5385            self.template = Some(processed_template.to_string());
5386            Ok(())
5387        }
5388
5389        fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
5390            if attr_name.starts_with('@') || attr_name == "f-ref" {
5391                AttributeAction::SkipAndCountBinding
5392            } else {
5393                AttributeAction::Keep
5394            }
5395        }
5396
5397        fn finish_element(&mut self, _binding_attribute_count: u32) -> Option<Vec<u8>> {
5398            None
5399        }
5400
5401        fn into_artifacts(self: Box<Self>) -> Result<ParserPluginArtifacts> {
5402            match self.template {
5403                Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
5404                    crate::plugin::ComponentTemplateArtifact::template(
5405                        "todo-app".to_string(),
5406                        template,
5407                    ),
5408                ])),
5409                None => Ok(ParserPluginArtifacts::None),
5410            }
5411        }
5412    }
5413
5414    #[test]
5415    fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
5416        let mut parser = HtmlParser::with_plugin_options(
5417            Box::new(TemplateCapturePlugin::new()),
5418            CssStrategy::Link,
5419        );
5420        parser
5421            .component_registry_mut()
5422            .register_component(
5423                "todo-app",
5424                r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
5425                Some(":host { display: block; }"),
5426             true,)
5427            .expect("register todo-app");
5428
5429        parser
5430            .parse("index.html", "<todo-app></todo-app>")
5431            .expect("parse failed");
5432
5433        let artifacts = parser.take_plugin_artifacts().expect("artifacts");
5434        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5435            panic!("expected captured component template");
5436        };
5437        let template = &templates[0].template;
5438
5439        assert!(
5440            template.contains(r#"shadowrootmode="open""#),
5441            "parser must pass dev-authored root template attrs to plugins, got: {template}"
5442        );
5443        assert!(
5444            template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
5445            "parser must pass dev-authored root event attrs to plugins, got: {template}"
5446        );
5447        assert!(
5448            template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
5449            "parser must pass dev-authored root event attrs to plugins, got: {template}"
5450        );
5451        assert!(
5452            template.contains(r#"f-ref="{root}""#),
5453            "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
5454        );
5455        assert!(
5456            !template.contains("shadowrootadoptedstylesheets"),
5457            "link CSS must not add CSS module adoption attrs, got: {template}"
5458        );
5459    }
5460
5461    #[test]
5462    fn test_component_static_attrs_not_counted_as_bindings() {
5463        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5464        parser
5465            .component_registry
5466            .register_component("my-btn", "<button><slot></slot></button>", None, true)
5467            .expect("register");
5468
5469        // All attributes are static — binding count should be 0
5470        let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
5471        let result = parser.parse("index.html", html);
5472        assert!(result.is_ok());
5473
5474        let records = parser.into_fragment_records();
5475
5476        // No Plugin fragment should appear (binding count = 0)
5477        let frags = &records["index.html"].fragments;
5478        let plugin_count = frags
5479            .iter()
5480            .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
5481            .count();
5482        assert_eq!(
5483            plugin_count, 0,
5484            "Static-only component attributes should not emit a Plugin fragment"
5485        );
5486    }
5487
5488    #[test]
5489    fn test_component_dynamic_attr_counted_as_binding() {
5490        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5491        parser
5492            .component_registry
5493            .register_component("my-btn", "<button><slot></slot></button>", None, true)
5494            .expect("register");
5495
5496        // One dynamic attribute ({{...}}) — binding count should be 1
5497        let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
5498        let result = parser.parse("index.html", html);
5499        assert!(result.is_ok());
5500
5501        let records = parser.into_fragment_records();
5502        let frags = &records["index.html"].fragments;
5503
5504        // Exactly one Plugin fragment with count = 1
5505        let plugin_frags: Vec<_> = frags
5506            .iter()
5507            .filter_map(|f| match f.fragment.as_ref() {
5508                Some(Fragment::Plugin(p)) => Some(&p.data),
5509                _ => None,
5510            })
5511            .collect();
5512        assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
5513        let count = u32::from_le_bytes([
5514            plugin_frags[0][0],
5515            plugin_frags[0][1],
5516            plugin_frags[0][2],
5517            plugin_frags[0][3],
5518        ]);
5519        assert_eq!(count, 1, "Binding attribute count should be 1");
5520    }
5521
5522    #[test]
5523    fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
5524        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5525        parser
5526            .component_registry
5527            .register_component("my-btn", "<button><slot></slot></button>", None, true)
5528            .expect("register");
5529
5530        // 2 static, 1 dynamic, 1 skipped-with-plugin (@click) — only dynamic + skipped counted
5531        let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
5532        let result = parser.parse("index.html", html);
5533        assert!(result.is_ok());
5534
5535        let records = parser.into_fragment_records();
5536        let frags = &records["index.html"].fragments;
5537
5538        let plugin_frags: Vec<_> = frags
5539            .iter()
5540            .filter_map(|f| match f.fragment.as_ref() {
5541                Some(Fragment::Plugin(p)) => Some(&p.data),
5542                _ => None,
5543            })
5544            .collect();
5545        assert_eq!(plugin_frags.len(), 1);
5546        let count = u32::from_le_bytes([
5547            plugin_frags[0][0],
5548            plugin_frags[0][1],
5549            plugin_frags[0][2],
5550            plugin_frags[0][3],
5551        ]);
5552        // 1 dynamic (appearance) + 1 skipped-but-counted (@click) = 2
5553        assert_eq!(
5554            count, 2,
5555            "Binding count should include dynamic + plugin-skipped attrs, not static"
5556        );
5557    }
5558
5559    #[test]
5560    fn test_component_only_skipped_plugin_attrs_counted() {
5561        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5562        parser
5563            .component_registry
5564            .register_component("my-btn", "<button><slot></slot></button>", None, true)
5565            .expect("register");
5566
5567        // Only plugin-skipped attrs (@click, f-ref) plus static — only skipped counted
5568        let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
5569        let result = parser.parse("index.html", html);
5570        assert!(result.is_ok());
5571
5572        let records = parser.into_fragment_records();
5573        let frags = &records["index.html"].fragments;
5574
5575        let plugin_frags: Vec<_> = frags
5576            .iter()
5577            .filter_map(|f| match f.fragment.as_ref() {
5578                Some(Fragment::Plugin(p)) => Some(&p.data),
5579                _ => None,
5580            })
5581            .collect();
5582        assert_eq!(plugin_frags.len(), 1);
5583        let count = u32::from_le_bytes([
5584            plugin_frags[0][0],
5585            plugin_frags[0][1],
5586            plugin_frags[0][2],
5587            plugin_frags[0][3],
5588        ]);
5589        // @click + f-ref = 2, title is static = not counted
5590        assert_eq!(
5591            count, 2,
5592            "Only plugin-skipped attrs should be counted, not static title"
5593        );
5594    }
5595
5596    // ── Comment stripping tests ──────────────────────────────────────
5597
5598    #[test]
5599    fn test_comment_handlebars_signal_is_stripped() {
5600        let mut parser = HtmlParser::new();
5601        let html = "<!--{{tokens}}-->";
5602        parser.parse("test.html", html).expect("parse failed");
5603        let records = parser.into_fragment_records();
5604
5605        assert_stream!(records, "test.html", []);
5606    }
5607
5608    #[test]
5609    fn test_comment_triple_brace_raw_signal_is_stripped() {
5610        let mut parser = HtmlParser::new();
5611        let html = "<!--{{{tokens}}}-->";
5612        parser.parse("test.html", html).expect("parse failed");
5613        let records = parser.into_fragment_records();
5614
5615        assert_stream!(records, "test.html", []);
5616    }
5617
5618    #[test]
5619    fn test_comment_regular_stripped() {
5620        let mut parser = HtmlParser::new();
5621        let html = "<!-- regular comment -->";
5622        parser.parse("test.html", html).expect("parse failed");
5623        let records = parser.into_fragment_records();
5624
5625        assert_stream!(records, "test.html", []);
5626    }
5627
5628    #[test]
5629    fn test_comment_dotted_signal_is_stripped() {
5630        let mut parser = HtmlParser::new();
5631        let html = "<!--{{tokens.light}}-->";
5632        parser.parse("test.html", html).expect("parse failed");
5633        let records = parser.into_fragment_records();
5634
5635        assert_stream!(records, "test.html", []);
5636    }
5637
5638    #[test]
5639    fn test_comment_arbitrary_identifier_is_stripped() {
5640        let mut parser = HtmlParser::new();
5641        let html = "<!--{{someOtherBinding}}-->";
5642        parser.parse("test.html", html).expect("parse failed");
5643        let records = parser.into_fragment_records();
5644
5645        assert_stream!(records, "test.html", []);
5646    }
5647
5648    #[test]
5649    fn test_comment_with_surrounding_content_is_stripped() {
5650        let mut parser = HtmlParser::new();
5651        let html = "<div><!--{{tokens}}--></div>";
5652        parser.parse("test.html", html).expect("parse failed");
5653        let records = parser.into_fragment_records();
5654
5655        assert_stream!(records, "test.html", [raw("<div></div>")]);
5656    }
5657
5658    #[test]
5659    fn test_component_template_unterminated_comment_returns_error() {
5660        let mut parser = HtmlParser::new();
5661        parser
5662            .component_registry_mut()
5663            .register_component("x-bad", "<span>ok</span><!-- missing close", None, true)
5664            .expect("register failed");
5665
5666        let result = parser.parse("index.html", "<x-bad></x-bad>");
5667
5668        assert!(matches!(result, Err(ParserError::Template(diag)) if
5669            diag.to_string().contains("unterminated HTML comment")
5670        ));
5671    }
5672
5673    #[test]
5674    fn test_webui_plugin_strips_component_comments_before_metadata() {
5675        let mut parser =
5676            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5677        parser
5678            .component_registry_mut()
5679            .register_component(
5680                "x-bleed",
5681                r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
5682                None,
5683                true,
5684            )
5685            .expect("register failed");
5686
5687        parser
5688            .parse("index.html", "<x-bleed></x-bleed>")
5689            .expect("parse failed");
5690
5691        let artifacts = parser.take_plugin_artifacts().unwrap();
5692        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5693            panic!("expected component templates");
5694        };
5695        let template = &templates[0].template_json;
5696        assert!(template.contains("<div>hello</div>"));
5697        assert!(!template.contains("{{path}}"));
5698        assert!(!template.contains("@click"));
5699        assert!(!template.contains("?hidden"));
5700        assert!(!template.contains("-->"));
5701    }
5702
5703    #[test]
5704    fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
5705        let mut parser =
5706            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5707        parser
5708            .component_registry_mut()
5709            .register_component(
5710                "x-style",
5711                "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
5712                None,
5713                true,
5714            )
5715            .expect("register failed");
5716
5717        parser
5718            .parse("index.html", "<x-style></x-style>")
5719            .expect("parse failed");
5720
5721        let artifacts = parser.take_plugin_artifacts().unwrap();
5722        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5723            panic!("expected component templates");
5724        };
5725        let template = &templates[0].template_json;
5726        assert!(template.contains(".x { color: red; }"));
5727        assert!(!template.contains("ignored"));
5728        assert!(!template.contains("//"));
5729    }
5730
5731    // ── Token collection tests ───────────────────────────────────────
5732
5733    #[test]
5734    fn test_tokens_from_style_tag() {
5735        let mut parser = HtmlParser::new();
5736        let html = r#"<style>
5737            .btn { color: var(--colorPrimary); background: var(--bgColor); }
5738        </style>"#;
5739        parser.parse("test.html", html).expect("parse failed");
5740        let tokens = parser.take_tokens();
5741
5742        assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
5743    }
5744
5745    #[test]
5746    fn test_tokens_from_malformed_style_error_on_unclosed_var() {
5747        let mut parser = HtmlParser::new();
5748        let html = r#"<style>
5749            .bad { color: var(--dangling; }
5750            .ok { color: var(--valid); }
5751        </style>"#;
5752        let result = parser.parse("test.html", html);
5753
5754        assert!(matches!(result, Err(ParserError::Template(diag)) if
5755            diag.error_code() == Some(codes::INVALID_CSS)
5756                && diag.to_string().contains("Unterminated CSS var() call")
5757        ));
5758    }
5759
5760    #[test]
5761    fn test_tokens_from_component_css() {
5762        let mut parser = HtmlParser::new();
5763        parser
5764            .component_registry_mut()
5765            .register_component(
5766                "my-button",
5767                "<button>Click</button>",
5768                Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
5769                true,
5770            )
5771            .expect("register failed");
5772
5773        let html = "<my-button></my-button>";
5774        parser.parse("test.html", html).expect("parse failed");
5775        let tokens = parser.take_tokens();
5776
5777        assert_eq!(tokens, vec!["borderWidth", "textColor"]);
5778    }
5779
5780    #[test]
5781    fn test_token_requirements_preserve_fallback_chains() {
5782        let mut parser = HtmlParser::new();
5783        parser
5784            .component_registry_mut()
5785            .register_component(
5786                "my-card",
5787                "<div>Card</div>",
5788                Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
5789                true,
5790            )
5791            .expect("register failed");
5792
5793        parser
5794            .parse("test.html", "<my-card></my-card>")
5795            .expect("parse failed");
5796        let analysis = parser.token_analysis();
5797
5798        assert_eq!(analysis.fallback_chains.len(), 1);
5799        assert_eq!(
5800            analysis.fallback_chains[0].tokens,
5801            vec!["token-a", "token-b", "token-c"]
5802        );
5803        assert!(!analysis.fallback_chains[0].has_literal_fallback);
5804    }
5805
5806    #[test]
5807    fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
5808        let mut parser = HtmlParser::new();
5809        parser
5810            .component_registry_mut()
5811            .register_component(
5812                "my-card",
5813                "<div>Card</div>",
5814                Some(
5815                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
5816                ),
5817             true,)
5818            .expect("register failed");
5819
5820        parser
5821            .parse("test.html", "<my-card></my-card>")
5822            .expect("parse failed");
5823        let analysis = parser.token_analysis();
5824
5825        assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
5826        assert_eq!(analysis.fallback_chains.len(), 1);
5827        assert_eq!(
5828            analysis.fallback_chains[0].tokens,
5829            vec!["token-b", "token-c"]
5830        );
5831    }
5832
5833    #[test]
5834    fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
5835        let mut parser = HtmlParser::new();
5836        parser
5837            .component_registry_mut()
5838            .register_component(
5839                "my-card",
5840                "<div>Card</div>",
5841                Some(
5842                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
5843                ),
5844             true,)
5845            .expect("register failed");
5846        parser
5847            .parse("test.html", "<my-card></my-card>")
5848            .expect("parse failed");
5849
5850        let analysis = parser.token_analysis();
5851        let theme = webui_tokens::TokenFile {
5852            themes: HashMap::from([(
5853                "light".to_string(),
5854                HashMap::from([("token-b".to_string(), "green".to_string())]),
5855            )]),
5856        };
5857
5858        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
5859            panic!("missing --token-c in the theme must fail parser validation");
5860        };
5861        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
5862        // The help is concise: a typo suggestion plus the local-definition
5863        // escape hatch — it does not restate "add --token-c to theme".
5864        let help = diagnostic.help_text().expect("help text");
5865        assert!(help.contains("did you mean --token-b?"), "help: {help}");
5866        assert!(help.contains("define it locally"), "help: {help}");
5867        assert!(!help.contains("add --token"), "help: {help}");
5868    }
5869
5870    #[test]
5871    fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
5872        let mut parser = HtmlParser::new();
5873        parser
5874            .component_registry_mut()
5875            .register_component(
5876                "my-card",
5877                "<div>Card</div>",
5878                Some(":host { color: var(--brand, #000); }"),
5879                true,
5880            )
5881            .expect("register failed");
5882        parser
5883            .parse("test.html", "<my-card></my-card>")
5884            .expect("parse failed");
5885
5886        let analysis = parser.token_analysis();
5887        // The token is still hoisted so the runtime resolves it when a theme
5888        // does provide it.
5889        assert_eq!(analysis.protocol_tokens, vec!["brand"]);
5890
5891        // A theme without `--brand` must NOT fail the build: the CSS literal
5892        // fallback (`#000`) already provides a value.
5893        let theme = webui_tokens::TokenFile {
5894            themes: HashMap::from([("light".to_string(), HashMap::new())]),
5895        };
5896        analysis
5897            .validate_theme_tokens(&theme)
5898            .expect("literal fallback must exempt --brand from theme validation");
5899    }
5900
5901    #[test]
5902    fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
5903        let mut parser = HtmlParser::new();
5904        parser
5905            .component_registry_mut()
5906            .register_component(
5907                "my-card",
5908                "<div>Card</div>",
5909                // One usage has a literal fallback, the other does not. The
5910                // bare `var(--brand)` makes the token genuinely required.
5911                Some(":host { color: var(--brand, #000); background: var(--brand); }"),
5912                true,
5913            )
5914            .expect("register failed");
5915        parser
5916            .parse("test.html", "<my-card></my-card>")
5917            .expect("parse failed");
5918
5919        let analysis = parser.token_analysis();
5920        let theme = webui_tokens::TokenFile {
5921            themes: HashMap::from([("light".to_string(), HashMap::new())]),
5922        };
5923
5924        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
5925            panic!("a bare var(--brand) usage must require --brand in the theme");
5926        };
5927        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
5928        assert!(diagnostic.to_string().contains("--brand"));
5929    }
5930
5931    #[test]
5932    fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
5933        let mut parser = HtmlParser::new();
5934        parser
5935            .component_registry_mut()
5936            .register_component(
5937                "my-card",
5938                "<div>Card</div>",
5939                Some(
5940                    ":host { \
5941                       color: var(--colr-brand, #000); \
5942                       border: var(--present, 1px); \
5943                       margin: var(--required); \
5944                     }",
5945                ),
5946                true,
5947            )
5948            .expect("register failed");
5949        parser
5950            .parse("test.html", "<my-card></my-card>")
5951            .expect("parse failed");
5952
5953        let analysis = parser.token_analysis();
5954        let theme = webui_tokens::TokenFile {
5955            themes: HashMap::from([(
5956                "light".to_string(),
5957                HashMap::from([
5958                    ("present".to_string(), "2px".to_string()),
5959                    ("required".to_string(), "8px".to_string()),
5960                ]),
5961            )]),
5962        };
5963
5964        // `colr-brand`: literal-only and absent from every theme → warned.
5965        // `present`: literal-only but defined in the theme → not warned.
5966        // `required`: has no literal fallback → a validation concern, not a warning.
5967        assert_eq!(
5968            analysis.unthemed_literal_fallback_tokens(&theme),
5969            vec!["colr-brand".to_string()]
5970        );
5971    }
5972
5973    #[test]
5974    fn test_theme_token_error_reports_location_and_suggestion() {
5975        let mut parser = HtmlParser::new();
5976        parser
5977            .component_registry_mut()
5978            .register_component(
5979                "my-card",
5980                "<div>Card</div>",
5981                ":host {\n  color: var(--color-neutral-2000);\n}".into(),
5982                true,
5983            )
5984            .expect("register failed");
5985        parser
5986            .parse("test.html", "<my-card></my-card>")
5987            .expect("parse failed");
5988
5989        let analysis = parser.token_analysis();
5990        let theme = webui_tokens::TokenFile {
5991            themes: HashMap::from([(
5992                "dark".to_string(),
5993                HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
5994            )]),
5995        };
5996
5997        let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
5998            panic!("missing --color-neutral-2000 must fail validation");
5999        };
6000        assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
6001        // File + line:column, like other authoring diagnostics.
6002        let location = diag.location().expect("a source location");
6003        assert!(location.contains("my-card.css:2:"), "location: {location}");
6004        // Snippet shows the offending CSS line.
6005        assert!(
6006            diag.snippet_text()
6007                .is_some_and(|s| s.contains("--color-neutral-2000")),
6008            "snippet: {:?}",
6009            diag.snippet_text()
6010        );
6011        // Did-you-mean from the theme's own tokens.
6012        assert!(
6013            diag.help_text()
6014                .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
6015            "help: {:?}",
6016            diag.help_text()
6017        );
6018    }
6019
6020    #[test]
6021    fn test_theme_token_warning_reports_location_and_suggestion() {
6022        let mut parser = HtmlParser::new();
6023        parser
6024            .component_registry_mut()
6025            .register_component(
6026                "my-card",
6027                "<div>Card</div>",
6028                ":host {\n  color: var(--colr-brand, #000);\n}".into(),
6029                true,
6030            )
6031            .expect("register failed");
6032        parser
6033            .parse("test.html", "<my-card></my-card>")
6034            .expect("parse failed");
6035
6036        let analysis = parser.token_analysis();
6037        let theme = webui_tokens::TokenFile {
6038            themes: HashMap::from([(
6039                "light".to_string(),
6040                HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
6041            )]),
6042        };
6043
6044        let warnings = analysis.theme_token_warnings(&theme);
6045        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
6046        let body = warnings[0].body();
6047        assert!(body.contains("my-card.css:2:"), "warning: {body}");
6048        assert!(body.contains("--colr-brand"), "warning: {body}");
6049        assert!(
6050            body.contains("did you mean --color-brand?"),
6051            "warning: {body}"
6052        );
6053    }
6054
6055    #[test]
6056    fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
6057        let mut parser = HtmlParser::new();
6058        let result = parser.component_registry_mut().register_component(
6059            "my-card",
6060            "<div>Card</div>",
6061            Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
6062            true,
6063        );
6064
6065        assert!(matches!(result, Err(ParserError::Css(message)) if
6066            message.contains("Unterminated CSS var() call")
6067        ));
6068    }
6069
6070    #[test]
6071    fn test_tokens_merged_from_style_and_components() {
6072        let mut parser = HtmlParser::new();
6073        parser
6074            .component_registry_mut()
6075            .register_component(
6076                "my-widget",
6077                "<div>Widget</div>",
6078                Some(".w { padding: var(--spacingM); }"),
6079                true,
6080            )
6081            .expect("register failed");
6082
6083        let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
6084        parser.parse("test.html", html).expect("parse failed");
6085        let tokens = parser.take_tokens();
6086
6087        assert_eq!(tokens, vec!["spacingM", "textColor"]);
6088    }
6089
6090    #[test]
6091    fn test_tokens_deduplicated() {
6092        let mut parser = HtmlParser::new();
6093        parser
6094            .component_registry_mut()
6095            .register_component(
6096                "my-btn",
6097                "<button>B</button>",
6098                Some(".b { color: var(--shared); }"),
6099                true,
6100            )
6101            .expect("register failed");
6102
6103        let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
6104        parser.parse("test.html", html).expect("parse failed");
6105        let tokens = parser.take_tokens();
6106
6107        assert_eq!(tokens, vec!["shared"]);
6108    }
6109
6110    #[test]
6111    fn test_tokens_empty_when_no_vars() {
6112        let mut parser = HtmlParser::new();
6113        let html = "<div>Hello</div>";
6114        parser.parse("test.html", html).expect("parse failed");
6115        let tokens = parser.take_tokens();
6116
6117        assert!(tokens.is_empty());
6118    }
6119
6120    #[test]
6121    fn test_tokens_exclude_locally_defined_in_component() {
6122        let mut parser = HtmlParser::new();
6123        parser
6124            .component_registry_mut()
6125            .register_component(
6126                "my-card",
6127                "<div>Card</div>",
6128                Some(":host { --local: 5px; width: var(--external); }"),
6129                true,
6130            )
6131            .expect("register failed");
6132
6133        let html = "<my-card></my-card>";
6134        parser.parse("test.html", html).expect("parse failed");
6135        let tokens = parser.take_tokens();
6136
6137        assert_eq!(tokens, vec!["external"]);
6138    }
6139
6140    #[test]
6141    fn test_tokens_exclude_entry_root_definitions() {
6142        let mut parser = HtmlParser::new();
6143        parser
6144            .component_registry_mut()
6145            .register_component(
6146                "my-btn",
6147                "<button>B</button>",
6148                Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
6149                true,
6150            )
6151            .expect("register failed");
6152
6153        // Entry HTML defines --color-primary and --radius-m in :root
6154        // Components use them — they should NOT appear in hoisted tokens
6155        let html = r#"<style>
6156            :root {
6157                --color-primary: #0078d4;
6158                --radius-m: 6px;
6159            }
6160            body { color: var(--color-primary); }
6161        </style>
6162        <my-btn></my-btn>"#;
6163        parser.parse("test.html", html).expect("parse failed");
6164        let tokens = parser.take_tokens();
6165
6166        // Both tokens are defined in entry :root, so neither should be hoisted
6167        assert!(
6168            tokens.is_empty(),
6169            "Tokens defined in entry :root should be excluded: {tokens:?}"
6170        );
6171    }
6172
6173    #[test]
6174    fn test_tokens_entry_defs_exclude_but_external_kept() {
6175        let mut parser = HtmlParser::new();
6176        parser
6177            .component_registry_mut()
6178            .register_component(
6179                "my-card",
6180                "<div>Card</div>",
6181                Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
6182                true,
6183            )
6184            .expect("register failed");
6185
6186        // Entry defines --color-primary but NOT --external-spacing
6187        let html = r#"<style>
6188            :root { --color-primary: #0078d4; }
6189        </style>
6190        <my-card></my-card>"#;
6191        parser.parse("test.html", html).expect("parse failed");
6192        let tokens = parser.take_tokens();
6193
6194        // Only --external-spacing should be hoisted (not defined in entry)
6195        assert_eq!(tokens, vec!["external-spacing"]);
6196    }
6197
6198    // ── Route parsing tests ─────────────────────────────────────────────
6199
6200    #[test]
6201    fn test_parse_simple_route() {
6202        let mut parser = HtmlParser::new();
6203        let html = r#"<route path="/profile" component="profile-page" exact />"#;
6204        parser.parse("test.html", html).expect("parse failed");
6205
6206        // Routes are emitted as Fragment::Route
6207        let records = parser.into_fragment_records();
6208        let frags = &records["test.html"].fragments;
6209        assert_eq!(frags.len(), 1);
6210        match frags[0].fragment.as_ref() {
6211            Some(web_ui_fragment::Fragment::Route(r)) => {
6212                assert_eq!(r.path, "/profile");
6213                assert_eq!(r.fragment_id, "profile-page");
6214                assert!(r.exact);
6215            }
6216            other => panic!("expected Fragment::Route, got {:?}", other),
6217        }
6218    }
6219
6220    #[test]
6221    fn test_parse_route_with_params() {
6222        let mut parser = HtmlParser::new();
6223        let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
6224        parser.parse("test.html", html).expect("parse failed");
6225
6226        // Route is emitted as Fragment::Route with correct path
6227        let records = parser.into_fragment_records();
6228        let frags = &records["test.html"].fragments;
6229        match frags[0].fragment.as_ref() {
6230            Some(web_ui_fragment::Fragment::Route(r)) => {
6231                assert_eq!(r.path, "/profile/:id/view/:section");
6232            }
6233            other => panic!("expected Fragment::Route, got {:?}", other),
6234        }
6235    }
6236
6237    #[test]
6238    fn test_parse_route_requires_component() {
6239        let mut parser = HtmlParser::new();
6240        let html = r#"<route path="/old" />"#;
6241        let result = parser.parse("test.html", html);
6242        assert!(result.is_err());
6243    }
6244
6245    #[test]
6246    fn test_parse_multiple_routes() {
6247        let mut parser = HtmlParser::new();
6248        let html = r#"<route path="/" component="app-layout" />
6249            <route path="/dashboard" component="dash-page" exact />
6250            <route path="/contacts" component="contacts-page" exact />"#;
6251        parser.parse("test.html", html).expect("parse failed");
6252
6253        let records = parser.into_fragment_records();
6254        let frags = &records["test.html"].fragments;
6255        assert_eq!(frags.len(), 3);
6256        // All should be Fragment::Route
6257        for frag in frags {
6258            assert!(matches!(
6259                frag.fragment.as_ref(),
6260                Some(web_ui_fragment::Fragment::Route(_))
6261            ));
6262        }
6263    }
6264
6265    #[test]
6266    fn test_parse_route_requires_component_with_body() {
6267        let mut parser = HtmlParser::new();
6268        let html = r#"<route path="/error">
6269            <div class="error"><h1>Not Found</h1></div>
6270        </route>"#;
6271        let result = parser.parse("test.html", html);
6272        assert!(
6273            result.is_err(),
6274            "Route without component attribute should fail"
6275        );
6276    }
6277
6278    #[test]
6279    fn test_parse_multiple_routes_with_fragments() {
6280        let mut parser = HtmlParser::new();
6281        let html = r#"<route path="/" component="home-page" exact />
6282            <route path="/about" component="about-page" exact />
6283            <route path="/contact/:id" component="contact-page" />"#;
6284        parser.parse("test.html", html).expect("parse failed");
6285
6286        let records = parser.into_fragment_records();
6287        let frags = &records["test.html"].fragments;
6288        assert_eq!(frags.len(), 3);
6289
6290        // Verify individual routes
6291        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
6292            assert_eq!(r.path, "/");
6293        }
6294        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
6295            assert_eq!(r.path, "/about");
6296        }
6297        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
6298            assert_eq!(r.path, "/contact/:id");
6299        }
6300    }
6301
6302    #[test]
6303    fn test_parse_route_with_query_allowlist() {
6304        let mut parser = HtmlParser::new();
6305        let html =
6306            r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
6307        parser.parse("test.html", html).expect("parse failed");
6308
6309        let records = parser.into_fragment_records();
6310        let frags = &records["test.html"].fragments;
6311        assert_eq!(frags.len(), 1);
6312        match frags[0].fragment.as_ref() {
6313            Some(web_ui_fragment::Fragment::Route(r)) => {
6314                assert_eq!(r.path, "/compose");
6315                assert_eq!(r.fragment_id, "compose-page");
6316                assert!(r.exact);
6317                assert_eq!(r.allowed_query, "action,to,subject");
6318            }
6319            other => panic!("expected Fragment::Route, got {:?}", other),
6320        }
6321    }
6322
6323    #[test]
6324    fn test_parse_route_without_query_has_empty_allowed_query() {
6325        let mut parser = HtmlParser::new();
6326        let html = r#"<route path="/profile" component="profile-page" exact />"#;
6327        parser.parse("test.html", html).expect("parse failed");
6328
6329        let records = parser.into_fragment_records();
6330        let frags = &records["test.html"].fragments;
6331        match frags[0].fragment.as_ref() {
6332            Some(web_ui_fragment::Fragment::Route(r)) => {
6333                assert!(r.allowed_query.is_empty());
6334            }
6335            other => panic!("expected Fragment::Route, got {:?}", other),
6336        }
6337    }
6338
6339    #[test]
6340    fn test_route_body_rejects_malformed_non_route_html() {
6341        let mut parser = HtmlParser::new();
6342        let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
6343        let result = parser.parse("test.html", html);
6344
6345        assert!(matches!(result, Err(ParserError::Template(diag)) if
6346            diag.to_string().contains("unclosed <span> tag")
6347        ));
6348    }
6349
6350    #[test]
6351    fn test_route_body_rejects_malformed_style_css() {
6352        let mut parser = HtmlParser::new();
6353        let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
6354        let result = parser.parse("test.html", html);
6355
6356        assert!(matches!(result, Err(ParserError::Template(diag)) if
6357            diag.error_code() == Some(codes::INVALID_CSS)
6358                && diag.to_string().contains("Unterminated CSS var() call")
6359        ));
6360    }
6361
6362    #[test]
6363    fn test_outlet_not_captured_by_for_loop() {
6364        let mut parser = HtmlParser::new();
6365        let html = r#"<template shadowrootmode="open">
6366  <ul>
6367    <for each="item in items">
6368      <li>{{item.name}}</li>
6369    </for>
6370  </ul>
6371  <main>
6372    <outlet />
6373  </main>
6374</template>"#;
6375        parser.parse("comp.html", html).expect("parse failed");
6376
6377        let records = parser.into_fragment_records();
6378        let frags = &records["comp.html"].fragments;
6379
6380        // The outlet should be a top-level fragment, NOT inside the for-loop's body
6381        let outlet_count = frags
6382            .iter()
6383            .filter(|f| {
6384                matches!(
6385                    f.fragment.as_ref(),
6386                    Some(web_ui_fragment::Fragment::Outlet(_))
6387                )
6388            })
6389            .count();
6390        assert_eq!(
6391            outlet_count, 1,
6392            "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
6393        );
6394
6395        // Verify outlet comes AFTER the raw "</ul>" text
6396        let outlet_idx = frags
6397            .iter()
6398            .position(|f| {
6399                matches!(
6400                    f.fragment.as_ref(),
6401                    Some(web_ui_fragment::Fragment::Outlet(_))
6402                )
6403            })
6404            .expect("no outlet found");
6405        let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
6406            Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
6407            _ => false,
6408        });
6409        if let Some(ul_idx) = close_ul_idx {
6410            assert!(
6411                outlet_idx > ul_idx,
6412                "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
6413            );
6414        }
6415    }
6416
6417    #[test]
6418    fn test_outlet_position_after_for_not_inside() {
6419        let mut parser = HtmlParser::new();
6420        let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
6421        parser.parse("test.html", html).expect("parse failed");
6422
6423        let records = parser.into_fragment_records();
6424        let frags = &records["test.html"].fragments;
6425
6426        // Outlet should be its own fragment at top level
6427        let has_outlet = frags.iter().any(|f| {
6428            matches!(
6429                f.fragment.as_ref(),
6430                Some(web_ui_fragment::Fragment::Outlet(_))
6431            )
6432        });
6433        assert!(
6434            has_outlet,
6435            "outlet should be in top-level fragments: {frags:?}"
6436        );
6437
6438        // The for-loop body should NOT contain the outlet
6439        let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
6440            Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
6441            _ => None,
6442        });
6443        if let Some(id) = for_id {
6444            let for_frags = &records[&id].fragments;
6445            let outlet_in_for = for_frags.iter().any(|f| {
6446                matches!(
6447                    f.fragment.as_ref(),
6448                    Some(web_ui_fragment::Fragment::Outlet(_))
6449                )
6450            });
6451            assert!(
6452                !outlet_in_for,
6453                "outlet should NOT be inside for-loop body: {for_frags:?}"
6454            );
6455        }
6456    }
6457
6458    #[test]
6459    fn test_style_element_with_handlebars_signal_comment_emits_signal() {
6460        let mut parser = HtmlParser::new();
6461        let html = r#"<html><head><style>
6462:root {
6463    /*{{{tokens.light}}}*/
6464}
6465</style></head><body></body></html>"#;
6466
6467        let result = parser.parse("test.html", html);
6468        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6469        let fragment_records = parser.into_fragment_records();
6470
6471        assert_stream!(
6472            fragment_records,
6473            "test.html",
6474            [
6475                raw("<html><head><style>\n:root {\n    "),
6476                signal_raw("tokens.light"),
6477                raw("\n}\n</style>"),
6478                signal_raw("head_end"),
6479                raw("</head><body>"),
6480                signal_raw("body_start"),
6481                signal_raw("body_end"),
6482                raw("</body></html>"),
6483            ]
6484        );
6485    }
6486
6487    #[test]
6488    fn test_style_comment_signal_with_spaces_emits_signal() {
6489        let mut parser = HtmlParser::new();
6490        let html = r#"<html><head><style>
6491:root {
6492    /* {{{tokens.light}}} */
6493}
6494</style></head><body></body></html>"#;
6495
6496        let result = parser.parse("test.html", html);
6497        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6498        let fragment_records = parser.into_fragment_records();
6499
6500        assert_stream!(
6501            fragment_records,
6502            "test.html",
6503            [
6504                raw("<html><head><style>\n:root {\n    "),
6505                signal_raw("tokens.light"),
6506                raw("\n}\n</style>"),
6507                signal_raw("head_end"),
6508                raw("</head><body>"),
6509                signal_raw("body_start"),
6510                signal_raw("body_end"),
6511                raw("</body></html>"),
6512            ]
6513        );
6514    }
6515
6516    #[test]
6517    fn test_style_comment_signal_double_brace_emits_signal() {
6518        let mut parser = HtmlParser::new();
6519        let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
6520
6521        let result = parser.parse("test.html", html);
6522        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6523        let fragment_records = parser.into_fragment_records();
6524
6525        assert_stream!(
6526            fragment_records,
6527            "test.html",
6528            [
6529                raw("<html><head><style>"),
6530                signal("themeCss"),
6531                raw("</style>"),
6532                signal_raw("head_end"),
6533                raw("</head><body>"),
6534                signal_raw("body_start"),
6535                signal_raw("body_end"),
6536                raw("</body></html>"),
6537            ]
6538        );
6539    }
6540
6541    #[test]
6542    fn test_style_comment_with_extra_text_is_stripped() {
6543        let mut parser = HtmlParser::new();
6544        let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
6545
6546        let result = parser.parse("test.html", html);
6547        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6548        let fragment_records = parser.into_fragment_records();
6549
6550        assert_stream!(
6551            fragment_records,
6552            "test.html",
6553            [
6554                raw("<html><head><style></style>"),
6555                signal_raw("head_end"),
6556                raw("</head><body>"),
6557                signal_raw("body_start"),
6558                signal_raw("body_end"),
6559                raw("</body></html>"),
6560            ]
6561        );
6562    }
6563
6564    #[test]
6565    fn test_style_comment_with_multiple_signals_is_stripped() {
6566        let mut parser = HtmlParser::new();
6567        let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
6568
6569        let result = parser.parse("test.html", html);
6570        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6571        let fragment_records = parser.into_fragment_records();
6572
6573        assert_stream!(
6574            fragment_records,
6575            "test.html",
6576            [
6577                raw("<html><head><style></style>"),
6578                signal_raw("head_end"),
6579                raw("</head><body>"),
6580                signal_raw("body_start"),
6581                signal_raw("body_end"),
6582                raw("</body></html>"),
6583            ]
6584        );
6585    }
6586
6587    #[test]
6588    fn test_style_bare_handlebars_stays_raw() {
6589        let mut parser = HtmlParser::new();
6590        // Handlebars outside a CSS comment must NOT be parsed as signals.
6591        let html =
6592            "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
6593
6594        let result = parser.parse("test.html", html);
6595        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6596        let fragment_records = parser.into_fragment_records();
6597
6598        assert_stream!(
6599            fragment_records,
6600            "test.html",
6601            [
6602                raw("<html><head><style>body { color: {{textColor}}; }</style>"),
6603                signal_raw("head_end"),
6604                raw("</head><body>"),
6605                signal_raw("body_start"),
6606                signal_raw("body_end"),
6607                raw("</body></html>"),
6608            ]
6609        );
6610    }
6611
6612    #[test]
6613    fn test_style_mixed_css_and_comment_signal_emits_signal() {
6614        let mut parser = HtmlParser::new();
6615        let html = r#"<html><head><style>
6616  .a { color: red; }
6617  /*{{themeCss}}*/
6618  .b { color: blue; }
6619</style></head><body></body></html>"#;
6620
6621        let result = parser.parse("test.html", html);
6622        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6623        let fragment_records = parser.into_fragment_records();
6624
6625        assert_stream!(
6626            fragment_records,
6627            "test.html",
6628            [
6629                raw("<html><head><style>\n  .a { color: red; }\n  "),
6630                signal("themeCss"),
6631                raw("\n  .b { color: blue; }\n</style>"),
6632                signal_raw("head_end"),
6633                raw("</head><body>"),
6634                signal_raw("body_start"),
6635                signal_raw("body_end"),
6636                raw("</body></html>"),
6637            ]
6638        );
6639    }
6640
6641    #[test]
6642    fn test_style_legal_comment_preserved_inline_by_default() {
6643        let mut parser = HtmlParser::new();
6644        let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
6645
6646        parser.parse("test.html", html).expect("parse failed");
6647        let fragment_records = parser.into_fragment_records();
6648
6649        assert_stream!(
6650            fragment_records,
6651            "test.html",
6652            [
6653                raw("<html><head><style>/*! @license MIT */ .x { color: red; } </style>"),
6654                signal_raw("head_end"),
6655                raw("</head><body>"),
6656                signal_raw("body_start"),
6657                signal_raw("body_end"),
6658                raw("</body></html>"),
6659            ]
6660        );
6661    }
6662
6663    #[test]
6664    fn test_style_legal_comments_none_strips_legal_comment() {
6665        let mut parser = HtmlParser::with_options(ParserOptions {
6666            legal_comments: LegalComments::None,
6667            ..ParserOptions::default()
6668        });
6669        let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
6670
6671        parser.parse("test.html", html).expect("parse failed");
6672        let fragment_records = parser.into_fragment_records();
6673
6674        assert_stream!(
6675            fragment_records,
6676            "test.html",
6677            [
6678                raw("<html><head><style> .x { color: red; }</style>"),
6679                signal_raw("head_end"),
6680                raw("</head><body>"),
6681                signal_raw("body_start"),
6682                signal_raw("body_end"),
6683                raw("</body></html>"),
6684            ]
6685        );
6686    }
6687
6688    #[test]
6689    fn test_style_element_plain_css_unchanged() {
6690        let mut parser = HtmlParser::new();
6691        let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
6692
6693        let result = parser.parse("test.html", html);
6694        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6695        let fragment_records = parser.into_fragment_records();
6696
6697        // Plain CSS with no handlebars should remain as a single raw fragment.
6698        assert_stream!(
6699            fragment_records,
6700            "test.html",
6701            [
6702                raw("<html><head><style>body { margin: 0; }</style>"),
6703                signal_raw("head_end"),
6704                raw("</head><body>"),
6705                signal_raw("body_start"),
6706                signal_raw("body_end"),
6707                raw("</body></html>"),
6708            ]
6709        );
6710    }
6711
6712    #[test]
6713    fn test_unclosed_style_element_returns_error() {
6714        let mut parser = HtmlParser::new();
6715        let result = parser.parse("index.html", "<style>.x { color: red; ");
6716        assert!(matches!(result, Err(ParserError::Template(diag)) if
6717            diag.to_string().contains("unclosed <style> tag")
6718        ));
6719    }
6720
6721    #[test]
6722    fn test_text_with_binding_after_void_element() {
6723        // Text nodes containing {{binding}} after void elements like <br>
6724        // must be preserved. Regression test for content being dropped.
6725        let (fragments, _) = parse_and_get_fragments(
6726            "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
6727        );
6728        assert_fragments!(
6729            fragments,
6730            [
6731                raw("Part A: "),
6732                signal("value1"),
6733                raw("<button>Click</button><br>Part B: "),
6734                signal("value2"),
6735                raw("<button>Click2</button>"),
6736            ]
6737        );
6738    }
6739}