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