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_light;
13mod css_link;
14mod css_parser;
15mod css_scan;
16mod diagnostic;
17mod error;
18mod handlebars_parser;
19mod html_parser;
20mod outlet_warnings;
21pub mod plugin;
22mod route_parser;
23mod scoped_visits;
24mod suggest;
25
26pub use asset_filename::{
27    AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
28};
29pub use component_registry::{Component, ComponentRegistration, ComponentRegistry};
30pub use condition_parser::ConditionParser;
31pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
32pub use css_parser::CssParser;
33pub use diagnostic::{codes, Diagnostic, Severity};
34pub use error::{ParserError, Result};
35pub use handlebars_parser::HandlebarsParser;
36pub use webui_tokens::CssFallbackChain;
37
38use crate::component_policy::{
39    parse_component_render_policy, ComponentRenderPolicy, HYDRATE_ATTR as COMPONENT_HYDRATE_ATTR,
40    RENDER_ATTR as COMPONENT_RENDER_ATTR,
41    RESERVE_BLOCK_SIZE_ATTR as COMPONENT_RESERVE_BLOCK_SIZE_ATTR,
42};
43use crate::html_parser::{self as html, Attrs, Element, Event, Walker};
44use crate::plugin::{
45    AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
46    ElementStartContext, FragmentContext, ParserPlugin, ParserPluginArtifacts,
47};
48use std::collections::{HashMap, HashSet};
49use std::ops::Range;
50use webui_protocol::{
51    web_ui_fragment, web_ui_fragment::Fragment, BoundaryPhase, ConditionExpr, FragmentList,
52    WebUIFragment, WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentBoundary,
53    WebUiFragmentRoute,
54};
55
56/// Maximum template size accepted by the parser.
57///
58/// Build-time parsing should never need to ingest arbitrarily large single
59/// templates. This cap prevents accidental or malicious OOM-scale inputs while
60/// staying far above normal component/page sizes.
61const MAX_TEMPLATE_BYTES: usize = 16 * 1024 * 1024;
62
63/// Maximum nested HTML element depth accepted by semantic parsing.
64///
65/// The scanner itself is iterative, but semantic fragment construction still
66/// enters child ranges. This limit keeps pathological nesting from exhausting
67/// the Rust call stack while preserving generous headroom for real templates.
68const MAX_TEMPLATE_DEPTH: usize = 512;
69
70/// Prefix for compiler-owned structural signals.
71///
72/// `}}}` cannot occur in a parsed double- or triple-brace expression: it closes
73/// the binding first. This keeps transport structure distinct from every
74/// authored state key without changing the protobuf schema.
75const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
76
77fn structural_signal(value: impl AsRef<str>) -> WebUIFragment {
78    WebUIFragment::signal(
79        format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
80        true,
81    )
82}
83
84/// How an element affects the HTML insertion mode that decides whether an
85/// unknown element (the generated `<webui-hydrate>` sentinel) is
86/// foster-parented away from its payload script.
87#[derive(Clone, Copy, PartialEq, Eq)]
88enum FosterContext {
89    /// Does not change the current insertion mode.
90    Transparent,
91    /// "in table"/"in table body"/"in row"/"in column group"/"in select":
92    /// unknown elements are foster-parented out of this subtree.
93    Hostile,
94    /// `<td>`/`<th>`/`<caption>` return to "in body" rules, so a boundary
95    /// inside them is safe even though an ancestor is hostile.
96    Barrier,
97}
98
99/// Classify `name` for foster-parenting purposes.
100///
101/// Dispatching on length first means the common non-table element (`div`,
102/// `span`, `p`, `section`, …) costs one integer compare and a fallthrough,
103/// keeping this off the parser's measured hot path.
104#[inline]
105fn foster_context_of(name: &str) -> FosterContext {
106    match name.len() {
107        2 => {
108            if name.eq_ignore_ascii_case("tr") {
109                FosterContext::Hostile
110            } else if name.eq_ignore_ascii_case("td") || name.eq_ignore_ascii_case("th") {
111                FosterContext::Barrier
112            } else {
113                FosterContext::Transparent
114            }
115        }
116        5 => {
117            if name.eq_ignore_ascii_case("table")
118                || name.eq_ignore_ascii_case("tbody")
119                || name.eq_ignore_ascii_case("thead")
120                || name.eq_ignore_ascii_case("tfoot")
121            {
122                FosterContext::Hostile
123            } else {
124                FosterContext::Transparent
125            }
126        }
127        6 => {
128            if name.eq_ignore_ascii_case("select") {
129                FosterContext::Hostile
130            } else {
131                FosterContext::Transparent
132            }
133        }
134        7 => {
135            if name.eq_ignore_ascii_case("caption") {
136                FosterContext::Barrier
137            } else {
138                FosterContext::Transparent
139            }
140        }
141        8 => {
142            if name.eq_ignore_ascii_case("optgroup") || name.eq_ignore_ascii_case("colgroup") {
143                FosterContext::Hostile
144            } else {
145                FosterContext::Transparent
146            }
147        }
148        _ => FosterContext::Transparent,
149    }
150}
151
152/// Return the browser parsing context that prevents a nested boundary sentinel
153/// from becoming an active custom element.
154#[inline]
155fn boundary_parent_scope(name: &str) -> Option<&'static str> {
156    match name.len() {
157        3 if name.eq_ignore_ascii_case("xmp") => Some("<xmp> raw-text content"),
158        5 if name.eq_ignore_ascii_case("title") => Some("<title> text content"),
159        6 if name.eq_ignore_ascii_case("iframe") => Some("<iframe> raw-text content"),
160        6 if name.eq_ignore_ascii_case("script") => Some("<script> raw-text content"),
161        7 if name.eq_ignore_ascii_case("noembed") => Some("<noembed> raw-text content"),
162        8 if name.eq_ignore_ascii_case("template") => Some("<template> inert content"),
163        8 if name.eq_ignore_ascii_case("textarea") => Some("<textarea> text content"),
164        8 if name.eq_ignore_ascii_case("noframes") => Some("<noframes> raw-text content"),
165        8 if name.eq_ignore_ascii_case("noscript") => Some("<noscript> inert content"),
166        9 if name.eq_ignore_ascii_case("plaintext") => Some("<plaintext> text content"),
167        _ => None,
168    }
169}
170
171#[inline]
172fn is_html_text_only_scope(scope: Option<&str>) -> bool {
173    // Explicit allowlist of the raw-text/RCDATA scopes returned by
174    // `boundary_parent_scope`, so a new scope value must be deliberately
175    // added here to become text-only rather than silently defaulting to it.
176    // `<template> inert content` and "component host content" are
177    // intentionally excluded: both parse normal (escaped) child content.
178    matches!(
179        scope,
180        Some(
181            "<xmp> raw-text content"
182                | "<title> text content"
183                | "<iframe> raw-text content"
184                | "<script> raw-text content"
185                | "<noembed> raw-text content"
186                | "<textarea> text content"
187                | "<noframes> raw-text content"
188                | "<noscript> inert content"
189                | "<plaintext> text content"
190        )
191    )
192}
193
194/// Strategy for how component CSS is delivered in rendered output.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
197pub enum CssStrategy {
198    /// Emit `<link rel="stylesheet" href="component.css">` tags (default).
199    #[default]
200    Link,
201    /// Embed CSS content in `<style>` tags within the component.
202    Style,
203    /// Register each component's CSS module as a `<script type="importmap">`
204    /// data-URI entry. The client runtime applies the registered sheet via
205    /// the document's adopted stylesheets.
206    Module,
207}
208
209impl std::fmt::Display for CssStrategy {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        match self {
212            CssStrategy::Link => write!(f, "link"),
213            CssStrategy::Style => write!(f, "style"),
214            CssStrategy::Module => write!(f, "module"),
215        }
216    }
217}
218
219impl std::str::FromStr for CssStrategy {
220    type Err = String;
221
222    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
223        match s {
224            "link" => Ok(CssStrategy::Link),
225            "style" => Ok(CssStrategy::Style),
226            "module" => Ok(CssStrategy::Module),
227            other => Err(format!(
228                "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
229            )),
230        }
231    }
232}
233
234/// Default DOM strategy for components without an authored declarative Shadow root.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
237pub enum DomStrategy {
238    /// Wrap unwrapped component content in an open declarative Shadow root.
239    #[default]
240    Shadow,
241    /// Render unwrapped component content directly in Light DOM.
242    Light,
243}
244
245impl std::fmt::Display for DomStrategy {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        match self {
248            Self::Shadow => write!(f, "shadow"),
249            Self::Light => write!(f, "light"),
250        }
251    }
252}
253
254impl std::str::FromStr for DomStrategy {
255    type Err = String;
256
257    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
258        match value {
259            "shadow" => Ok(Self::Shadow),
260            "light" => Ok(Self::Light),
261            other => Err(format!(
262                "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
263            )),
264        }
265    }
266}
267
268/// Strategy for preserving legal comments in generated output.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
270#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
271pub enum LegalComments {
272    /// Strip every HTML and CSS comment.
273    None,
274    /// Preserve legal CSS comments inline and strip all other comments.
275    #[default]
276    Inline,
277}
278
279impl std::fmt::Display for LegalComments {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        match self {
282            LegalComments::None => write!(f, "none"),
283            LegalComments::Inline => write!(f, "inline"),
284        }
285    }
286}
287
288impl std::str::FromStr for LegalComments {
289    type Err = String;
290
291    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
292        match s {
293            "none" => Ok(LegalComments::None),
294            "inline" => Ok(LegalComments::Inline),
295            other => Err(format!(
296                "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
297            )),
298        }
299    }
300}
301
302/// Build/output options that affect parser-generated component templates.
303#[derive(Debug, Clone, Default)]
304pub struct ParserOptions {
305    /// Strategy for how component CSS is delivered.
306    pub css_strategy: CssStrategy,
307    /// Fallback DOM strategy for components without an authored Shadow root.
308    pub dom_strategy: DomStrategy,
309    /// Link-mode CSS filename/href options.
310    pub css_link_options: CssLinkOptions,
311    /// Legal comment preservation strategy.
312    pub legal_comments: LegalComments,
313}
314
315impl ParserOptions {
316    /// Create parser output options.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`ParserError::Css`] when Link-mode CSS link options are invalid.
321    pub fn try_new(
322        css_strategy: CssStrategy,
323        dom_strategy: DomStrategy,
324        css_file_name_template: &str,
325        css_public_base: Option<&str>,
326        legal_comments: LegalComments,
327    ) -> Result<Self> {
328        let css_link_options = if css_strategy == CssStrategy::Link {
329            CssLinkOptions::try_new(
330                css_file_name_template.to_string(),
331                css_public_base.map(std::string::ToString::to_string),
332            )?
333        } else {
334            CssLinkOptions::default()
335        };
336
337        Ok(Self {
338            css_strategy,
339            dom_strategy,
340            css_link_options,
341            legal_comments,
342        })
343    }
344}
345
346impl From<DomStrategy> for ParserOptions {
347    fn from(dom_strategy: DomStrategy) -> Self {
348        Self {
349            dom_strategy,
350            ..Self::default()
351        }
352    }
353}
354
355impl From<CssStrategy> for ParserOptions {
356    fn from(css_strategy: CssStrategy) -> Self {
357        Self {
358            css_strategy,
359            ..Self::default()
360        }
361    }
362}
363
364impl From<(CssStrategy, DomStrategy)> for ParserOptions {
365    fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
366        Self {
367            css_strategy,
368            dom_strategy,
369            ..Self::default()
370        }
371    }
372}
373
374/// Framework plugin to load for build-time and render-time processing.
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
377pub enum Plugin {
378    /// Deprecated compatibility alias for the FAST 2 hydration plugin.
379    #[cfg_attr(feature = "cli", value(name = "fast"))]
380    Fast,
381    /// Deprecated FAST 2 hydration plugin with legacy marker output.
382    #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
383    FastV2,
384    /// FAST 3 hydration plugin with compact marker output.
385    #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
386    FastV3,
387    /// WebUI plugin — full component model with shadow DOM support.
388    #[cfg_attr(feature = "cli", value(name = "webui"))]
389    WebUI,
390}
391
392impl std::fmt::Display for Plugin {
393    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394        match self {
395            Plugin::Fast => write!(f, "fast"),
396            Plugin::FastV2 => write!(f, "fast-v2"),
397            Plugin::FastV3 => write!(f, "fast-v3"),
398            Plugin::WebUI => write!(f, "webui"),
399        }
400    }
401}
402
403impl std::str::FromStr for Plugin {
404    type Err = String;
405
406    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
407        match s {
408            "fast" => Ok(Plugin::Fast),
409            "fast-v2" => Ok(Plugin::FastV2),
410            "fast-v3" => Ok(Plugin::FastV3),
411            "webui" => Ok(Plugin::WebUI),
412            other => Err(format!(
413                "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
414            )),
415        }
416    }
417}
418
419/// Counter for generating unique fragment IDs.
420struct FragmentIdCounter {
421    /// Map of counter types to their current values.
422    counters: HashMap<String, usize>,
423}
424
425impl FragmentIdCounter {
426    /// Create a new fragment ID counter.
427    fn new() -> Self {
428        Self {
429            counters: HashMap::new(),
430        }
431    }
432
433    /// Generate a unique fragment ID.
434    fn next_id(&mut self, prefix: &str) -> String {
435        let count = self.counters.entry(prefix.to_string()).or_insert(0);
436        *count += 1;
437        format!("{}-{}", prefix, count)
438    }
439
440    fn has_generated(&self, id: &str) -> bool {
441        let Some((prefix, number)) = id.rsplit_once('-') else {
442            return false;
443        };
444        self.counters.get(prefix).is_some_and(|count| {
445            number
446                .parse::<usize>()
447                .is_ok_and(|number| number > 0 && number <= *count)
448        })
449    }
450}
451
452#[derive(Default)]
453struct NamedForLoops {
454    entries: HashMap<String, NamedFor>,
455}
456
457struct NamedFor {
458    item: String,
459    offset: usize,
460    defined: bool,
461}
462
463impl NamedForLoops {
464    fn register(&mut self, owner: &str, id: &str, item: &str, site: (bool, usize)) -> Result<()> {
465        let (definition, offset) = site;
466        if let Some(entry) = self.entries.get_mut(id) {
467            if definition && entry.defined {
468                return Err(named_for_error(
469                    owner,
470                    codes::DUPLICATE_FOR_ID,
471                    "duplicate named <for> definition",
472                    id,
473                    "define the body once per file and use a self-closing <for id=\"...\" each=\"...\" /> to reuse it",
474                )
475                .into());
476            }
477            if entry.item != item {
478                return Err(named_for_error(
479                    owner,
480                    codes::INCOMPATIBLE_FOR_ITEM,
481                    "inconsistent item variable for named <for>",
482                    id,
483                    &format!(
484                        "use each=\"{} in collection\" at every reference to this id",
485                        entry.item
486                    ),
487                )
488                .into());
489            }
490            entry.defined |= definition;
491        } else {
492            self.entries.insert(
493                id.to_string(),
494                NamedFor {
495                    item: item.to_string(),
496                    offset,
497                    defined: definition,
498                },
499            );
500        }
501        Ok(())
502    }
503
504    fn validate(&self, owner: &str, source: &str) -> Result<()> {
505        let missing = self
506            .entries
507            .iter()
508            .filter(|(_, entry)| !entry.defined)
509            .min_by_key(|(_, entry)| entry.offset);
510        if let Some((id, entry)) = missing {
511            return Err(named_for_error(
512                owner,
513                codes::UNKNOWN_FOR_ID,
514                "unknown named <for> definition",
515                id,
516                "add a <for> with this id and a body in the same file; definitions in other files are not visible",
517            )
518            .at_offset(source, entry.offset)
519            .into());
520        }
521        Ok(())
522    }
523}
524
525fn parse_for_each<'a>(owner: &str, each: &'a str) -> Result<(&'a str, &'a str)> {
526    let mut parts = each.split_whitespace();
527    let (Some(item), Some("in"), Some(collection), None) =
528        (parts.next(), parts.next(), parts.next(), parts.next())
529    else {
530        return Err(invalid_for_each(owner, each, codes::INVALID_FOR_EACH));
531    };
532    let allowed = |value: &str| {
533        value.bytes().all(|byte| {
534            byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-' || byte == b'.'
535        })
536    };
537    if !allowed(item) || !allowed(collection) {
538        return Err(invalid_for_each(owner, each, codes::INVALID_FOR_IDENTIFIER));
539    }
540    Ok((item, collection))
541}
542
543fn validate_for_id<'a>(owner: &str, tag: &html::Tag<'a>) -> Result<Option<&'a str>> {
544    let mut id = None;
545    let mut id_count = 0;
546    for attr in tag.attrs() {
547        match attr.name {
548            "template" => return Err(unsupported_for_template(owner)),
549            "id" => {
550                id = Some(attr.value.unwrap_or_default());
551                id_count += 1;
552            }
553            _ => {}
554        }
555    }
556    let Some(id) = id else {
557        return Ok(None);
558    };
559    if id.is_empty()
560        || !id
561            .bytes()
562            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
563        || id_count != 1
564    {
565        return Err(named_for_error(
566            owner,
567            codes::INVALID_FOR_ID,
568            "invalid <for> id",
569            id,
570            "use one non-empty static id containing letters, digits, '_' or '-'",
571        )
572        .into());
573    }
574    Ok(Some(id))
575}
576
577fn strip_condition_braces(value: &str) -> &str {
578    let value = value.trim();
579    value
580        .strip_prefix("{{")
581        .and_then(|value| value.strip_suffix("}}"))
582        .map_or(value, str::trim)
583}
584
585#[cold]
586#[inline(never)]
587fn locate_for_error(error: ParserError, element: &Element<'_>) -> ParserError {
588    match error {
589        ParserError::Template(diagnostic) => (*diagnostic)
590            .at_offset(element.source(), element.start)
591            .into(),
592        other => other,
593    }
594}
595
596#[cold]
597#[inline(never)]
598fn invalid_for_each(owner: &str, each: &str, code: &'static str) -> ParserError {
599    Diagnostic::error("invalid <for> each expression")
600        .code(code)
601        .component(owner)
602        .element("for")
603        .snippet(format!("each=\"{each}\""))
604        .help("use each=\"item in collection\" without braces; item and collection names may use only letters, digits, '_', '-', and '.'")
605        .into()
606}
607
608#[cold]
609#[inline(never)]
610fn unsupported_for_template(owner: &str) -> ParserError {
611    Diagnostic::error("unsupported <for> template attribute")
612        .code(codes::INVALID_FOR_ID)
613        .component(owner)
614        .element("for")
615        .snippet("template")
616        .help("replace template with id on the loop definition and every reference; named loops are local to their file")
617        .into()
618}
619
620#[cold]
621#[inline(never)]
622fn named_for_error(
623    owner: &str,
624    code: &'static str,
625    title: &str,
626    id: &str,
627    help: &str,
628) -> Diagnostic {
629    Diagnostic::error(title)
630        .code(code)
631        .component(owner)
632        .element("for")
633        .snippet(format!("id=\"{id}\""))
634        .help(help)
635}
636
637struct ParseContext {
638    fragments: Vec<WebUIFragment>,
639    raw_buffer: String,
640}
641
642struct PendingBoundary {
643    declaration_id: u32,
644}
645
646struct BoundaryGraphEdge {
647    target: String,
648}
649
650/// A `<for>` callsite, used to name the offending repeat in a diagnostic.
651struct RepeatSite<'a> {
652    owner: &'a str,
653    item: &'a str,
654    collection: &'a str,
655}
656
657/// A boundary declaration a `<for>` repeat body can reach.
658struct RepeatBoundaryViolation {
659    repeat_owner: String,
660    each: String,
661    boundary_owner: String,
662    boundary_name: String,
663}
664
665/// Push every record a route subtree renders into `targets`.
666fn push_route_records<'a>(targets: &mut Vec<&'a str>, root: &'a WebUiFragmentRoute) {
667    let mut pending = vec![root];
668    while let Some(route) = pending.pop() {
669        targets.push(route.fragment_id.as_str());
670        targets.push(route.content_fragment_id.as_str());
671        targets.push(route.pending_component.as_str());
672        targets.push(route.error_component.as_str());
673        pending.extend(route.children.iter());
674    }
675}
676
677/// Push every record `list` renders through a lexical child edge.
678fn push_child_records<'a>(list: &'a FragmentList, targets: &mut Vec<&'a str>) {
679    for fragment in &list.fragments {
680        match fragment.fragment.as_ref() {
681            Some(Fragment::Component(component)) => targets.push(&component.fragment_id),
682            Some(Fragment::IfCond(if_cond)) => targets.push(&if_cond.fragment_id),
683            Some(Fragment::ForLoop(for_loop)) => targets.push(&for_loop.fragment_id),
684            Some(Fragment::Route(route)) => push_route_records(targets, route),
685            _ => {}
686        }
687    }
688}
689
690#[derive(Default)]
691struct FragmentCssTokens {
692    definitions: Vec<String>,
693    fallback_chains: Vec<CssFallbackChain>,
694}
695
696/// CSS token analysis produced from the parsed template/component graph.
697#[derive(Debug, Clone, PartialEq, Eq)]
698pub struct CssTokenAnalysis {
699    /// Sorted, deduplicated token candidates stored in the protocol.
700    pub protocol_tokens: Vec<String>,
701    /// Fallback chains that still need theme/literal coverage after local and
702    /// ancestor custom-property definitions are considered.
703    pub fallback_chains: Vec<CssFallbackChain>,
704    /// Source location of each unresolved token's first `var()` usage, keyed by
705    /// token name. Used to point theme-validation diagnostics at the offending
706    /// CSS. Build-time only; never serialized.
707    pub(crate) token_sites: HashMap<String, TokenSite>,
708}
709
710/// Where an unresolved CSS token is first referenced, for diagnostics.
711///
712/// `owner` is a file-like label (`my-card.css`, or the entry file for inline
713/// `<style>`). `position`/`snippet` are populated for component CSS where the
714/// source is a standalone file; inline styles record the owner only.
715#[derive(Debug, Clone, PartialEq, Eq)]
716pub(crate) struct TokenSite {
717    owner: String,
718    position: Option<(usize, usize)>,
719    snippet: Option<String>,
720}
721
722enum ParseOp<'a> {
723    Parse {
724        range: Range<usize>,
725        depth: usize,
726    },
727    EmitClose(&'a str),
728    EndComponent(&'a str),
729    EndHead(&'a str),
730    EndBody(&'a str),
731    CompleteBoundary {
732        boundary: PendingBoundary,
733    },
734    /// Restore [`HtmlParser::foster_context_depth`] to the saved value when
735    /// leaving an element that changed it.
736    ///
737    /// Save/restore of the single overwritten value keeps ancestor tracking
738    /// allocation-free: no ancestor stack is retained.
739    RestoreFosterDepth(u32),
740    /// Restore the enclosing component/raw/inert context after its child range
741    /// has been parsed.
742    RestoreBoundaryParentScope(Option<&'static str>),
743    CompleteFor {
744        parent: ParseContext,
745        item: String,
746        collection: String,
747        fragment_id: String,
748        named_definition: bool,
749        previous_for_depth: usize,
750    },
751    CompleteIf {
752        parent: ParseContext,
753        condition: ConditionExpr,
754        fragment_id: String,
755    },
756}
757
758enum TokenGraphOp<'a> {
759    EnterFragment(&'a str),
760    ExitFragment(&'a str),
761    EnterComponent(&'a str),
762    EnterRoute(&'a WebUiFragmentRoute),
763    ExitDefinitions(&'a [String]),
764}
765
766impl Default for HtmlParser {
767    fn default() -> Self {
768        Self::new()
769    }
770}
771
772/// Parser for WebUI directives.
773pub struct HtmlParser {
774    /// CSS parser.
775    css_parser: CssParser,
776
777    /// Fragment ID counter.
778    id_counter: FragmentIdCounter,
779
780    /// Condition parser for parsing conditions in directives.
781    condition_parser: ConditionParser,
782
783    /// Handlebars parser for parsing handlebars expressions.
784    handlebars_parser: HandlebarsParser,
785
786    /// Component registry for WebUI components.
787    component_registry: ComponentRegistry,
788
789    /// Map of fragment IDs to their fragments
790    fragment_records: WebUIFragmentRecords,
791
792    /// Non-fatal parser advisories collected while parsing templates.
793    warnings: Vec<Diagnostic>,
794
795    /// Buffer for accumulating raw content
796    raw_buffer: String,
797
798    /// Parser output options for generated component templates.
799    options: ParserOptions,
800
801    /// Optional parser plugin for framework-specific behavior.
802    plugin: Option<Box<dyn ParserPlugin>>,
803
804    /// Static component behavior copied from the plugin at construction.
805    component_processing: ComponentProcessing,
806
807    /// Declarative Shadow DOM analysis resolved once per component tag.
808    component_dom_analyses: HashMap<String, ComponentDomAnalysis>,
809
810    /// Top-level fragments parsed by callers. Token graph traversal starts
811    /// from these roots after parsing completes.
812    token_roots: Vec<String>,
813
814    /// CSS custom property definitions and fallback chains from inline
815    /// `<style>` tags, keyed by owning fragment id.
816    fragment_css_tokens: HashMap<String, FragmentCssTokens>,
817
818    /// Fragment IDs currently being parsed, used to reject recursive component
819    /// references before they can recurse through template parsing.
820    in_progress_fragments: HashSet<String>,
821
822    /// The fragment ID (entry file or component tag) currently being parsed.
823    /// Used to name the owning template in authoring [`Diagnostic`]s.
824    current_fragment_id: String,
825
826    loop_owner_indices: HashMap<String, usize>,
827    named_for_loops: NamedForLoops,
828    named_for_fragment_ids: HashSet<String>,
829
830    /// Next protocol-wide compile-time boundary declaration identity.
831    next_boundary_declaration_id: u32,
832
833    /// Authored boundary names keyed by their owning entry/component template.
834    boundary_names_by_owner: HashMap<String, HashSet<String>>,
835
836    /// `true` while parsing is inside an open `<boundary>`, used to
837    /// reject a nested boundary.
838    in_boundary: bool,
839
840    /// Number of boundary scopes surrounding the current reusable-template
841    /// callsite. Kept separate from lexical `in_boundary` so component syntax
842    /// is analyzed independently while island-owned module entries stay
843    /// excluded from critical preloads.
844    boundary_ancestor_depth: usize,
845
846    /// Number of currently open native `<body>` elements. A depth rather than
847    /// a boolean preserves the parent state while recursively parsing a
848    /// component template.
849    body_depth: usize,
850
851    /// Number of lexical `<for>` scopes in the current template. Boundaries in
852    /// such scopes can occur repeatedly and therefore require an authored key.
853    for_depth: usize,
854
855    /// Number of runtime route-content scopes currently being parsed.
856    route_depth: usize,
857
858    /// Number of enclosing elements whose HTML insertion mode would
859    /// foster-parent an unknown element such as the generated
860    /// `<webui-hydrate>` sentinel.
861    ///
862    /// Non-zero means a `<boundary>` here would be split from its
863    /// payload script by the browser's parser, so it must be rejected at build
864    /// time. `<td>`/`<th>`/`<caption>` reset it to `0` because those switch
865    /// back to "in body" insertion rules.
866    foster_context_depth: u32,
867
868    /// Enclosing component host content or native raw/inert HTML context.
869    ///
870    /// A generated sentinel in any of these contexts is either text or lives
871    /// in an inert fragment, so it cannot commit a streaming boundary.
872    boundary_parent_scope: Option<&'static str>,
873
874    /// `src` of every authored `<script type="module">` outside any
875    /// `<boundary>`, in document order, from the current top-level parse.
876    ///
877    /// These are the page's critical module entries. The build resolves each
878    /// against the projection manifest to emit `<link rel="modulepreload">`
879    /// for the shared chunks they statically import, which the browser's
880    /// preload scanner cannot discover on its own.
881    ///
882    /// Scripts *inside* a boundary are deliberately excluded: an island loader
883    /// is meant to be requested only when the parser reaches its chunk, so
884    /// preloading it would undo the deferral the author asked for.
885    /// Explicit `fetchpriority="low"` modules are excluded for the same reason.
886    module_entry_srcs: Vec<String>,
887}
888
889struct BuiltComponentTemplate {
890    ssr: String,
891    artifact: Option<String>,
892    uses_shadow_dom: bool,
893    style: Option<OwnedComponentStyle>,
894}
895
896/// Owned form of [`plugin::ComponentStyleDelivery`], held for the lifetime of a
897/// built component template so plugin context can borrow from it.
898enum OwnedComponentStyle {
899    Link(String),
900    Inline(String),
901    Adopted(String),
902}
903
904#[derive(Clone, Copy)]
905struct ComponentStyleInjection<'a> {
906    css_snippet: Option<&'a str>,
907    adopted_specifier: Option<&'a str>,
908}
909
910#[derive(Clone, Copy)]
911struct ComponentTemplateMode {
912    preserve_runtime_attrs: bool,
913    policy_wrapper: bool,
914    // Emit inline CSS after bindings for clients that scan braces.
915    styles_at_end: bool,
916}
917
918impl BuiltComponentTemplate {
919    fn artifact(&self) -> &str {
920        self.artifact.as_deref().unwrap_or(&self.ssr)
921    }
922
923    fn plugin_context<'a>(&'a self, component: &'a Component) -> ComponentBuildContext<'a> {
924        ComponentBuildContext {
925            component,
926            template: self.artifact(),
927            uses_shadow_dom: self.uses_shadow_dom,
928            style: self.style.as_ref().map(|style| match style {
929                OwnedComponentStyle::Link(href) => plugin::ComponentStyleDelivery::Link { href },
930                OwnedComponentStyle::Inline(css) => plugin::ComponentStyleDelivery::Inline { css },
931                OwnedComponentStyle::Adopted(specifier) => {
932                    plugin::ComponentStyleDelivery::Adopted { specifier }
933                }
934            }),
935        }
936    }
937}
938
939#[derive(Debug, Clone, Copy)]
940struct ComponentDomAnalysis {
941    uses_shadow_dom: bool,
942    /// Byte range of the authored declarative Shadow DOM root, when present.
943    authored_shadow_root: Option<(usize, usize)>,
944    /// Content range of a sole bare `<template>` that explicitly selects Light
945    /// DOM.
946    authored_light_root: Option<(usize, usize)>,
947}
948
949struct ShadowRootModeOccurrence<'a> {
950    element_start: usize,
951    element_end: usize,
952    element_name: &'a str,
953    has_closing_tag: bool,
954    dynamically_bound: bool,
955    attr_offset: usize,
956    value: Option<&'a str>,
957}
958
959#[cfg(test)]
960fn analyze_component_dom(
961    tag_name: &str,
962    source: &str,
963    fallback: DomStrategy,
964) -> Result<ComponentDomAnalysis> {
965    analyze_component_dom_with_diagnostic_source(tag_name, source, fallback, None)
966}
967
968fn analyze_component_dom_with_diagnostic_source(
969    tag_name: &str,
970    source: &str,
971    fallback: DomStrategy,
972    diagnostic_source: Option<&str>,
973) -> Result<ComponentDomAnalysis> {
974    let mut ranges = Vec::with_capacity(8);
975    ranges.push((0..source.len(), true));
976    let mut top_level_element_count = 0usize;
977    let mut top_level_root_start = 0usize;
978    let mut has_top_level_content = false;
979    let mut bare_template_root = None;
980    let mut shadow_mode = None;
981    let mut slot_offset = None;
982
983    while let Some((range, top_level)) = ranges.pop() {
984        for event in Walker::new_range(source, range.start, range.end) {
985            match event {
986                Event::Text(text) if top_level && !text.trim().is_empty() => {
987                    has_top_level_content = true;
988                }
989                Event::Declaration(_) if top_level => {
990                    has_top_level_content = true;
991                }
992                Event::Element(element) => {
993                    if top_level {
994                        top_level_element_count += 1;
995                        top_level_root_start = element.start;
996                        bare_template_root = (element.name().eq_ignore_ascii_case("template")
997                            && !element.self_closing()
998                            && element.attrs().next().is_none())
999                        .then_some((element.content_start, element.content_end));
1000                    }
1001
1002                    for attr in element.attrs() {
1003                        let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1004                        if attr_name.eq_ignore_ascii_case("shadowrootmode") {
1005                            if shadow_mode.is_some() {
1006                                return Err(invalid_shadow_root_mode_error(
1007                                    tag_name,
1008                                    DiagnosticSite::new(
1009                                        source,
1010                                        diagnostic_source,
1011                                        element.start + attr.raw_range.start,
1012                                    ),
1013                                    "a component may declare only one `shadowrootmode`",
1014                                    "keep one `shadowrootmode=\"open\"` attribute on the sole top-level `<template>`",
1015                                ));
1016                            }
1017                            shadow_mode = Some(ShadowRootModeOccurrence {
1018                                element_start: element.start,
1019                                element_end: element.close_end(),
1020                                element_name: element.name(),
1021                                has_closing_tag: !element.self_closing()
1022                                    && element.close_end() > element.content_end(),
1023                                dynamically_bound: attr.name != attr_name,
1024                                attr_offset: element.start + attr.raw_range.start,
1025                                value: attr.value,
1026                            });
1027                        }
1028                    }
1029
1030                    if slot_offset.is_none() && element.name().eq_ignore_ascii_case("slot") {
1031                        slot_offset = Some(element.start);
1032                    }
1033
1034                    let name = element.name();
1035                    let raw_text = name.eq_ignore_ascii_case("script")
1036                        || name.eq_ignore_ascii_case("style")
1037                        || name.eq_ignore_ascii_case("textarea")
1038                        || name.eq_ignore_ascii_case("title");
1039                    if element.content_end() > element.inner().start && !raw_text {
1040                        ranges.push((element.inner(), false));
1041                    }
1042                }
1043                Event::Text(_)
1044                | Event::Comment(_)
1045                | Event::Declaration(_)
1046                | Event::ClosingTag(_) => {}
1047            }
1048        }
1049    }
1050
1051    let authored_shadow_root = match shadow_mode {
1052        Some(mode) => {
1053            let value = mode.value;
1054            if mode.dynamically_bound {
1055                return Err(invalid_shadow_root_mode_error(
1056                    tag_name,
1057                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1058                    "`shadowrootmode` must be a static attribute",
1059                    "replace the binding with `shadowrootmode=\"open\"` on the sole top-level `<template>`",
1060                ));
1061            }
1062            if value.is_some_and(|value| value.eq_ignore_ascii_case("closed")) {
1063                return Err(invalid_shadow_root_mode_error(
1064                    tag_name,
1065                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1066                    "`shadowrootmode=\"closed\"` is not supported",
1067                    "use `shadowrootmode=\"open\"`; WebUI requires an open root for hydration and style delivery",
1068                ));
1069            }
1070            if !value.is_some_and(|value| value.eq_ignore_ascii_case("open")) {
1071                return Err(invalid_shadow_root_mode_error(
1072                    tag_name,
1073                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1074                    "`shadowrootmode` must be `open`",
1075                    "set the attribute to `shadowrootmode=\"open\"`",
1076                ));
1077            }
1078            if top_level_element_count != 1
1079                || has_top_level_content
1080                || mode.element_start != top_level_root_start
1081                || !mode.element_name.eq_ignore_ascii_case("template")
1082                || !mode.has_closing_tag
1083            {
1084                return Err(invalid_shadow_root_mode_error(
1085                    tag_name,
1086                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1087                    "the declarative Shadow DOM wrapper must be the sole top-level element",
1088                    "wrap the complete component in one top-level `<template shadowrootmode=\"open\">`",
1089                ));
1090            }
1091            Some((mode.element_start, mode.element_end))
1092        }
1093        None => None,
1094    };
1095
1096    let authored_light_root =
1097        if authored_shadow_root.is_none() && top_level_element_count == 1 && !has_top_level_content
1098        {
1099            bare_template_root
1100        } else {
1101            None
1102        };
1103    let uses_shadow_dom = authored_shadow_root.is_some()
1104        || (authored_light_root.is_none() && fallback == DomStrategy::Shadow);
1105    if !uses_shadow_dom {
1106        if let Some(offset) = slot_offset {
1107            return Err(light_dom_slot_error(
1108                tag_name,
1109                DiagnosticSite::new(source, diagnostic_source, offset),
1110            ));
1111        }
1112    }
1113
1114    Ok(ComponentDomAnalysis {
1115        uses_shadow_dom,
1116        authored_shadow_root,
1117        authored_light_root,
1118    })
1119}
1120
1121#[cold]
1122#[inline(never)]
1123fn invalid_shadow_root_mode_error(
1124    tag_name: &str,
1125    site: DiagnosticSite<'_>,
1126    title: &str,
1127    help: &str,
1128) -> ParserError {
1129    let (source, offset) = site.resolve(DiagnosticTarget::Attribute("shadowrootmode"));
1130    Diagnostic::error(title)
1131        .code(codes::INVALID_SHADOW_ROOT_MODE)
1132        .component(tag_name)
1133        .at_offset(source, offset)
1134        .snippet(source_line_snippet(source, offset))
1135        .help(help)
1136        .into()
1137}
1138
1139#[cold]
1140#[inline(never)]
1141fn light_dom_slot_error(tag_name: &str, site: DiagnosticSite<'_>) -> ParserError {
1142    let (source, offset) = site.resolve(DiagnosticTarget::Element("slot"));
1143    Diagnostic::error("native `<slot>` projection requires Shadow DOM")
1144        .code(codes::LIGHT_DOM_SLOT)
1145        .component(tag_name)
1146        .element("slot")
1147        .at_offset(source, offset)
1148        .snippet(source_line_snippet(source, offset))
1149        .help(
1150            "wrap the complete component template in `<template shadowrootmode=\"open\">` to use `<slot>`",
1151        )
1152        .into()
1153}
1154
1155#[derive(Clone, Copy)]
1156struct DiagnosticSite<'a> {
1157    source: &'a str,
1158    authored_source: Option<&'a str>,
1159    offset: usize,
1160}
1161
1162impl<'a> DiagnosticSite<'a> {
1163    fn new(source: &'a str, authored_source: Option<&'a str>, offset: usize) -> Self {
1164        Self {
1165            source,
1166            authored_source,
1167            offset,
1168        }
1169    }
1170
1171    fn resolve(self, target: DiagnosticTarget) -> (&'a str, usize) {
1172        let Some(authored_source) = self.authored_source else {
1173            return (self.source, self.offset);
1174        };
1175        let transformed_offsets = diagnostic_target_offsets(self.source, target);
1176        let occurrence = transformed_offsets
1177            .iter()
1178            .position(|offset| *offset == self.offset)
1179            .unwrap_or(0);
1180        let authored_offsets = diagnostic_target_offsets(authored_source, target);
1181        authored_offsets
1182            .get(occurrence)
1183            .copied()
1184            .map_or((self.source, self.offset), |offset| {
1185                (authored_source, offset)
1186            })
1187    }
1188}
1189
1190#[derive(Clone, Copy)]
1191enum DiagnosticTarget {
1192    Element(&'static str),
1193    Attribute(&'static str),
1194}
1195
1196#[cold]
1197#[inline(never)]
1198fn diagnostic_target_offsets(source: &str, target: DiagnosticTarget) -> Vec<usize> {
1199    let mut offsets = Vec::new();
1200    let mut ranges = Vec::with_capacity(4);
1201    ranges.push(0..source.len());
1202
1203    while let Some(range) = ranges.pop() {
1204        for event in Walker::new_range(source, range.start, range.end) {
1205            let Event::Element(element) = event else {
1206                continue;
1207            };
1208            match target {
1209                DiagnosticTarget::Element(name) if element.name().eq_ignore_ascii_case(name) => {
1210                    offsets.push(element.start);
1211                }
1212                DiagnosticTarget::Attribute(name) => {
1213                    for attr in element.attrs() {
1214                        let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1215                        if attr_name.eq_ignore_ascii_case(name) {
1216                            offsets.push(element.start + attr.raw_range.start);
1217                        }
1218                    }
1219                }
1220                DiagnosticTarget::Element(_) => {}
1221            }
1222
1223            let name = element.name();
1224            let raw_text = name.eq_ignore_ascii_case("script")
1225                || name.eq_ignore_ascii_case("style")
1226                || name.eq_ignore_ascii_case("textarea")
1227                || name.eq_ignore_ascii_case("title");
1228            if element.content_end() > element.inner().start && !raw_text {
1229                ranges.push(element.inner());
1230            }
1231        }
1232    }
1233    offsets.sort_unstable();
1234    offsets
1235}
1236
1237struct ComponentTemplateInput<'a> {
1238    tag_name: &'a str,
1239    html: &'a str,
1240    artifact_html: Option<&'a str>,
1241    authored_html: Option<&'a str>,
1242    css_content: Option<&'a str>,
1243    artifact_needed: bool,
1244}
1245
1246fn add_token_definitions<'a>(
1247    definitions: &'a [String],
1248    available_counts: &mut HashMap<&'a str, usize>,
1249) {
1250    for definition in definitions {
1251        let count = available_counts.entry(definition.as_str()).or_insert(0);
1252        *count += 1;
1253    }
1254}
1255
1256fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<&str, usize>) {
1257    for definition in definitions {
1258        if let Some(count) = available_counts.get_mut(definition.as_str()) {
1259            if *count == 1 {
1260                available_counts.remove(definition.as_str());
1261            } else {
1262                *count -= 1;
1263            }
1264        }
1265    }
1266}
1267
1268/// Accumulators for the token-graph walk: the unresolved fallback chains and
1269/// the per-token source locations, grouped so walk helpers stay within the
1270/// argument-count budget.
1271#[derive(Default)]
1272struct UnresolvedTokens {
1273    chains: Vec<CssFallbackChain>,
1274    sites: HashMap<String, TokenSite>,
1275}
1276
1277fn record_unresolved_requirements(
1278    source: &[CssFallbackChain],
1279    available_counts: &HashMap<&str, usize>,
1280    owner: &str,
1281    css_source: Option<&str>,
1282    out: &mut UnresolvedTokens,
1283) {
1284    for requirement in source {
1285        let tokens: Vec<String> = requirement
1286            .tokens
1287            .iter()
1288            .filter(|token| !available_counts.contains_key(token.as_str()))
1289            .cloned()
1290            .collect();
1291        if tokens.is_empty() {
1292            continue;
1293        }
1294        for token in &tokens {
1295            if !out.sites.contains_key(token) {
1296                out.sites
1297                    .insert(token.clone(), token_site(owner, css_source, token));
1298            }
1299        }
1300        out.chains.push(CssFallbackChain {
1301            tokens,
1302            has_literal_fallback: requirement.has_literal_fallback,
1303        });
1304    }
1305}
1306
1307/// Build the [`TokenSite`] for `token`. When `css_source` is available (a
1308/// component's standalone CSS), the token's `var()` usage is located for a
1309/// precise `line:column` and snippet; otherwise only the `owner` is recorded.
1310fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
1311    let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
1312        Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
1313        None => (None, None),
1314    };
1315    TokenSite {
1316        owner: owner.to_string(),
1317        position,
1318        snippet,
1319    }
1320}
1321
1322/// Locate the first `var(--token)` **usage** (not a `--token:` definition) in
1323/// `css`, returning its 1-based `(line, column)` and a one-line snippet.
1324///
1325/// Iterative byte scan — no regex, no recursion. Cold-ish (build time, only for
1326/// unresolved tokens).
1327fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
1328    let bytes = css.as_bytes();
1329    let name = token.as_bytes();
1330    let mut index = 0;
1331    while index + 2 + name.len() <= bytes.len() {
1332        let is_prefixed = bytes[index] == b'-'
1333            && bytes[index + 1] == b'-'
1334            && bytes.get(index + 2..index + 2 + name.len()) == Some(name);
1335        if is_prefixed {
1336            let after = index + 2 + name.len();
1337            let is_exact_name = bytes
1338                .get(after)
1339                .is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
1340            if is_exact_name {
1341                let mut cursor = after;
1342                while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
1343                    cursor += 1;
1344                }
1345                // `--token:` is a definition, not the usage we want to point at.
1346                if bytes.get(cursor) != Some(&b':') {
1347                    let (line, column) = diagnostic::line_column(css, index);
1348                    return Some((line, column, source_line_snippet(css, index)));
1349                }
1350            }
1351        }
1352        index += 1;
1353    }
1354    None
1355}
1356
1357/// Extract the trimmed line containing byte `offset`, capped so minified input
1358/// does not produce a wall-of-text snippet.
1359fn source_line_snippet(source: &str, offset: usize) -> String {
1360    const MAX_SNIPPET: usize = 80;
1361    let start = source[..offset].rfind('\n').map_or(0, |n| n + 1);
1362    let end = source[offset..]
1363        .find('\n')
1364        .map_or(source.len(), |n| offset + n);
1365    let line = source[start..end].trim();
1366    if line.chars().count() > MAX_SNIPPET {
1367        let truncated: String = line.chars().take(MAX_SNIPPET).collect();
1368        format!("{truncated}…")
1369    } else {
1370        line.to_string()
1371    }
1372}
1373
1374/// File-like owner label for a component's standalone CSS
1375/// (`my-card` → `my-card.css`), used as the `--> owner:line:column` prefix.
1376fn css_owner_label(tag_name: &str) -> String {
1377    let mut label = String::with_capacity(tag_name.len() + 4);
1378    label.push_str(tag_name);
1379    label.push_str(".css");
1380    label
1381}
1382
1383impl CssTokenAnalysis {
1384    /// Validate the required CSS tokens against a loaded design-token theme.
1385    ///
1386    /// A token is *required* in every theme only when it appears in at least one
1387    /// unresolved `var()` chain with **no** literal CSS fallback. A chain such as
1388    /// `var(--x, 16px)` provides its own value, so `--x` stays in
1389    /// [`protocol_tokens`](Self::protocol_tokens) for runtime resolution (the
1390    /// theme value still wins when present) but does not fail the build when the
1391    /// theme omits it. The chain/theme policy lives in
1392    /// [`webui_tokens::validate_chain_tokens`]; this only adapts its
1393    /// [`webui_tokens::TokenError`] into a structured [`Diagnostic`].
1394    ///
1395    /// # Errors
1396    ///
1397    /// Returns [`ParserError::Template`] with a stable diagnostic code when a
1398    /// required token is missing from a theme. Theme token values are trusted and
1399    /// their transitive references are left to browser CSS semantics.
1400    pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
1401        webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
1402            .map_err(|source| self.theme_token_error(source, theme))
1403    }
1404
1405    /// CSS tokens used **only** with a literal `var()` fallback and defined in no
1406    /// theme — likely typos that callers may surface as non-fatal advisories.
1407    ///
1408    /// Thin wrapper over [`webui_tokens::unthemed_literal_fallback_tokens`].
1409    #[must_use]
1410    pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
1411        webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
1412    }
1413
1414    /// Structured, color-free advisories for CSS tokens used only with a
1415    /// literal `var()` fallback and defined in no theme — almost always typos.
1416    ///
1417    /// Returned as warning-severity [`Diagnostic`]s so hosts render them with
1418    /// the same location/snippet/`help:` layout as errors (the CLI colorizes;
1419    /// other hosts use the plain [`Diagnostic::body`]). Each carries the token's
1420    /// source location and a `did you mean --…?` suggestion.
1421    #[must_use]
1422    pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
1423        let mut warnings = Vec::new();
1424        for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
1425            let help = match closest_theme_token(&token, theme.themes.values()) {
1426                Some(suggestion) => {
1427                    format!("did you mean --{suggestion}? otherwise the literal fallback is used")
1428                }
1429                None => format!(
1430                    "define --{token} in the theme, or keep its literal fallback if intentional"
1431                ),
1432            };
1433            let diag = self.locate_diagnostic(
1434                Diagnostic::warning(format!("unthemed CSS token --{token}"))
1435                    .code(codes::UNTHEMED_TOKEN)
1436                    .help(help),
1437                &token,
1438            );
1439            warnings.push(diag);
1440        }
1441        warnings
1442    }
1443
1444    /// Adapt a [`webui_tokens::TokenError`] into a structured [`Diagnostic`],
1445    /// enriching it with the token's source location and a `did you mean …?`
1446    /// suggestion drawn from the theme's own tokens.
1447    #[cold]
1448    #[inline(never)]
1449    fn theme_token_error(
1450        &self,
1451        source: webui_tokens::TokenError,
1452        theme: &webui_tokens::TokenFile,
1453    ) -> ParserError {
1454        match source {
1455            webui_tokens::TokenError::MissingToken {
1456                theme: theme_name,
1457                token,
1458            } => {
1459                // The error title + snippet already say the token is missing
1460                // from the theme, so the help only adds what isn't obvious: the
1461                // likely-typo suggestion and the local-definition escape hatch.
1462                let help =
1463                    match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
1464                        Some(suggestion) => {
1465                            format!("did you mean --{suggestion}? otherwise define it locally")
1466                        }
1467                        None => {
1468                            "define it locally if it should not come from the theme".to_string()
1469                        }
1470                    };
1471                self.locate_diagnostic(
1472                    Diagnostic::error("missing theme token")
1473                        .code(codes::MISSING_THEME_TOKEN)
1474                        .help(help),
1475                    &token,
1476                )
1477                .into()
1478            }
1479            other => ParserError::Generic(format!("Theme token validation failed: {other}")),
1480        }
1481    }
1482
1483    /// Attach `token`'s recorded source location (owner, `line:column`, snippet)
1484    /// to `diag`. Falls back to a `--token` snippet when no location is known.
1485    fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
1486        let Some(site) = self.token_sites.get(token) else {
1487            return diag.snippet(format!("--{token}"));
1488        };
1489        let mut diag = diag.component(site.owner.clone());
1490        if let Some((line, column)) = site.position {
1491            diag = diag.position(line, column);
1492        }
1493        match &site.snippet {
1494            Some(snippet) => diag.snippet(snippet.clone()),
1495            None => diag.snippet(format!("--{token}")),
1496        }
1497    }
1498}
1499
1500/// Closest theme token to `target` by Levenshtein distance across the given
1501/// theme token maps, considering each theme's keys. Sorted for determinism.
1502#[cold]
1503#[inline(never)]
1504fn closest_theme_token<'a>(
1505    target: &str,
1506    themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
1507) -> Option<String> {
1508    let mut names: Vec<&str> = themes
1509        .flat_map(|theme| theme.keys().map(String::as_str))
1510        .collect();
1511    names.sort_unstable();
1512    names.dedup();
1513    crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
1514}
1515
1516impl HtmlParser {
1517    /// Create a new directive parser with default parser options.
1518    #[must_use]
1519    pub fn new() -> Self {
1520        Self::with_options(ParserOptions::default())
1521    }
1522
1523    /// Create a new directive parser with explicit parser options.
1524    #[must_use]
1525    pub fn with_options(options: impl Into<ParserOptions>) -> Self {
1526        let options = options.into();
1527
1528        Self {
1529            component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
1530            css_parser: CssParser::new(),
1531            id_counter: FragmentIdCounter::new(),
1532            condition_parser: ConditionParser::new(),
1533            handlebars_parser: HandlebarsParser::new(),
1534            raw_buffer: String::new(),
1535            fragment_records: WebUIFragmentRecords::new(),
1536            warnings: Vec::new(),
1537            options,
1538            plugin: None,
1539            component_processing: ComponentProcessing::default(),
1540            component_dom_analyses: HashMap::new(),
1541            token_roots: Vec::new(),
1542            fragment_css_tokens: HashMap::new(),
1543            in_progress_fragments: HashSet::new(),
1544            current_fragment_id: String::new(),
1545            loop_owner_indices: HashMap::new(),
1546            named_for_loops: NamedForLoops::default(),
1547            named_for_fragment_ids: HashSet::new(),
1548            next_boundary_declaration_id: 0,
1549            boundary_names_by_owner: HashMap::new(),
1550            in_boundary: false,
1551            boundary_ancestor_depth: 0,
1552            body_depth: 0,
1553            for_depth: 0,
1554            route_depth: 0,
1555            foster_context_depth: 0,
1556            boundary_parent_scope: None,
1557            module_entry_srcs: Vec::new(),
1558        }
1559    }
1560
1561    /// Create a new parser with a plugin and default parser options.
1562    #[must_use]
1563    pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
1564        Self::with_plugin_options(plugin, ParserOptions::default())
1565    }
1566
1567    /// Create a new parser with a plugin and explicit parser options.
1568    #[must_use]
1569    pub fn with_plugin_options(
1570        mut plugin: Box<dyn ParserPlugin>,
1571        options: impl Into<ParserOptions>,
1572    ) -> Self {
1573        let mut p = Self::with_options(options);
1574        plugin.configure_parser(&p.options);
1575        let component_processing = plugin.component_processing();
1576        p.component_registry
1577            .set_component_source_transform(component_processing.source_transform);
1578        p.component_processing = component_processing;
1579        p.plugin = Some(plugin);
1580        p
1581    }
1582
1583    /// Get a mutable reference to the component registry.
1584    pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
1585        &mut self.component_registry
1586    }
1587
1588    /// Get a shared reference to the component registry.
1589    #[must_use]
1590    pub fn component_registry(&self) -> &ComponentRegistry {
1591        &self.component_registry
1592    }
1593
1594    pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
1595        std::mem::take(&mut self.fragment_records)
1596    }
1597
1598    /// Take non-fatal parser warnings collected so far.
1599    #[must_use]
1600    pub fn take_warnings(&mut self) -> Vec<Diagnostic> {
1601        std::mem::take(&mut self.warnings)
1602    }
1603
1604    /// Check if a fragment ID has been parsed (exists in the fragment records).
1605    pub fn has_fragment(&self, fragment_id: &str) -> bool {
1606        self.fragment_records.contains_key(fragment_id)
1607    }
1608
1609    /// Compute transitive boundary presence and conservative declaration
1610    /// repeatability for the complete parsed fragment graph, and reject every
1611    /// boundary a `<for>` repeat body can reach.
1612    ///
1613    /// This runs only after a top-level parse succeeds. Every analysis is
1614    /// iterative: presence propagates through reverse edges, repeat
1615    /// reachability is a multi-source forward walk, and occurrence counts use a
1616    /// two-value (once/many) fixed point. Cycles therefore converge without
1617    /// recursion and are conservatively classified as repeatable.
1618    #[cold]
1619    #[inline(never)]
1620    fn finalize_boundary_metadata(&mut self) -> Result<()> {
1621        if self.next_boundary_declaration_id != 0 {
1622            let (outgoing, direct_boundary_records) = self.boundary_graph();
1623            self.mark_boundary_records(&outgoing, direct_boundary_records);
1624            if let Some(violation) = self.find_repeat_reachable_boundary() {
1625                return Err(Self::repeat_reachable_boundary_error(&violation));
1626            }
1627            let occurrence_counts = self.boundary_occurrence_counts(&outgoing);
1628
1629            let mut unkeyed_repeat: Option<(u32, String, String)> = None;
1630            for (fragment_id, list) in &mut self.fragment_records {
1631                let repeated = occurrence_counts
1632                    .get(fragment_id)
1633                    .is_some_and(|count| *count > 1);
1634                if !repeated {
1635                    continue;
1636                }
1637                for fragment in &mut list.fragments {
1638                    if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_mut() {
1639                        if boundary.phase() == BoundaryPhase::Start {
1640                            boundary.may_repeat = true;
1641                            if boundary.key.is_none()
1642                                && unkeyed_repeat
1643                                    .as_ref()
1644                                    .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1645                            {
1646                                unkeyed_repeat = Some((
1647                                    boundary.declaration_id,
1648                                    boundary.owner_fragment_id.clone(),
1649                                    boundary.name.clone(),
1650                                ));
1651                            }
1652                        }
1653                    }
1654                }
1655            }
1656            if let Some((_, owner, name)) = unkeyed_repeat {
1657                return Err(Self::missing_boundary_key_error(&owner, &name));
1658            }
1659
1660            if let Some((owner, name)) = self.find_transitively_nested_boundary() {
1661                return Err(Self::transitively_nested_boundary_error(&owner, &name));
1662            }
1663        }
1664        self.finalize_component_span_signals();
1665        Ok(())
1666    }
1667
1668    fn finalize_component_span_signals(&mut self) {
1669        const START: &str = "streaming_span_start:";
1670        const END: &str = "streaming_span_end:";
1671
1672        let spanning: HashSet<String> = self
1673            .fragment_records
1674            .iter()
1675            .filter_map(|(id, list)| list.contains_boundary.then_some(id.clone()))
1676            .collect();
1677        for list in self.fragment_records.values_mut() {
1678            let mut normalized = Vec::with_capacity(list.fragments.len());
1679            for fragment in list.fragments.drain(..) {
1680                let remove = match fragment.fragment.as_ref() {
1681                    Some(Fragment::Signal(signal)) => signal
1682                        .value
1683                        .strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1684                        .and_then(|value| {
1685                            value
1686                                .strip_prefix(START)
1687                                .or_else(|| value.strip_prefix(END))
1688                        })
1689                        .is_some_and(|tag| !spanning.contains(tag)),
1690                    _ => false,
1691                };
1692                if remove {
1693                    continue;
1694                }
1695                if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
1696                    if let Some(WebUIFragment {
1697                        fragment: Some(Fragment::Raw(previous)),
1698                    }) = normalized.last_mut()
1699                    {
1700                        previous.value.push_str(&raw.value);
1701                        continue;
1702                    }
1703                }
1704                normalized.push(fragment);
1705            }
1706            list.fragments = normalized;
1707        }
1708    }
1709
1710    fn boundary_graph(&self) -> (HashMap<String, Vec<BoundaryGraphEdge>>, Vec<String>) {
1711        let mut outgoing: HashMap<String, Vec<BoundaryGraphEdge>> =
1712            HashMap::with_capacity(self.fragment_records.len());
1713        let mut direct_boundary_records = Vec::new();
1714
1715        for (fragment_id, list) in &self.fragment_records {
1716            let edges = outgoing.entry(fragment_id.clone()).or_default();
1717            for fragment in &list.fragments {
1718                match fragment.fragment.as_ref() {
1719                    Some(Fragment::Component(component)) => {
1720                        Self::push_boundary_edge(edges, &component.fragment_id);
1721                    }
1722                    Some(Fragment::ForLoop(for_loop)) => {
1723                        Self::push_boundary_edge(edges, &for_loop.fragment_id);
1724                    }
1725                    Some(Fragment::IfCond(if_cond)) => {
1726                        Self::push_boundary_edge(edges, &if_cond.fragment_id);
1727                    }
1728                    Some(Fragment::Boundary(boundary)) => {
1729                        if boundary.phase() == BoundaryPhase::Start {
1730                            direct_boundary_records.push(fragment_id.clone());
1731                        }
1732                    }
1733                    Some(Fragment::Route(route)) => {
1734                        Self::push_route_boundary_edges(edges, route);
1735                    }
1736                    _ => {}
1737                }
1738            }
1739        }
1740        (outgoing, direct_boundary_records)
1741    }
1742
1743    fn push_boundary_edge(edges: &mut Vec<BoundaryGraphEdge>, target: &str) {
1744        if !target.is_empty() {
1745            edges.push(BoundaryGraphEdge {
1746                target: target.to_string(),
1747            });
1748        }
1749    }
1750
1751    fn push_route_boundary_edges(edges: &mut Vec<BoundaryGraphEdge>, root: &WebUiFragmentRoute) {
1752        let mut pending = vec![root];
1753        while let Some(route) = pending.pop() {
1754            Self::push_boundary_edge(edges, &route.fragment_id);
1755            Self::push_boundary_edge(edges, &route.content_fragment_id);
1756            Self::push_boundary_edge(edges, &route.pending_component);
1757            Self::push_boundary_edge(edges, &route.error_component);
1758            pending.extend(route.children.iter());
1759        }
1760    }
1761
1762    fn mark_boundary_records(
1763        &mut self,
1764        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1765        direct_boundary_records: Vec<String>,
1766    ) {
1767        for list in self.fragment_records.values_mut() {
1768            list.contains_boundary = false;
1769        }
1770
1771        let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
1772        for (parent, edges) in outgoing {
1773            for edge in edges {
1774                reverse
1775                    .entry(edge.target.as_str())
1776                    .or_default()
1777                    .push(parent.as_str());
1778            }
1779        }
1780
1781        let mut marked = HashSet::with_capacity(self.fragment_records.len());
1782        let mut pending = direct_boundary_records;
1783        while let Some(fragment_id) = pending.pop() {
1784            if !marked.insert(fragment_id.clone()) {
1785                continue;
1786            }
1787            if let Some(list) = self.fragment_records.get_mut(&fragment_id) {
1788                list.contains_boundary = true;
1789            }
1790            if let Some(parents) = reverse.get(fragment_id.as_str()) {
1791                pending.extend(parents.iter().map(|parent| (*parent).to_string()));
1792            }
1793        }
1794    }
1795
1796    fn boundary_occurrence_counts(
1797        &self,
1798        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1799    ) -> HashMap<String, u8> {
1800        let mut incoming: HashMap<&str, Vec<&str>> = HashMap::new();
1801        for (parent, edges) in outgoing {
1802            for edge in edges {
1803                incoming
1804                    .entry(edge.target.as_str())
1805                    .or_default()
1806                    .push(parent.as_str());
1807            }
1808        }
1809
1810        let mut aggregate = HashMap::with_capacity(self.fragment_records.len());
1811        for root in &self.token_roots {
1812            if !self.fragment_records.contains_key(root) {
1813                continue;
1814            }
1815            let counts = self.boundary_occurrences_from_root(root.as_str(), outgoing, &incoming);
1816            for (fragment_id, count) in counts {
1817                let current = aggregate.entry(fragment_id).or_insert(0);
1818                *current = (*current).max(count);
1819            }
1820        }
1821        aggregate
1822    }
1823
1824    fn boundary_occurrences_from_root(
1825        &self,
1826        root: &str,
1827        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1828        incoming: &HashMap<&str, Vec<&str>>,
1829    ) -> HashMap<String, u8> {
1830        let mut counts: HashMap<String, u8> = HashMap::with_capacity(self.fragment_records.len());
1831        counts.insert(root.to_string(), 1);
1832        let mut pending = vec![root.to_string()];
1833        while let Some(parent) = pending.pop() {
1834            let Some(edges) = outgoing.get(&parent) else {
1835                continue;
1836            };
1837            for edge in edges {
1838                let next = Self::recompute_boundary_occurrences(
1839                    edge.target.as_str(),
1840                    root,
1841                    incoming,
1842                    &counts,
1843                );
1844                let current = counts.get(&edge.target).copied().unwrap_or(0);
1845                if next > current {
1846                    counts.insert(edge.target.clone(), next);
1847                    pending.push(edge.target.clone());
1848                }
1849            }
1850        }
1851        counts
1852    }
1853
1854    fn recompute_boundary_occurrences(
1855        fragment_id: &str,
1856        root: &str,
1857        incoming: &HashMap<&str, Vec<&str>>,
1858        counts: &HashMap<String, u8>,
1859    ) -> u8 {
1860        let mut count = u8::from(fragment_id == root);
1861        let Some(parents) = incoming.get(fragment_id) else {
1862            return count;
1863        };
1864        for parent in parents {
1865            let parent_count = counts.get(*parent).copied().unwrap_or(0);
1866            count = count.saturating_add(parent_count).min(2);
1867            if count == 2 {
1868                break;
1869            }
1870        }
1871        count
1872    }
1873
1874    /// Locate the lowest-numbered declaration a `<for>` repeat body reaches.
1875    ///
1876    /// Reachability is a single multi-source forward walk seeded with every
1877    /// repeat body record, so a graph with many loops still costs one pass. It
1878    /// follows the same component/condition/loop/route edges the presence
1879    /// analysis uses, plus the route-child records an enclosing route mounts at
1880    /// its component's `<outlet />` — the one runtime edge that has no
1881    /// lexical counterpart.
1882    fn find_repeat_reachable_boundary(&self) -> Option<RepeatBoundaryViolation> {
1883        let mut origin: HashMap<&str, &str> = HashMap::new();
1884        let mut pending: Vec<&str> = Vec::new();
1885        let mut repeats: HashMap<&str, RepeatSite<'_>> = HashMap::new();
1886        let mut seeds: Vec<(&str, RepeatSite<'_>)> = Vec::new();
1887
1888        for (owner, list) in &self.fragment_records {
1889            for fragment in &list.fragments {
1890                let Some(Fragment::ForLoop(for_loop)) = fragment.fragment.as_ref() else {
1891                    continue;
1892                };
1893                if for_loop.fragment_id.is_empty() {
1894                    continue;
1895                }
1896                let body = for_loop.fragment_id.as_str();
1897                seeds.push((
1898                    body,
1899                    RepeatSite {
1900                        owner: owner.as_str(),
1901                        item: for_loop.item.as_str(),
1902                        collection: for_loop.collection.as_str(),
1903                    },
1904                ));
1905            }
1906        }
1907        seeds.sort_unstable_by(|(left_body, left), (right_body, right)| {
1908            (left.owner, left.item, left.collection, *left_body).cmp(&(
1909                right.owner,
1910                right.item,
1911                right.collection,
1912                *right_body,
1913            ))
1914        });
1915        for (body, site) in seeds {
1916            if !origin.contains_key(body) {
1917                repeats.insert(body, site);
1918                origin.insert(body, body);
1919                pending.push(body);
1920            }
1921        }
1922        // `pending` is a LIFO stack. Reverse the sorted seeds so the lowest
1923        // repeat site owns every shared reachable record deterministically.
1924        pending.reverse();
1925        if pending.is_empty() {
1926            return None;
1927        }
1928
1929        let mounts = self.route_mount_targets();
1930        let mut found: Option<(u32, &str, &WebUiFragmentBoundary)> = None;
1931        let mut targets: Vec<&str> = Vec::new();
1932        while let Some(id) = pending.pop() {
1933            let Some(seed) = origin.get(id).copied() else {
1934                continue;
1935            };
1936            let Some(list) = self.fragment_records.get(id) else {
1937                continue;
1938            };
1939            for fragment in &list.fragments {
1940                if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
1941                    if boundary.phase() == BoundaryPhase::Start
1942                        && found
1943                            .as_ref()
1944                            .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1945                    {
1946                        found = Some((boundary.declaration_id, seed, boundary));
1947                    }
1948                }
1949            }
1950            targets.clear();
1951            push_child_records(list, &mut targets);
1952            if let Some(mounted) = mounts.get(id) {
1953                targets.extend(mounted.iter().copied());
1954            }
1955            for target in targets.drain(..) {
1956                if target.is_empty() {
1957                    continue;
1958                }
1959                if !origin.contains_key(target) {
1960                    origin.insert(target, seed);
1961                    pending.push(target);
1962                }
1963            }
1964        }
1965
1966        found.map(|(_, seed, boundary)| {
1967            let site = repeats.get(seed);
1968            RepeatBoundaryViolation {
1969                repeat_owner: site.map_or_else(String::new, |site| site.owner.to_string()),
1970                each: site.map_or_else(String::new, |site| {
1971                    format!("{} in {}", site.item, site.collection)
1972                }),
1973                boundary_owner: boundary.owner_fragment_id.clone(),
1974                boundary_name: boundary.name.clone(),
1975            }
1976        })
1977    }
1978
1979    /// Map every `<outlet />`-hosting record to the child-route records a
1980    /// route can mount there.
1981    ///
1982    /// `<outlet />` is the only render edge with no lexical counterpart: the
1983    /// mounted record is chosen from the enclosing route's children at request
1984    /// time. Resolving it needs the parent route's render closure, so the map
1985    /// is built once and reused by the repeat walk instead of being recomputed
1986    /// per outlet.
1987    fn route_mount_targets(&self) -> HashMap<&str, Vec<&str>> {
1988        let outlet_hosts = self.outlet_host_records();
1989        if outlet_hosts.is_empty() {
1990            return HashMap::new();
1991        }
1992
1993        let mut mounts: HashMap<&str, Vec<&str>> = HashMap::new();
1994        let mut children: Vec<&str> = Vec::new();
1995        let mut closure: HashSet<&str> = HashSet::new();
1996        let mut pending: Vec<&str> = Vec::new();
1997        let mut targets: Vec<&str> = Vec::new();
1998        for list in self.fragment_records.values() {
1999            for fragment in &list.fragments {
2000                let Some(Fragment::Route(root)) = fragment.fragment.as_ref() else {
2001                    continue;
2002                };
2003                let mut routes = vec![root];
2004                while let Some(route) = routes.pop() {
2005                    routes.extend(route.children.iter());
2006                    if route.children.is_empty() {
2007                        continue;
2008                    }
2009                    children.clear();
2010                    for child in &route.children {
2011                        push_route_records(&mut children, child);
2012                    }
2013                    closure.clear();
2014                    pending.clear();
2015                    for host in [
2016                        route.fragment_id.as_str(),
2017                        route.content_fragment_id.as_str(),
2018                    ] {
2019                        if !host.is_empty() && closure.insert(host) {
2020                            pending.push(host);
2021                        }
2022                    }
2023                    while let Some(id) = pending.pop() {
2024                        if outlet_hosts.contains(id) {
2025                            mounts
2026                                .entry(id)
2027                                .or_default()
2028                                .extend(children.iter().copied());
2029                        }
2030                        let Some(list) = self.fragment_records.get(id) else {
2031                            continue;
2032                        };
2033                        targets.clear();
2034                        push_child_records(list, &mut targets);
2035                        for target in targets.drain(..) {
2036                            if !target.is_empty() && closure.insert(target) {
2037                                pending.push(target);
2038                            }
2039                        }
2040                    }
2041                }
2042            }
2043        }
2044        mounts
2045    }
2046
2047    /// Records that render an `<outlet />`.
2048    fn outlet_host_records(&self) -> HashSet<&str> {
2049        self.fragment_records
2050            .iter()
2051            .filter(|(_, list)| {
2052                list.fragments
2053                    .iter()
2054                    .any(|fragment| matches!(fragment.fragment.as_ref(), Some(Fragment::Outlet(_))))
2055            })
2056            .map(|(id, _)| id.as_str())
2057            .collect()
2058    }
2059
2060    /// Locate the lowest-numbered declaration whose inline body reaches another
2061    /// boundary through a component, condition, loop, or route record.
2062    ///
2063    /// Lexical nesting is already rejected while parsing, so this only has to
2064    /// look at the child records referenced between a start and end marker.
2065    fn find_transitively_nested_boundary(&self) -> Option<(String, String)> {
2066        let mut nested: Option<(u32, &str, &str)> = None;
2067        for list in self.fragment_records.values() {
2068            let mut active: Option<&WebUiFragmentBoundary> = None;
2069            for fragment in &list.fragments {
2070                let Some(boundary) = active else {
2071                    if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
2072                        if boundary.phase() == BoundaryPhase::Start {
2073                            active = Some(boundary);
2074                        }
2075                    }
2076                    continue;
2077                };
2078                if let Some(Fragment::Boundary(marker)) = fragment.fragment.as_ref() {
2079                    if marker.phase() == BoundaryPhase::End {
2080                        active = None;
2081                    }
2082                    continue;
2083                }
2084                if !self.fragment_reaches_boundary(fragment) {
2085                    continue;
2086                }
2087                if nested
2088                    .as_ref()
2089                    .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
2090                {
2091                    nested = Some((
2092                        boundary.declaration_id,
2093                        boundary.owner_fragment_id.as_str(),
2094                        boundary.name.as_str(),
2095                    ));
2096                }
2097            }
2098        }
2099        nested.map(|(_, owner, name)| (owner.to_string(), name.to_string()))
2100    }
2101
2102    /// Whether a fragment inside a boundary body reaches a nested declaration
2103    /// through one of its child records.
2104    fn fragment_reaches_boundary(&self, fragment: &WebUIFragment) -> bool {
2105        let reaches = |id: &str| {
2106            !id.is_empty()
2107                && self
2108                    .fragment_records
2109                    .get(id)
2110                    .is_some_and(|list| list.contains_boundary)
2111        };
2112        match fragment.fragment.as_ref() {
2113            Some(Fragment::Component(component)) => reaches(&component.fragment_id),
2114            Some(Fragment::ForLoop(for_loop)) => reaches(&for_loop.fragment_id),
2115            Some(Fragment::IfCond(if_cond)) => reaches(&if_cond.fragment_id),
2116            Some(Fragment::Route(route)) => {
2117                let mut pending = vec![route];
2118                while let Some(current) = pending.pop() {
2119                    if reaches(&current.fragment_id)
2120                        || reaches(&current.content_fragment_id)
2121                        || reaches(&current.pending_component)
2122                        || reaches(&current.error_component)
2123                    {
2124                        return true;
2125                    }
2126                    pending.extend(current.children.iter());
2127                }
2128                false
2129            }
2130            _ => false,
2131        }
2132    }
2133
2134    #[cold]
2135    #[inline(never)]
2136    fn transitively_nested_boundary_error(owner: &str, name: &str) -> ParserError {
2137        Diagnostic::error(format!(
2138            "boundary \"{name}\" transitively contains another boundary"
2139        ))
2140        .code(codes::NESTED_BOUNDARY)
2141        .component(owner)
2142        .element("boundary")
2143        .snippet(format!("name=\"{name}\""))
2144        .help("remove the inner declaration or move it outside the enclosing boundary, including declarations reached through components and runtime branches")
2145        .into()
2146    }
2147
2148    /// Build the error for a boundary a `<for>` repeat body reaches through a
2149    /// component, condition, route, or outlet.
2150    #[cold]
2151    #[inline(never)]
2152    fn repeat_reachable_boundary_error(violation: &RepeatBoundaryViolation) -> ParserError {
2153        let RepeatBoundaryViolation {
2154            repeat_owner,
2155            each,
2156            boundary_owner,
2157            boundary_name,
2158        } = violation;
2159        Diagnostic::error(format!(
2160            "boundary \"{boundary_name}\" in {boundary_owner} is reachable from a <for> repeat body"
2161        ))
2162        .code(codes::BOUNDARY_IN_REPEAT)
2163        .component(repeat_owner)
2164        .element("for")
2165        .snippet(format!("each=\"{each}\""))
2166        .help(
2167            "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
2168             whole <for> in one <boundary> to render the list as a single independently paced \
2169             region — this includes declarations reached through components, runtime branches, \
2170             routes, and outlets",
2171        )
2172        .into()
2173    }
2174
2175    /// `src` of every authored `<script type="module">` outside any
2176    /// `<boundary>`, in document order, from the most recent top-level
2177    /// [`HtmlParser::parse`] call.
2178    ///
2179    /// These are the page's critical module entries — the ones whose shared
2180    /// chunks belong in `<link rel="modulepreload">`. Island loaders placed
2181    /// inside a boundary are excluded, because deferring them is the point.
2182    ///
2183    /// A `src` containing `{{` is skipped: it is a binding resolved per
2184    /// request, so no build-time artifact can be matched to it.
2185    #[must_use]
2186    pub fn module_entry_srcs(&self) -> &[String] {
2187        &self.module_entry_srcs
2188    }
2189
2190    /// Iterate component tags and whether each effectively uses Shadow DOM.
2191    ///
2192    /// This remains available independently of parser plugins so protocol
2193    /// builders can persist effective per-component ownership in plugin-free builds.
2194    pub fn component_shadow_dom_usage(&self) -> impl Iterator<Item = (&str, bool)> {
2195        self.component_dom_analyses
2196            .iter()
2197            .map(|(tag_name, analysis)| (tag_name.as_str(), analysis.uses_shadow_dom))
2198    }
2199
2200    /// Iterate non-eager component work-policy codes.
2201    pub fn component_work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
2202        self.component_registry.work_policies()
2203    }
2204
2205    /// Take any post-parse artifacts captured by the parser plugin.
2206    ///
2207    /// # Errors
2208    ///
2209    /// Returns [`ParserError::Template`] if a tracked component contains an
2210    /// invalid `@event` handler or a non-braced `w-ref` binding.
2211    pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
2212        self.plugin
2213            .take()
2214            .map_or(Ok(ParserPluginArtifacts::None), |plugin| plugin.finish())
2215    }
2216
2217    /// Take the accumulated CSS tokens as a sorted, deduplicated `Vec`.
2218    ///
2219    /// Convenience wrapper around [`HtmlParser::token_analysis`].
2220    #[must_use]
2221    pub fn take_tokens(&mut self) -> Vec<String> {
2222        self.token_analysis().protocol_tokens
2223    }
2224
2225    /// Analyze CSS token requirements from the parsed fragment/component graph.
2226    ///
2227    /// Each token candidate in a `var()` fallback chain is removed when covered
2228    /// by a same-block definition or an unconditional root/host default in the
2229    /// current fragment or an ancestor component/root. The returned list is
2230    /// sorted and deduplicated from the remaining unresolved candidates.
2231    #[must_use]
2232    pub fn token_analysis(&self) -> CssTokenAnalysis {
2233        let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
2234        let mut protocol_token_set = HashSet::new();
2235        for chain in &fallback_chains {
2236            for token in &chain.tokens {
2237                protocol_token_set.insert(token.as_str());
2238            }
2239        }
2240        let mut protocol_tokens: Vec<String> =
2241            protocol_token_set.into_iter().map(str::to_owned).collect();
2242        protocol_tokens.sort();
2243        CssTokenAnalysis {
2244            protocol_tokens,
2245            fallback_chains,
2246            token_sites,
2247        }
2248    }
2249
2250    fn collect_unresolved_fallback_chains(
2251        &self,
2252    ) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
2253        let mut out = UnresolvedTokens::default();
2254        let mut available_counts: HashMap<&str, usize> = HashMap::new();
2255        let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
2256        let mut active_fragments = HashSet::new();
2257        let mut visits =
2258            (!self.named_for_fragment_ids.is_empty()).then(scoped_visits::ScopedVisits::default);
2259        for root in self.token_roots.iter().rev() {
2260            ops.push(TokenGraphOp::EnterFragment(root.as_str()));
2261        }
2262
2263        while let Some(op) = ops.pop() {
2264            match op {
2265                TokenGraphOp::EnterFragment(fragment_id) => {
2266                    if let Some(visits) = &mut visits {
2267                        if active_fragments.contains(fragment_id)
2268                            || !visits.insert(fragment_id, available_counts.keys().copied())
2269                        {
2270                            continue;
2271                        }
2272                        active_fragments.insert(fragment_id);
2273                        ops.push(TokenGraphOp::ExitFragment(fragment_id));
2274                    }
2275                    self.enter_token_fragment(
2276                        fragment_id,
2277                        &mut available_counts,
2278                        &mut out,
2279                        &mut ops,
2280                    );
2281                }
2282                TokenGraphOp::ExitFragment(fragment_id) => {
2283                    active_fragments.remove(fragment_id);
2284                }
2285                TokenGraphOp::EnterComponent(tag_name) => {
2286                    self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
2287                }
2288                TokenGraphOp::EnterRoute(route) => {
2289                    self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
2290                }
2291                TokenGraphOp::ExitDefinitions(definitions) => {
2292                    remove_token_definitions(definitions, &mut available_counts);
2293                }
2294            }
2295        }
2296
2297        (out.chains, out.sites)
2298    }
2299
2300    fn enter_token_fragment<'a>(
2301        &'a self,
2302        fragment_id: &'a str,
2303        available_counts: &mut HashMap<&'a str, usize>,
2304        out: &mut UnresolvedTokens,
2305        ops: &mut Vec<TokenGraphOp<'a>>,
2306    ) {
2307        if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
2308            add_token_definitions(&css.definitions, available_counts);
2309            // Inline `<style>` tokens record their owning fragment (the entry
2310            // file). A fragment may carry several `<style>` blocks, so an
2311            // offset into one body is ambiguous — record the owner only.
2312            record_unresolved_requirements(
2313                &css.fallback_chains,
2314                available_counts,
2315                fragment_id,
2316                None,
2317                out,
2318            );
2319            ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
2320        }
2321
2322        let Some(fragments) = self.fragment_records.get(fragment_id) else {
2323            return;
2324        };
2325        for fragment in fragments.fragments.iter().rev() {
2326            match fragment.fragment.as_ref() {
2327                Some(web_ui_fragment::Fragment::Component(component)) => {
2328                    ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
2329                }
2330                Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
2331                    ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
2332                }
2333                Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
2334                    ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
2335                }
2336                Some(web_ui_fragment::Fragment::Route(route)) => {
2337                    ops.push(TokenGraphOp::EnterRoute(route));
2338                }
2339                _ => {}
2340            }
2341        }
2342    }
2343
2344    fn enter_token_component<'a>(
2345        &'a self,
2346        tag_name: &'a str,
2347        available_counts: &mut HashMap<&'a str, usize>,
2348        out: &mut UnresolvedTokens,
2349        ops: &mut Vec<TokenGraphOp<'a>>,
2350    ) {
2351        let Some(component) = self.component_registry.get(tag_name) else {
2352            return;
2353        };
2354        add_token_definitions(&component.css_definitions, available_counts);
2355        record_unresolved_requirements(
2356            &component.css_fallback_chains,
2357            available_counts,
2358            &css_owner_label(tag_name),
2359            self.component_registry.diagnostic_css_content(tag_name),
2360            out,
2361        );
2362        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2363        ops.push(TokenGraphOp::EnterFragment(tag_name));
2364    }
2365
2366    fn enter_token_route<'a>(
2367        &'a self,
2368        route: &'a WebUiFragmentRoute,
2369        available_counts: &mut HashMap<&'a str, usize>,
2370        out: &mut UnresolvedTokens,
2371        ops: &mut Vec<TokenGraphOp<'a>>,
2372    ) {
2373        if !route.content_fragment_id.is_empty() {
2374            ops.push(TokenGraphOp::EnterFragment(
2375                route.content_fragment_id.as_str(),
2376            ));
2377        }
2378        let Some(component) = self.component_registry.get(&route.fragment_id) else {
2379            return;
2380        };
2381        add_token_definitions(&component.css_definitions, available_counts);
2382        record_unresolved_requirements(
2383            &component.css_fallback_chains,
2384            available_counts,
2385            &css_owner_label(&route.fragment_id),
2386            self.component_registry
2387                .diagnostic_css_content(&route.fragment_id),
2388            out,
2389        );
2390        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2391        if !route.error_component.is_empty() {
2392            ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
2393        }
2394        if !route.pending_component.is_empty() {
2395            ops.push(TokenGraphOp::EnterComponent(
2396                route.pending_component.as_str(),
2397            ));
2398        }
2399        for child in route.children.iter().rev() {
2400            ops.push(TokenGraphOp::EnterRoute(child));
2401        }
2402        ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
2403    }
2404
2405    fn record_fragment_css_tokens(
2406        &mut self,
2407        definitions: HashSet<String>,
2408        fallback_chains: Vec<CssFallbackChain>,
2409    ) {
2410        let css = self
2411            .fragment_css_tokens
2412            .entry(self.current_fragment_id.clone())
2413            .or_default();
2414        css.definitions.extend(definitions);
2415        css.definitions.sort();
2416        css.definitions.dedup();
2417        css.fallback_chains.extend(fallback_chains);
2418    }
2419
2420    /// Parse HTML content to generate WebUI fragments.
2421    pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2422        let fragment_key = fragment_id.to_string();
2423        let is_token_root = self.in_progress_fragments.is_empty();
2424        if is_token_root {
2425            self.module_entry_srcs.clear();
2426        }
2427        self.boundary_names_by_owner.remove(&fragment_key);
2428        // Save the caller's fragment id and restore it before returning. A
2429        // component parse recurses through `enter_component_directive`
2430        // (`self.parse(child, …)`); without restoring, the parent would keep
2431        // parsing with `current_fragment_id` pointing at the child, so a later
2432        // diagnostic in the parent would be attributed to the wrong owner.
2433        let previous_fragment_id =
2434            std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
2435        if !self.in_progress_fragments.insert(fragment_key.clone()) {
2436            let err = self
2437                .authoring_error(
2438                    codes::RECURSIVE_TEMPLATE,
2439                    format!("recursive template reference while parsing <{fragment_id}>"),
2440                )
2441                .help(
2442                    "move the recursive usage behind runtime data, or split the component graph \
2443                     so templates do not reference themselves at build time",
2444                )
2445                .into();
2446            self.current_fragment_id = previous_fragment_id;
2447            return Err(err);
2448        }
2449        if is_token_root && !self.token_roots.contains(&fragment_key) {
2450            self.token_roots.push(fragment_key.clone());
2451        }
2452
2453        // Lexical directive/HTML context belongs to one authored template.
2454        // Component parsing may recurse while its callsite is inside a loop or
2455        // boundary, but those callsite properties are computed later by the
2456        // fragment graph analysis rather than leaking into the component's
2457        // authored syntax.
2458        let previous_in_boundary = std::mem::replace(&mut self.in_boundary, false);
2459        let previous_boundary_ancestor_depth = self.boundary_ancestor_depth;
2460        if previous_in_boundary {
2461            self.boundary_ancestor_depth += 1;
2462        }
2463        let previous_body_depth = std::mem::replace(&mut self.body_depth, 0);
2464        let previous_for_depth = std::mem::replace(&mut self.for_depth, 0);
2465        let previous_route_depth = std::mem::replace(&mut self.route_depth, 0);
2466        let previous_foster_depth = std::mem::replace(&mut self.foster_context_depth, 0);
2467        let previous_parent_scope = self.boundary_parent_scope.take();
2468        let previous_named_for_loops = std::mem::take(&mut self.named_for_loops);
2469        let owner_index = self.loop_owner_indices.len() + 1;
2470        self.loop_owner_indices
2471            .entry(fragment_key.clone())
2472            .or_insert(owner_index);
2473
2474        let mut result = self.parse_inner(fragment_id, html_content);
2475        if is_token_root && result.is_ok() {
2476            result = self.finalize_boundary_metadata();
2477            if result.is_ok() {
2478                self.finalize_outlet_warnings();
2479            }
2480        }
2481
2482        self.in_boundary = previous_in_boundary;
2483        self.boundary_ancestor_depth = previous_boundary_ancestor_depth;
2484        self.body_depth = previous_body_depth;
2485        self.for_depth = previous_for_depth;
2486        self.route_depth = previous_route_depth;
2487        self.foster_context_depth = previous_foster_depth;
2488        self.boundary_parent_scope = previous_parent_scope;
2489        self.named_for_loops = previous_named_for_loops;
2490        self.in_progress_fragments.remove(&fragment_key);
2491        self.current_fragment_id = previous_fragment_id;
2492        result
2493    }
2494
2495    fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2496        if html_content.len() > MAX_TEMPLATE_BYTES {
2497            return Err(ParserError::Html(format!(
2498                "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.",
2499                html_content.len()
2500            )));
2501        }
2502
2503        // Reset sub-fragments for new parse
2504        self.raw_buffer.clear();
2505        if let Some(ref mut plugin) = self.plugin {
2506            plugin.begin_fragment(FragmentContext { id: fragment_id });
2507        }
2508
2509        let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
2510        self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
2511        self.named_for_loops.validate(fragment_id, html_content)?;
2512
2513        self.flush_raw_buffer(&mut entry_fragment);
2514
2515        self.fragment_records.insert(
2516            fragment_id.to_string(),
2517            FragmentList {
2518                fragments: entry_fragment,
2519                contains_boundary: false,
2520            },
2521        );
2522
2523        Ok(())
2524    }
2525
2526    /// Add raw content to the buffer
2527    fn add_raw_fragment(&mut self, content: &str) {
2528        if !content.is_empty() {
2529            self.raw_buffer.push_str(content);
2530        }
2531    }
2532
2533    /// Add a for fragment, flushing raw buffer first
2534    fn add_for_fragment(
2535        &mut self,
2536        item: String,
2537        collection: String,
2538        fragment_id: String,
2539        fragments: &mut Vec<WebUIFragment>,
2540    ) {
2541        self.flush_raw_buffer(fragments);
2542        fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
2543    }
2544
2545    /// Add an if fragment, flushing raw buffer first
2546    fn add_if_fragment(
2547        &mut self,
2548        condition: ConditionExpr,
2549        fragment_id: String,
2550        fragments: &mut Vec<WebUIFragment>,
2551    ) {
2552        self.flush_raw_buffer(fragments);
2553        fragments.push(WebUIFragment::if_cond(condition, fragment_id));
2554    }
2555
2556    /// Add a non-raw fragment, flushing the raw buffer first if needed
2557    fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
2558        self.flush_raw_buffer(fragments);
2559        fragments.push(fragment);
2560    }
2561
2562    /// Flush the raw buffer into fragments if not empty
2563    fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
2564        if !self.raw_buffer.is_empty() {
2565            fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
2566        }
2567    }
2568
2569    /// Returns true when a text node should be emitted into the fragment stream.
2570    ///
2571    /// Pure formatting runs that contain line breaks are still dropped, but
2572    /// inline whitespace-only separators such as the spaces around `&gt;` in
2573    /// `{{sectionName}} &gt; {{topicName}}` must be preserved.
2574    fn should_emit_text_content(content: &str) -> bool {
2575        if content.is_empty() {
2576            return false;
2577        }
2578
2579        if !content.trim().is_empty() {
2580            return true;
2581        }
2582
2583        content.chars().all(char::is_whitespace)
2584            && !content.contains('\n')
2585            && !content.contains('\r')
2586    }
2587
2588    fn parse_range(
2589        &mut self,
2590        source: &str,
2591        range: Range<usize>,
2592        fragments: &mut Vec<WebUIFragment>,
2593        depth: usize,
2594    ) -> Result<()> {
2595        let mut ops = vec![ParseOp::Parse { range, depth }];
2596        let mut outlet_seen = false;
2597
2598        while let Some(op) = ops.pop() {
2599            match op {
2600                ParseOp::Parse { range, depth } => {
2601                    if depth > MAX_TEMPLATE_DEPTH {
2602                        return Err(self
2603                            .html_error(
2604                                codes::EXCESSIVE_NESTING,
2605                                format!(
2606                                    "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
2607                                ),
2608                                source,
2609                                range.start,
2610                            )
2611                            .help(
2612                                "split deeply nested markup into components, or reduce generated nesting",
2613                            )
2614                            .into());
2615                    }
2616
2617                    let end = range.end;
2618                    let mut index = range.start;
2619                    while index < end {
2620                        let remaining = &source[index..end];
2621                        if remaining.starts_with("<!--") {
2622                            let Some(close) = html::find_comment_close(remaining) else {
2623                                return Err(self
2624                                    .html_error(
2625                                        codes::UNTERMINATED_HTML_COMMENT,
2626                                        "unterminated HTML comment",
2627                                        source,
2628                                        index,
2629                                    )
2630                                    .help("close the comment with `-->`")
2631                                    .into());
2632                            };
2633                            index += close;
2634                            continue;
2635                        }
2636
2637                        if remaining.starts_with("<!") {
2638                            let Some(close) = html::find_declaration_close(remaining) else {
2639                                return Err(self
2640                                    .html_error(
2641                                        codes::UNTERMINATED_HTML_DECLARATION,
2642                                        "unterminated HTML declaration",
2643                                        source,
2644                                        index,
2645                                    )
2646                                    .help("close the declaration with `>`")
2647                                    .into());
2648                            };
2649                            self.add_raw_fragment(&remaining[..close]);
2650                            index += close;
2651                            continue;
2652                        }
2653
2654                        if remaining.starts_with('<') {
2655                            let Some(tag) = html::parse_tag(remaining) else {
2656                                return Err(self
2657                                    .html_error(
2658                                        codes::MALFORMED_HTML_TAG,
2659                                        "malformed HTML tag",
2660                                        source,
2661                                        index,
2662                                    )
2663                                    .help(
2664                                        "close the tag with `>`, or escape a literal `<` as `&lt;`",
2665                                    )
2666                                    .into());
2667                            };
2668                            if tag.closing {
2669                                return Err(self
2670                                    .html_error(
2671                                        codes::UNEXPECTED_CLOSING_TAG,
2672                                        format!("unexpected closing tag </{}>", tag.name),
2673                                        source,
2674                                        index,
2675                                    )
2676                                    .help("remove it, or add the matching opening tag before it")
2677                                    .into());
2678                            }
2679
2680                            let content_start = index + tag.close + 1;
2681                            let (content_end, close_end) = if tag.self_closing
2682                                || html::is_void_element(tag.name)
2683                            {
2684                                (content_start, content_start)
2685                            } else if let Some((close_start, close_end)) =
2686                                html::find_matching_end(remaining, tag.name, tag.close + 1)
2687                            {
2688                                (index + close_start, index + close_end)
2689                            } else {
2690                                return Err(self
2691                                    .html_error(
2692                                        codes::UNCLOSED_HTML_TAG,
2693                                        format!("unclosed <{}> tag", tag.name),
2694                                        source,
2695                                        index,
2696                                    )
2697                                    .help(format!(
2698                                        "add the matching </{}> closing tag, or make the element self-closing",
2699                                        tag.name
2700                                    ))
2701                                    .into());
2702                            };
2703
2704                            let element = Element {
2705                                source,
2706                                start: index,
2707                                tag,
2708                                content_start,
2709                                content_end,
2710                                close_end,
2711                            };
2712                            if close_end < end {
2713                                ops.push(ParseOp::Parse {
2714                                    range: close_end..end,
2715                                    depth,
2716                                });
2717                            }
2718
2719                            match element.name() {
2720                                "for" => {
2721                                    self.enter_for_directive(&element, fragments, depth, &mut ops)?;
2722                                }
2723                                "if" => {
2724                                    self.enter_if_directive(&element, fragments, depth, &mut ops)?;
2725                                }
2726                                "route" => {
2727                                    self.process_route_directive(&element, fragments, depth)?;
2728                                }
2729                                "outlet" => {
2730                                    self.flush_raw_buffer(fragments);
2731                                    if outlet_seen {
2732                                        self.warnings.push(self.multiple_outlets_warning(&element));
2733                                    }
2734                                    outlet_seen = true;
2735                                    fragments.push(WebUIFragment::outlet());
2736                                }
2737                                "boundary" => {
2738                                    self.enter_boundary_directive(
2739                                        &element, fragments, depth, &mut ops,
2740                                    )?;
2741                                }
2742                                name if name.eq_ignore_ascii_case("body") => {
2743                                    self.enter_body_element(&element, fragments, depth, &mut ops)?;
2744                                }
2745                                name if name.eq_ignore_ascii_case("head") => {
2746                                    self.enter_head_element(&element, fragments, depth, &mut ops)?;
2747                                }
2748                                name if name.eq_ignore_ascii_case("webui-hydrate") => {
2749                                    return Err(self.authored_webui_hydrate_error(&element));
2750                                }
2751                                name if name.eq_ignore_ascii_case("style") => {
2752                                    self.process_style_element(&element, fragments)?;
2753                                }
2754                                _ if self.component_registry.contains(element.name()) => {
2755                                    self.enter_component_directive(
2756                                        &element, fragments, depth, &mut ops,
2757                                    )?;
2758                                }
2759                                _ => {
2760                                    self.check_component_typo(&element)?;
2761                                    self.enter_regular_element(
2762                                        &element, fragments, depth, &mut ops,
2763                                    )?;
2764                                }
2765                            }
2766
2767                            break;
2768                        }
2769
2770                        let next = remaining.find('<').unwrap_or(remaining.len());
2771                        let text_end = if next == 0 {
2772                            remaining
2773                                .chars()
2774                                .next()
2775                                .map_or(remaining.len(), char::len_utf8)
2776                        } else {
2777                            next
2778                        };
2779                        self.process_text(&remaining[..text_end], fragments)?;
2780                        index += text_end;
2781                    }
2782                }
2783                ParseOp::EmitClose(name) => {
2784                    self.add_raw_fragment("</");
2785                    self.add_raw_fragment(name);
2786                    self.add_raw_fragment(">");
2787                }
2788                ParseOp::EndComponent(name) => {
2789                    self.add_raw_fragment("</");
2790                    self.add_raw_fragment(name);
2791                    self.add_raw_fragment(">");
2792                    self.flush_raw_buffer(fragments);
2793                    fragments.push(structural_signal(format!("streaming_span_end:{name}")));
2794                }
2795                ParseOp::EndHead(name) => {
2796                    self.flush_raw_buffer(fragments);
2797                    fragments.push(structural_signal("head_end"));
2798                    self.add_raw_fragment("</");
2799                    self.add_raw_fragment(name);
2800                    self.add_raw_fragment(">");
2801                }
2802                ParseOp::EndBody(name) => {
2803                    self.flush_raw_buffer(fragments);
2804                    fragments.push(structural_signal("body_end"));
2805                    self.add_raw_fragment("</");
2806                    self.add_raw_fragment(name);
2807                    self.add_raw_fragment(">");
2808                    self.body_depth = self.body_depth.saturating_sub(1);
2809                }
2810                ParseOp::CompleteBoundary { boundary } => {
2811                    self.flush_raw_buffer(fragments);
2812                    fragments.push(WebUIFragment::boundary_end(boundary.declaration_id));
2813                    self.in_boundary = false;
2814                }
2815                ParseOp::RestoreFosterDepth(previous) => {
2816                    self.foster_context_depth = previous;
2817                }
2818                ParseOp::RestoreBoundaryParentScope(previous) => {
2819                    self.boundary_parent_scope = previous;
2820                }
2821                ParseOp::CompleteFor {
2822                    parent,
2823                    item,
2824                    collection,
2825                    fragment_id,
2826                    named_definition,
2827                    previous_for_depth,
2828                } => {
2829                    self.for_depth = previous_for_depth;
2830                    self.flush_raw_buffer(fragments);
2831                    let for_fragment = std::mem::take(fragments);
2832                    *fragments = parent.fragments;
2833                    self.raw_buffer = parent.raw_buffer;
2834
2835                    if !for_fragment.is_empty() || named_definition {
2836                        self.fragment_records.insert(
2837                            fragment_id.clone(),
2838                            FragmentList {
2839                                fragments: for_fragment,
2840                                contains_boundary: false,
2841                            },
2842                        );
2843                    } else {
2844                        continue;
2845                    }
2846
2847                    self.add_for_fragment(item, collection, fragment_id, fragments);
2848                }
2849                ParseOp::CompleteIf {
2850                    parent,
2851                    condition,
2852                    fragment_id,
2853                } => {
2854                    self.flush_raw_buffer(fragments);
2855                    let if_fragment = std::mem::take(fragments);
2856                    *fragments = parent.fragments;
2857                    self.raw_buffer = parent.raw_buffer;
2858
2859                    self.fragment_records.insert(
2860                        fragment_id.clone(),
2861                        FragmentList {
2862                            fragments: if_fragment,
2863                            contains_boundary: false,
2864                        },
2865                    );
2866                    self.add_if_fragment(condition, fragment_id, fragments);
2867                }
2868            }
2869        }
2870
2871        Ok(())
2872    }
2873
2874    fn enter_regular_element<'a>(
2875        &mut self,
2876        element: &Element<'a>,
2877        fragments: &mut Vec<WebUIFragment>,
2878        depth: usize,
2879        ops: &mut Vec<ParseOp<'a>>,
2880    ) -> Result<()> {
2881        if !self.in_boundary
2882            && self.boundary_ancestor_depth == 0
2883            && element.name().eq_ignore_ascii_case("script")
2884        {
2885            self.record_module_entry(element);
2886        }
2887        self.add_raw_fragment("<");
2888        self.add_raw_fragment(element.name());
2889
2890        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2891        if let Some(ref mut p) = self.plugin {
2892            if let Some(data) = p.finish_opening_tag(ElementStartContext {
2893                tag_name: element.name(),
2894                binding_count,
2895            }) {
2896                self.add_fragment(WebUIFragment::plugin(data), fragments);
2897            }
2898        }
2899
2900        if element.self_closing() {
2901            self.add_raw_fragment("/>");
2902            return Ok(());
2903        }
2904
2905        self.add_raw_fragment(">");
2906        if element.name().eq_ignore_ascii_case("template")
2907            && element
2908                .attrs()
2909                .find(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
2910                .and_then(|attr| attr.value)
2911                .is_some_and(|mode| mode.eq_ignore_ascii_case("open"))
2912            && self
2913                .component_dom_analyses
2914                .get(&self.current_fragment_id)
2915                .is_some_and(|analysis| analysis.uses_shadow_dom)
2916        {
2917            // This compiler-owned no-DOM hook lets the server install the
2918            // complete tree-local style closure before hydratable children.
2919            // It is a protocol fragment rather than an HTML marker, so client
2920            // ordinal paths are unchanged.
2921            self.flush_raw_buffer(fragments);
2922            fragments.push(structural_signal(format!(
2923                "shadow_styles:{}",
2924                self.current_fragment_id
2925            )));
2926        }
2927        if !element.is_void() {
2928            if element.close_end() > element.content_end() {
2929                ops.push(ParseOp::EmitClose(element.name()));
2930            }
2931            self.enter_foster_context(element.name(), ops);
2932            self.enter_boundary_parent_scope(element, ops);
2933            ops.push(ParseOp::Parse {
2934                range: element.inner(),
2935                depth: depth + 1,
2936            });
2937        }
2938        Ok(())
2939    }
2940
2941    /// Record an authored `<script type="module" src="...">` as a critical
2942    /// module entry, so the build can preload the chunks it statically imports.
2943    ///
2944    /// Only bare, static `src` values qualify. A `{{binding}}` resolves per
2945    /// request and cannot be matched to a build artifact, and a script without
2946    /// `type="module"` has no ES module graph to preload. Both are skipped
2947    /// silently rather than diagnosed, because either is a perfectly valid
2948    /// thing to author — they just carry no preload information.
2949    ///
2950    /// The caller has already excluded scripts inside a `<boundary>`.
2951    fn record_module_entry(&mut self, element: &Element<'_>) {
2952        let Some(src) = element.attr("src") else {
2953            return;
2954        };
2955        if src.is_empty() || src.contains("{{") {
2956            return;
2957        }
2958        if element
2959            .attrs()
2960            .find(|attr| attr.name.eq_ignore_ascii_case("fetchpriority"))
2961            .and_then(|attr| attr.value)
2962            .is_some_and(|priority| priority.eq_ignore_ascii_case("low"))
2963        {
2964            return;
2965        }
2966        if !element
2967            .attr("type")
2968            .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module"))
2969        {
2970            return;
2971        }
2972        // Document order is meaningful and duplicates are near-impossible, so
2973        // this stays a `Vec` scan rather than paying for a set.
2974        if self.module_entry_srcs.iter().any(|seen| seen == src) {
2975            return;
2976        }
2977        self.module_entry_srcs.push(src.to_string());
2978    }
2979
2980    fn enter_head_element<'a>(
2981        &mut self,
2982        element: &Element<'a>,
2983        fragments: &mut Vec<WebUIFragment>,
2984        depth: usize,
2985        ops: &mut Vec<ParseOp<'a>>,
2986    ) -> Result<()> {
2987        if element.name() == "head" {
2988            // Preserve the lowercase legacy protocol shape exactly: authored
2989            // attributes on `<head>` were ignored and the opening was emitted
2990            // as the literal `<head>`.
2991            self.add_raw_fragment("<head>");
2992        } else {
2993            // origin/main treated mixed-case native tags as ordinary elements.
2994            // Keep their authored name and attributes while adding only the
2995            // separate case-insensitive streaming structure.
2996            self.add_raw_fragment("<");
2997            self.add_raw_fragment(element.name());
2998            let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2999            if let Some(ref mut p) = self.plugin {
3000                if let Some(data) = p.finish_opening_tag(ElementStartContext {
3001                    tag_name: element.name(),
3002                    binding_count,
3003                }) {
3004                    self.add_fragment(WebUIFragment::plugin(data), fragments);
3005                }
3006            }
3007            self.add_raw_fragment(">");
3008        }
3009        self.flush_raw_buffer(fragments);
3010        // Mirrors `body_start`: emitted immediately after the raw opening
3011        // `<head>` fragment and before any child content (including
3012        // authored async scripts), so `render_streaming` can preflight on it.
3013        // Raw signals unknown to the legacy (non-streaming) handler are
3014        // ignored there, so this is a no-op for existing output.
3015        fragments.push(structural_signal("head_start"));
3016        ops.push(ParseOp::EndHead(element.name()));
3017        ops.push(ParseOp::Parse {
3018            range: element.inner(),
3019            depth: depth + 1,
3020        });
3021        Ok(())
3022    }
3023
3024    fn enter_body_element<'a>(
3025        &mut self,
3026        element: &Element<'a>,
3027        fragments: &mut Vec<WebUIFragment>,
3028        depth: usize,
3029        ops: &mut Vec<ParseOp<'a>>,
3030    ) -> Result<()> {
3031        self.add_raw_fragment("<");
3032        self.add_raw_fragment(element.name());
3033        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
3034        if let Some(ref mut p) = self.plugin {
3035            if let Some(data) = p.finish_opening_tag(ElementStartContext {
3036                tag_name: element.name(),
3037                binding_count,
3038            }) {
3039                self.add_fragment(WebUIFragment::plugin(data), fragments);
3040            }
3041        }
3042        self.add_raw_fragment(">");
3043        self.flush_raw_buffer(fragments);
3044        fragments.push(structural_signal("body_start"));
3045        self.body_depth += 1;
3046        ops.push(ParseOp::EndBody(element.name()));
3047        ops.push(ParseOp::Parse {
3048            range: element.inner(),
3049            depth: depth + 1,
3050        });
3051        Ok(())
3052    }
3053
3054    /// Start an authoring [`Diagnostic`] for the template currently being
3055    /// parsed, naming the owning fragment (entry file or component tag) when
3056    /// known. `code` is the stable machine-readable [`code`](crate::diagnostic::codes).
3057    ///
3058    /// Marked `#[cold]`/`#[inline(never)]`: this only runs while *building* a
3059    /// build error, so keeping it out-of-line preserves hot parse-path layout.
3060    #[cold]
3061    #[inline(never)]
3062    fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
3063        let diag = Diagnostic::error(title).code(code);
3064        if self.current_fragment_id.is_empty() {
3065            diag
3066        } else {
3067            diag.component(self.current_fragment_id.clone())
3068        }
3069    }
3070
3071    /// Like [`HtmlParser::authoring_error`], but also records the source
3072    /// position (line/column) of `element` so the diagnostic can point at the
3073    /// exact spot in the template.
3074    #[cold]
3075    #[inline(never)]
3076    fn authoring_error_at(
3077        &self,
3078        code: &'static str,
3079        title: impl Into<String>,
3080        element: &Element<'_>,
3081    ) -> Diagnostic {
3082        self.authoring_error(code, title)
3083            .at_offset(element.source(), element.start)
3084    }
3085
3086    /// Build a positioned [`Diagnostic`] for a structural HTML well-formedness
3087    /// error (unclosed/malformed tag, unterminated comment, …), naming the
3088    /// owning fragment and the source position from a byte `offset`.
3089    #[cold]
3090    #[inline(never)]
3091    fn html_error(
3092        &self,
3093        code: &'static str,
3094        title: impl Into<String>,
3095        source: &str,
3096        offset: usize,
3097    ) -> Diagnostic {
3098        self.authoring_error(code, title).at_offset(source, offset)
3099    }
3100
3101    /// Build the warning for a second `<outlet>` at one parsed route level.
3102    #[cold]
3103    #[inline(never)]
3104    fn multiple_outlets_warning(&self, element: &Element<'_>) -> Diagnostic {
3105        outlet_warnings::multiple_outlets_warning(&self.current_fragment_id)
3106            .at_offset(element.source(), element.start)
3107    }
3108
3109    /// Build the `help:` line for an unknown component `<name>`.
3110    ///
3111    /// Offers the closest registered component as a "did you mean …?" fix when
3112    /// one is a near typo; otherwise falls back to the generic registration
3113    /// hint.
3114    #[cold]
3115    #[inline(never)]
3116    fn unknown_component_help(&self, name: &str) -> String {
3117        match suggest::closest_match(name, self.component_registry.names()) {
3118            Some(suggestion) => format!(
3119                "did you mean <{suggestion}>? otherwise register the component by adding a \
3120                 matching .html file"
3121            ),
3122            None => "register the component (add a matching .html file) or check the tag name \
3123                     for a typo"
3124                .to_string(),
3125        }
3126    }
3127
3128    /// Suggest a registered component that an unregistered custom-element `tag`
3129    /// likely mistypes.
3130    ///
3131    /// Only same-namespace candidates are considered — the text before the
3132    /// first `-` must match exactly — so a genuine third-party custom element
3133    /// (`<md-button>` when `<mp-button>` is registered) is never flagged, while
3134    /// an in-namespace slip (`<mp-buton>` → `<mp-button>`) is. Returns `None`
3135    /// for non-hyphenated (native) tags and when no near match exists.
3136    fn suggest_component(&self, tag: &str) -> Option<&str> {
3137        let (prefix, _) = tag.split_once('-')?;
3138        let same_namespace = self
3139            .component_registry
3140            .names()
3141            .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
3142        suggest::closest_match(tag, same_namespace)
3143    }
3144
3145    /// Error with a "did you mean …?" hint when `element` is an unregistered
3146    /// custom-element tag that closely matches a registered component in the
3147    /// same namespace. Genuine external custom elements pass through as raw
3148    /// HTML (returns `Ok`).
3149    fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
3150        if let Some(suggestion) = self.suggest_component(element.name()) {
3151            return Err(self
3152                .authoring_error_at(
3153                    codes::UNKNOWN_COMPONENT,
3154                    format!("unknown component <{}>", element.name()),
3155                    element,
3156                )
3157                .help(format!(
3158                    "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
3159                     .html file",
3160                    element.name()
3161                ))
3162                .into());
3163        }
3164        Ok(())
3165    }
3166
3167    /// Name of an attribute on `element` that looks like a typo of `expected`
3168    /// (close edit distance, not an exact match), if any. Used to suggest the
3169    /// intended directive attribute (e.g. `eahc` → `each`).
3170    #[cold]
3171    #[inline(never)]
3172    fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
3173        suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
3174            .filter(|&name| name != expected)
3175    }
3176
3177    /// Promote a standalone [`ParserError::Css`] into a structured authoring
3178    /// [`Diagnostic`] (code [`codes::INVALID_CSS`], owning fragment), so CSS
3179    /// mistakes render and serialize like every other authoring error. The
3180    /// message already carries the in-`<style>` line/column. Non-CSS errors
3181    /// pass through unchanged.
3182    #[cold]
3183    #[inline(never)]
3184    fn css_diagnostic(&self, err: ParserError) -> ParserError {
3185        match err {
3186            ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
3187            other => other,
3188        }
3189    }
3190
3191    /// Build the error for a `<for>` missing its `each` attribute (cold path).
3192    #[cold]
3193    #[inline(never)]
3194    fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
3195        let diag = self
3196            .authoring_error_at(
3197                codes::MISSING_FOR_EACH,
3198                "missing each attribute on <for>",
3199                element,
3200            )
3201            .element("for");
3202        match Self::attr_typo_suggestion(element, "each") {
3203            Some(typo) => diag.help(format!(
3204                "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
3205            )),
3206            None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
3207        }
3208        .into()
3209    }
3210
3211    fn enter_for_directive<'a>(
3212        &mut self,
3213        element: &Element<'a>,
3214        fragments: &mut Vec<WebUIFragment>,
3215        depth: usize,
3216        ops: &mut Vec<ParseOp<'a>>,
3217    ) -> Result<()> {
3218        let each = element
3219            .attr("each")
3220            .ok_or_else(|| self.for_each_missing_error(element))?;
3221        let (item, collection) = parse_for_each(&self.current_fragment_id, each)
3222            .map_err(|error| locate_for_error(error, element))?;
3223
3224        let named_id = validate_for_id(&self.current_fragment_id, &element.tag)
3225            .map_err(|error| locate_for_error(error, element))?;
3226        let fragment_id = if let Some(id) = named_id {
3227            self.named_for_loops
3228                .register(
3229                    &self.current_fragment_id,
3230                    id,
3231                    item,
3232                    (!element.self_closing(), element.start),
3233                )
3234                .map_err(|error| locate_for_error(error, element))?;
3235            let scoped_id = format!(
3236                "{id}-{}",
3237                self.loop_owner_indices[&self.current_fragment_id]
3238            );
3239            if !self.named_for_fragment_ids.contains(&scoped_id)
3240                && (self.id_counter.has_generated(&scoped_id)
3241                    || self.fragment_records.contains_key(&scoped_id)
3242                    || self.component_registry.contains(&scoped_id)
3243                    || self.in_progress_fragments.contains(&scoped_id))
3244            {
3245                return Err(self.for_id_collision_error(element, &scoped_id));
3246            }
3247            self.named_for_fragment_ids.insert(scoped_id.clone());
3248            scoped_id
3249        } else {
3250            self.next_fragment_id("for")
3251        };
3252        if named_id.is_some() && element.self_closing() {
3253            self.add_for_fragment(
3254                item.to_string(),
3255                collection.to_string(),
3256                fragment_id,
3257                fragments,
3258            );
3259            return Ok(());
3260        }
3261        let parent = ParseContext {
3262            fragments: std::mem::take(fragments),
3263            raw_buffer: std::mem::take(&mut self.raw_buffer),
3264        };
3265
3266        let previous_for_depth = self.for_depth;
3267        self.for_depth += 1;
3268        ops.push(ParseOp::CompleteFor {
3269            parent,
3270            item: item.to_string(),
3271            collection: collection.to_string(),
3272            fragment_id,
3273            named_definition: named_id.is_some(),
3274            previous_for_depth,
3275        });
3276        ops.push(ParseOp::Parse {
3277            range: element.inner(),
3278            depth: depth + 1,
3279        });
3280        Ok(())
3281    }
3282
3283    fn next_fragment_id(&mut self, prefix: &str) -> String {
3284        loop {
3285            let id = self.id_counter.next_id(prefix);
3286            if !self.named_for_fragment_ids.contains(&id) {
3287                return id;
3288            }
3289        }
3290    }
3291
3292    #[cold]
3293    #[inline(never)]
3294    fn for_id_collision_error(&self, element: &Element<'_>, id: &str) -> ParserError {
3295        self.authoring_error_at(
3296            codes::INVALID_FOR_ID,
3297            "named <for> id conflicts with an existing fragment",
3298            element,
3299        )
3300        .element("for")
3301        .help(format!(
3302            "choose a different id; the fragment name '{id}' is already in use"
3303        ))
3304        .into()
3305    }
3306
3307    /// Build the error for an `<if>` missing its `condition` attribute (cold
3308    /// path).
3309    #[cold]
3310    #[inline(never)]
3311    fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
3312        let diag = self
3313            .authoring_error_at(
3314                codes::MISSING_IF_CONDITION,
3315                "missing condition attribute on <if>",
3316                element,
3317            )
3318            .element("if");
3319        match Self::attr_typo_suggestion(element, "condition") {
3320            Some(typo) => diag.help(format!(
3321                "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
3322            )),
3323            None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
3324        }
3325        .into()
3326    }
3327
3328    /// Build the error for a malformed `<if condition>` expression (cold path).
3329    #[cold]
3330    #[inline(never)]
3331    fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
3332        self.authoring_error_at(
3333            codes::INVALID_IF_CONDITION,
3334            "invalid <if> condition expression",
3335            element,
3336        )
3337        .element("if")
3338        .snippet(format!("condition=\"{condition}\""))
3339        .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
3340        .into()
3341    }
3342
3343    fn enter_if_directive<'a>(
3344        &mut self,
3345        element: &Element<'a>,
3346        fragments: &mut Vec<WebUIFragment>,
3347        depth: usize,
3348        ops: &mut Vec<ParseOp<'a>>,
3349    ) -> Result<()> {
3350        let condition_str = element
3351            .attr("condition")
3352            .ok_or_else(|| self.if_condition_missing_error(element))?;
3353
3354        let condition = self
3355            .condition_parser
3356            .parse(strip_condition_braces(condition_str))
3357            .map_err(|_| self.if_condition_invalid_error(element, condition_str))?;
3358
3359        self.flush_raw_buffer(fragments);
3360        let parent = ParseContext {
3361            fragments: std::mem::take(fragments),
3362            raw_buffer: std::mem::take(&mut self.raw_buffer),
3363        };
3364        let fragment_id = self.next_fragment_id("if");
3365
3366        ops.push(ParseOp::CompleteIf {
3367            parent,
3368            condition,
3369            fragment_id,
3370        });
3371        ops.push(ParseOp::Parse {
3372            range: element.inner(),
3373            depth: depth + 1,
3374        });
3375        Ok(())
3376    }
3377
3378    /// Track whether `name` opens (or shields from) an HTML insertion mode
3379    /// that foster-parents unknown elements, pushing a restore op when the
3380    /// depth changes.
3381    ///
3382    /// Called for every regular element, so the common `Transparent` case
3383    /// does no work beyond the classification compare — no op is pushed and
3384    /// no field is written.
3385    #[inline]
3386    fn enter_foster_context<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
3387        match foster_context_of(name) {
3388            FosterContext::Transparent => {}
3389            FosterContext::Hostile => {
3390                ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3391                self.foster_context_depth += 1;
3392            }
3393            FosterContext::Barrier => {
3394                if self.foster_context_depth != 0 {
3395                    ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3396                    self.foster_context_depth = 0;
3397                }
3398            }
3399        }
3400    }
3401
3402    #[inline]
3403    fn enter_boundary_parent_scope<'a>(
3404        &mut self,
3405        element: &Element<'a>,
3406        ops: &mut Vec<ParseOp<'a>>,
3407    ) {
3408        if element.name().eq_ignore_ascii_case("template") && element.has_attr("shadowrootmode") {
3409            return;
3410        }
3411        let Some(scope) = boundary_parent_scope(element.name()) else {
3412            return;
3413        };
3414        ops.push(ParseOp::RestoreBoundaryParentScope(
3415            self.boundary_parent_scope,
3416        ));
3417        self.boundary_parent_scope = Some(scope);
3418    }
3419
3420    #[inline]
3421    fn enter_component_content_scope<'a>(&mut self, ops: &mut Vec<ParseOp<'a>>) {
3422        ops.push(ParseOp::RestoreBoundaryParentScope(
3423            self.boundary_parent_scope,
3424        ));
3425        self.boundary_parent_scope = Some("component host content");
3426    }
3427
3428    /// Enter a `<boundary name="…">` directive.
3429    ///
3430    /// `<boundary>` is a reserved, compile-time-only directive (see
3431    /// "Progressive Streaming Hydration" in `DESIGN.md`): it emits no wrapper
3432    /// element. Its children stay inline in the owner's record, bracketed by a
3433    /// [`BoundaryPhase::Start`]/[`BoundaryPhase::End`] fragment pair.
3434    ///
3435    /// # Errors
3436    ///
3437    /// Returns [`ParserError::Template`] when:
3438    /// - the boundary is nested inside another open boundary
3439    ///   ([`codes::NESTED_BOUNDARY`]);
3440    /// - the boundary is outside an open `<body>`
3441    ///   ([`codes::BOUNDARY_OUTSIDE_BODY`]);
3442    /// - the boundary sits in an HTML foster-parenting context such as
3443    ///   `<table>`/`<tbody>`/`<tr>`/`<select>`
3444    ///   ([`codes::BOUNDARY_IN_FOSTER_CONTEXT`]);
3445    /// - the boundary would cut through component host content or native
3446    ///   raw/inert content ([`codes::BOUNDARY_CROSSES_SCOPE`]);
3447    /// - `name` is missing/empty ([`codes::MISSING_BOUNDARY_NAME`]), dynamic
3448    ///   ([`codes::INVALID_BOUNDARY_NAME`]), or a duplicate within its owner
3449    ///   ([`codes::DUPLICATE_BOUNDARY_NAME`]).
3450    /// - the boundary sits inside a `<for>` repeat body
3451    ///   ([`codes::BOUNDARY_IN_REPEAT`]), or an authored key is malformed
3452    ///   ([`codes::INVALID_BOUNDARY_KEY`]).
3453    fn enter_boundary_directive<'a>(
3454        &mut self,
3455        element: &Element<'a>,
3456        fragments: &mut Vec<WebUIFragment>,
3457        depth: usize,
3458        ops: &mut Vec<ParseOp<'a>>,
3459    ) -> Result<()> {
3460        if let Some(scope) = self.boundary_parent_scope {
3461            return Err(self.boundary_scope_error(element, scope));
3462        }
3463        if self.in_boundary {
3464            return Err(self.nested_boundary_error(element));
3465        }
3466        if self.for_depth != 0 {
3467            return Err(self.boundary_in_repeat_error(element));
3468        }
3469        let owner_fragment_id = self.current_fragment_id.clone();
3470        let owner_is_component = self.component_registry.contains(&owner_fragment_id);
3471        if !owner_is_component && self.body_depth == 0 && self.route_depth == 0 {
3472            return Err(self.boundary_outside_body_error(element));
3473        }
3474        if self.foster_context_depth != 0 {
3475            return Err(self.boundary_in_foster_context_error(element));
3476        }
3477
3478        let name = self.validate_boundary_name(element)?;
3479        let inserted = self
3480            .boundary_names_by_owner
3481            .entry(owner_fragment_id.clone())
3482            .or_default()
3483            .insert(name.clone());
3484        if !inserted {
3485            return Err(self.duplicate_boundary_name_error(element, &name));
3486        }
3487        let key = self.validate_boundary_key(element)?;
3488        let declaration_id = self.allocate_boundary_declaration_id(element)?;
3489        self.in_boundary = true;
3490        self.add_fragment(
3491            WebUIFragment {
3492                fragment: Some(Fragment::Boundary(WebUiFragmentBoundary {
3493                    declaration_id,
3494                    owner_fragment_id,
3495                    name,
3496                    key,
3497                    may_repeat: false,
3498                    phase: BoundaryPhase::Start as i32,
3499                })),
3500            },
3501            fragments,
3502        );
3503
3504        ops.push(ParseOp::CompleteBoundary {
3505            boundary: PendingBoundary { declaration_id },
3506        });
3507        ops.push(ParseOp::Parse {
3508            range: element.inner(),
3509            depth: depth + 1,
3510        });
3511        Ok(())
3512    }
3513
3514    /// Validate `<boundary name>`: required, non-empty, and static (no
3515    /// `{{binding}}`).
3516    fn validate_boundary_name(&self, element: &Element<'_>) -> Result<String> {
3517        let name = element.attr("name").unwrap_or_default();
3518        if name.trim().is_empty() {
3519            return Err(self.missing_boundary_name_error(element));
3520        }
3521        if name.contains("{{") {
3522            return Err(self.invalid_boundary_name_error(element, name));
3523        }
3524        Ok(name.to_string())
3525    }
3526
3527    fn validate_boundary_key(&self, element: &Element<'_>) -> Result<Option<String>> {
3528        if !element.has_attr("key") {
3529            return Ok(None);
3530        }
3531        let Some(raw) = element.attr("key") else {
3532            return Err(self.invalid_boundary_key_error(element, ""));
3533        };
3534        let trimmed = raw.trim();
3535        let braced = trimmed.starts_with("{{") || trimmed.ends_with("}}");
3536        let valid = if braced {
3537            trimmed
3538                .strip_prefix("{{")
3539                .and_then(|value| value.strip_suffix("}}"))
3540                .filter(|value| !value.starts_with('{') && !value.ends_with('}'))
3541                .is_some_and(|value| !value.trim().is_empty())
3542        } else {
3543            !trimmed.is_empty()
3544        };
3545        if !valid {
3546            return Err(self.invalid_boundary_key_error(element, raw));
3547        }
3548        Ok(Some(raw.to_string()))
3549    }
3550
3551    fn allocate_boundary_declaration_id(&mut self, element: &Element<'_>) -> Result<u32> {
3552        let declaration_id = self.next_boundary_declaration_id;
3553        let Some(next) = declaration_id.checked_add(1) else {
3554            return Err(self.too_many_boundaries_error(element));
3555        };
3556        self.next_boundary_declaration_id = next;
3557        Ok(declaration_id)
3558    }
3559
3560    /// Build the error for a `<boundary>` missing its `name` attribute
3561    /// (cold path).
3562    #[cold]
3563    #[inline(never)]
3564    fn missing_boundary_name_error(&self, element: &Element<'_>) -> ParserError {
3565        self.authoring_error_at(
3566            codes::MISSING_BOUNDARY_NAME,
3567            "missing name attribute on <boundary>",
3568            element,
3569        )
3570        .element("boundary")
3571        .help("add a unique static name, e.g. <boundary name=\"counter-ready\">")
3572        .into()
3573    }
3574
3575    /// Build the error for a `<boundary name>` that is not a static
3576    /// string literal (cold path).
3577    #[cold]
3578    #[inline(never)]
3579    fn invalid_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3580        self.authoring_error_at(
3581            codes::INVALID_BOUNDARY_NAME,
3582            "invalid name attribute on <boundary>",
3583            element,
3584        )
3585        .element("boundary")
3586        .snippet(format!("name=\"{name}\""))
3587        .help("boundary names must be a static string literal, e.g. name=\"counter-ready\"")
3588        .into()
3589    }
3590
3591    /// Build the error for a duplicate `<boundary name>` within one owner.
3592    #[cold]
3593    #[inline(never)]
3594    fn duplicate_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3595        self.authoring_error_at(
3596            codes::DUPLICATE_BOUNDARY_NAME,
3597            format!("duplicate <boundary> name \"{name}\""),
3598            element,
3599        )
3600        .element("boundary")
3601        .help("boundary names must be unique within their entry or component template; rename one of the declarations")
3602        .into()
3603    }
3604
3605    #[cold]
3606    #[inline(never)]
3607    fn missing_boundary_key_error(owner: &str, name: &str) -> ParserError {
3608        Diagnostic::error(format!(
3609            "boundary \"{name}\" renders more than once and has no key"
3610        ))
3611        .code(codes::MISSING_BOUNDARY_KEY)
3612        .component(owner)
3613        .element("boundary")
3614        .snippet(format!("name=\"{name}\""))
3615        .help(
3616            "the owning template is rendered from more than one callsite, so every occurrence \
3617             needs a distinct key, e.g. key=\"{{sectionId}}\" — or render the declaration from a \
3618             single callsite",
3619        )
3620        .into()
3621    }
3622
3623    /// Build the error for a `<boundary>` authored directly inside `<for>`.
3624    #[cold]
3625    #[inline(never)]
3626    fn boundary_in_repeat_error(&self, element: &Element<'_>) -> ParserError {
3627        let name = element.attr("name").unwrap_or_default();
3628        self.authoring_error_at(
3629            codes::BOUNDARY_IN_REPEAT,
3630            "<boundary> is not valid inside a <for> repeat",
3631            element,
3632        )
3633        .element("boundary")
3634        .snippet(format!("name=\"{name}\""))
3635        .help(
3636            "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
3637             whole <for> in one <boundary> to render the list as a single independently paced \
3638             region",
3639        )
3640        .into()
3641    }
3642
3643    #[cold]
3644    #[inline(never)]
3645    fn invalid_boundary_key_error(&self, element: &Element<'_>, key: &str) -> ParserError {
3646        self.authoring_error_at(
3647            codes::INVALID_BOUNDARY_KEY,
3648            "invalid key expression on <boundary>",
3649            element,
3650        )
3651        .element("boundary")
3652        .snippet(format!("key=\"{key}\""))
3653        .help("use a non-empty expression such as key=\"item.id\" or key=\"{{item.id}}\"")
3654        .into()
3655    }
3656
3657    #[cold]
3658    #[inline(never)]
3659    fn too_many_boundaries_error(&self, element: &Element<'_>) -> ParserError {
3660        self.authoring_error_at(
3661            codes::TOO_MANY_BOUNDARIES,
3662            "too many boundary declarations",
3663            element,
3664        )
3665        .element("boundary")
3666        .help("split this build into smaller protocols so each declaration has a unique u32 ID")
3667        .into()
3668    }
3669
3670    /// Build the error for a `<boundary>` nested inside another open
3671    /// boundary (cold path).
3672    #[cold]
3673    #[inline(never)]
3674    fn nested_boundary_error(&self, element: &Element<'_>) -> ParserError {
3675        self.authoring_error_at(
3676            codes::NESTED_BOUNDARY,
3677            "nested <boundary> is not allowed",
3678            element,
3679        )
3680        .element("boundary")
3681        .help("boundaries cannot be nested; close the enclosing <boundary> before opening another")
3682        .into()
3683    }
3684
3685    /// Build the error for a `<boundary>` that would cut through a native
3686    /// raw/inert scope or component host content.
3687    #[cold]
3688    #[inline(never)]
3689    fn boundary_scope_error(&self, element: &Element<'_>, scope: &str) -> ParserError {
3690        self.authoring_error_at(
3691            codes::BOUNDARY_CROSSES_SCOPE,
3692            format!("<boundary> cannot appear inside {scope}"),
3693            element,
3694        )
3695        .element("boundary")
3696        .help("move the <boundary> outside this raw, inert, or component-host content scope")
3697        .into()
3698    }
3699
3700    /// Build the error for a `<boundary>` outside an open body.
3701    #[cold]
3702    #[inline(never)]
3703    fn boundary_outside_body_error(&self, element: &Element<'_>) -> ParserError {
3704        self.authoring_error_at(
3705            codes::BOUNDARY_OUTSIDE_BODY,
3706            "<boundary> must appear inside <body>",
3707            element,
3708        )
3709        .element("boundary")
3710        .help("move the <boundary> between the opening <body> and its matching </body>")
3711        .into()
3712    }
3713
3714    /// Build the error for a `<boundary>` inside an HTML
3715    /// foster-parenting insertion mode (cold path).
3716    ///
3717    /// The browser would move the generated `<webui-hydrate>` sentinel out of
3718    /// the table while leaving its payload `<script>` behind, so the
3719    /// coordinator could not pair them and would halt hydration for the whole
3720    /// page. Rejecting at build time keeps that failure impossible.
3721    #[cold]
3722    #[inline(never)]
3723    fn boundary_in_foster_context_error(&self, element: &Element<'_>) -> ParserError {
3724        self.authoring_error_at(
3725            codes::BOUNDARY_IN_FOSTER_CONTEXT,
3726            "<boundary> cannot appear inside a table or select context",
3727            element,
3728        )
3729        .element("boundary")
3730        .help(
3731            "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>",
3732        )
3733        .into()
3734    }
3735
3736    #[cold]
3737    #[inline(never)]
3738    fn route_boundary_placement_error(&self, element: &Element<'_>) -> ParserError {
3739        self.authoring_error_at(
3740            codes::INVALID_ROUTE_BOUNDARY_PLACEMENT,
3741            "route boundary must be a direct <route> child",
3742            element,
3743        )
3744        .element("boundary")
3745        .help("move the <boundary> directly under <route>, or place it in the route component template")
3746        .into()
3747    }
3748
3749    /// Build the error for an authored `<webui-hydrate>` (cold path).
3750    /// `<webui-hydrate>` is reserved for the compiler/handler-generated
3751    /// runtime hydration sentinel and must never appear in authored markup.
3752    #[cold]
3753    #[inline(never)]
3754    fn authored_webui_hydrate_error(&self, element: &Element<'_>) -> ParserError {
3755        self.authoring_error_at(
3756            codes::AUTHORED_WEBUI_HYDRATE,
3757            "<webui-hydrate> is reserved for the generated runtime sentinel",
3758            element,
3759        )
3760        .element("webui-hydrate")
3761        .help("remove <webui-hydrate>; it is emitted only by the compiler/handler, never authored")
3762        .into()
3763    }
3764
3765    fn enter_component_directive<'a>(
3766        &mut self,
3767        element: &Element<'a>,
3768        fragments: &mut Vec<WebUIFragment>,
3769        depth: usize,
3770        ops: &mut Vec<ParseOp<'a>>,
3771    ) -> Result<()> {
3772        let needs_template_build = !self.fragment_records.contains_key(element.name());
3773        let cached_dom_analysis = self.component_dom_analyses.get(element.name()).copied();
3774        let has_plugin_artifact = self
3775            .component_registry
3776            .component_artifact_source(element.name())
3777            .is_some();
3778        let authored_html = if needs_template_build || cached_dom_analysis.is_none() {
3779            self.component_registry
3780                .component_authored_source(element.name())
3781        } else {
3782            None
3783        };
3784        let (dom_analysis, template_source) = {
3785            let component = self.component_registry.get(element.name()).ok_or_else(|| {
3786                self.authoring_error_at(
3787                    codes::UNKNOWN_COMPONENT,
3788                    format!("unknown component <{}>", element.name()),
3789                    element,
3790                )
3791                .help(self.unknown_component_help(element.name()))
3792            })?;
3793            (
3794                cached_dom_analysis.map_or_else(
3795                    || {
3796                        let fast_html = (has_plugin_artifact
3797                            && self.options.dom_strategy == DomStrategy::Shadow)
3798                            .then(|| {
3799                                Self::append_shadow_mode_if_missing(component.html_content.clone())
3800                            });
3801                        analyze_component_dom_with_diagnostic_source(
3802                            element.name(),
3803                            fast_html.as_deref().unwrap_or(&component.html_content),
3804                            self.options.dom_strategy,
3805                            authored_html.as_deref(),
3806                        )
3807                    },
3808                    Ok,
3809                )?,
3810                needs_template_build.then(|| {
3811                    (
3812                        component.html_content.clone(),
3813                        component.css_content.clone(),
3814                    )
3815                }),
3816            )
3817        };
3818        if cached_dom_analysis.is_none() {
3819            self.component_dom_analyses
3820                .insert(element.name().to_string(), dom_analysis);
3821        }
3822
3823        self.flush_raw_buffer(fragments);
3824        fragments.push(structural_signal(format!(
3825            "streaming_span_start:{}",
3826            element.name()
3827        )));
3828        self.add_raw_fragment("<");
3829        self.add_raw_fragment(element.name());
3830        if self
3831            .component_registry
3832            .render_policy(element.name())
3833            .is_some_and(ComponentRenderPolicy::is_interaction_hydration)
3834        {
3835            self.add_raw_fragment(" data-webui-interaction");
3836        }
3837
3838        let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
3839        if let Some(ref mut p) = self.plugin {
3840            if let Some(data) = p.finish_opening_tag(ElementStartContext {
3841                tag_name: element.name(),
3842                binding_count,
3843            }) {
3844                self.add_fragment(WebUIFragment::plugin(data), fragments);
3845            }
3846        }
3847
3848        // Emit a compiler-owned streamed SSR root signal immediately before the
3849        // component opening tag closes. Ordinary rendering ignores it byte-for-byte;
3850        // streaming rendering consumes it to inject ` data-ws` inside the opening tag
3851        // (so the marker exists before custom-element upgrade). Placing the signal
3852        // between the attribute run and the closing `>` is required because an
3853        // attribute cannot be injected once `>` has already been flushed.
3854        self.flush_raw_buffer(fragments);
3855        fragments.push(structural_signal(format!(
3856            "streaming_root:{}",
3857            element.name()
3858        )));
3859
3860        if element.self_closing() {
3861            self.add_raw_fragment("/>");
3862        } else {
3863            self.add_raw_fragment(">");
3864        }
3865
3866        self.flush_raw_buffer(fragments);
3867
3868        if let Some((html_content, css_content)) = template_source {
3869            let artifact_html = self
3870                .component_registry
3871                .component_artifact_source(element.name())
3872                .map(str::to_string);
3873            let built = self.build_component_templates(ComponentTemplateInput {
3874                tag_name: element.name(),
3875                html: &html_content,
3876                artifact_html: artifact_html.as_deref(),
3877                authored_html: authored_html.as_deref(),
3878                css_content: css_content.as_deref(),
3879                artifact_needed: self.plugin.is_some(),
3880            })?;
3881
3882            if let Some(ref mut p) = self.plugin {
3883                let component_data = self
3884                    .component_registry
3885                    .get(element.name())
3886                    .ok_or_else(|| {
3887                        ParserError::NotFound(format!(
3888                            "component <{}> disappeared during CSS compilation",
3889                            element.name()
3890                        ))
3891                    })?
3892                    .clone();
3893                p.component_built(built.plugin_context(&component_data))?;
3894            }
3895
3896            self.parse(element.name(), &built.ssr)?;
3897        }
3898
3899        fragments.push(WebUIFragment::component(element.name().to_string()));
3900
3901        if element.self_closing() {
3902            fragments.push(structural_signal(format!(
3903                "streaming_span_end:{}",
3904                element.name()
3905            )));
3906        } else {
3907            ops.push(ParseOp::EndComponent(element.name()));
3908            self.enter_component_content_scope(ops);
3909            ops.push(ParseOp::Parse {
3910                range: element.inner(),
3911                depth: depth + 1,
3912            });
3913        }
3914
3915        Ok(())
3916    }
3917
3918    fn analyze_component_dom(
3919        &mut self,
3920        tag_name: &str,
3921        source: &str,
3922        authored_source: Option<&str>,
3923    ) -> Result<ComponentDomAnalysis> {
3924        if let Some(analysis) = self.component_dom_analyses.get(tag_name) {
3925            return Ok(*analysis);
3926        }
3927        let analysis = analyze_component_dom_with_diagnostic_source(
3928            tag_name,
3929            source,
3930            self.options.dom_strategy,
3931            authored_source,
3932        )?;
3933        self.component_dom_analyses
3934            .insert(tag_name.to_string(), analysis);
3935        Ok(analysis)
3936    }
3937
3938    fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
3939        if !Self::should_emit_text_content(content) {
3940            return Ok(());
3941        }
3942
3943        let parsed_fragments = self.handlebars_parser.parse(content)?;
3944        for fragment in parsed_fragments {
3945            if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
3946                // Whitespace inside an emitted text run is part of the binding,
3947                // not inter-element formatting. Client text parts retain it too.
3948                self.add_raw_fragment(&raw.value);
3949            } else if let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() {
3950                if signal.raw && is_html_text_only_scope(self.boundary_parent_scope) {
3951                    self.add_fragment(
3952                        WebUIFragment::raw_text_signal(signal.value.as_str(), true),
3953                        fragments,
3954                    );
3955                } else {
3956                    self.add_fragment(fragment, fragments);
3957                }
3958            } else {
3959                self.add_fragment(fragment, fragments);
3960            }
3961        }
3962        Ok(())
3963    }
3964
3965    fn process_style_content(
3966        &mut self,
3967        css: &str,
3968        comments: &[crate::css_parser::CssComment],
3969        fragments: &mut Vec<WebUIFragment>,
3970    ) {
3971        let mut last_end = 0usize;
3972
3973        for comment in comments {
3974            if comment.start_byte < last_end {
3975                if comment.end_byte > last_end {
3976                    last_end = comment.end_byte;
3977                }
3978                continue;
3979            }
3980
3981            self.add_raw_fragment(&css[last_end..comment.start_byte]);
3982            let comment_text = &css[comment.start_byte..comment.end_byte];
3983            if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
3984                self.add_fragment(fragment, fragments);
3985            } else if comment.preserve {
3986                self.add_raw_fragment(comment_text);
3987            }
3988            last_end = comment.end_byte;
3989        }
3990
3991        self.add_raw_fragment(&css[last_end..]);
3992    }
3993
3994    fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
3995        let signal = comment_policy::parse_css_signal_comment(comment)?;
3996        Some(WebUIFragment::raw_text_signal(signal.path, signal.raw))
3997    }
3998
3999    /// Check if an attribute value is a pure handlebars expression (e.g., "{{name}}" or
4000    /// "{{name}}" with quotes). Returns the inner signal name (borrowed) if so.
4001    fn extract_single_handlebars(value: &str) -> Option<&str> {
4002        let trimmed = value.trim();
4003        if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
4004            let inner = trimmed[2..trimmed.len() - 2].trim();
4005            // Verify there's no other {{ in the middle (i.e., it's truly a single expression)
4006            if !inner.contains("{{") && !inner.is_empty() {
4007                return Some(inner);
4008            }
4009        }
4010        None
4011    }
4012
4013    /// Check if an attribute value contains any handlebars expressions.
4014    fn contains_handlebars(value: &str) -> bool {
4015        value.contains("{{")
4016    }
4017
4018    fn process_tag_attributes(
4019        &mut self,
4020        attrs: Attrs<'_>,
4021        fragments: &mut Vec<WebUIFragment>,
4022        is_component: bool,
4023    ) -> Result<u32> {
4024        let mut first_dynamic_emitted = false;
4025        let mut binding_count: u32 = 0;
4026        for attr in attrs {
4027            let attr_name = attr.name;
4028            let attr_value = attr.value;
4029            let is_property_binding = attr_name.starts_with(':')
4030                && attr_value
4031                    .and_then(Self::extract_single_handlebars)
4032                    .is_some();
4033
4034            // `:property="{{expr}}"` is WebUI-owned syntax. Plugins may classify
4035            // FAST's single-brace `:property="{expr}"`, but cannot discard the
4036            // server-side scope transfer before parser-core processes it.
4037            if !is_property_binding {
4038                if let Some(ref mut p) = self.plugin {
4039                    match p.process_attribute(AttributeContext { name: attr_name }) {
4040                        AttributeAction::Keep => {}
4041                        AttributeAction::Skip => continue,
4042                        AttributeAction::SkipAndCountBinding => {
4043                            binding_count += 1;
4044                            continue;
4045                        }
4046                    }
4047                }
4048            }
4049
4050            if let Some(bool_name) = attr_name.strip_prefix('?') {
4051                if is_component {
4052                    if let Some(condition) = self.parse_boolean_condition(attr_value) {
4053                        let frag = Self::maybe_mark_attr_start(
4054                            WebUIFragment::attribute_boolean(bool_name, condition),
4055                            &mut first_dynamic_emitted,
4056                        );
4057                        self.add_fragment(frag, fragments);
4058                        binding_count += 1;
4059                    }
4060                } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
4061                    binding_count += 1;
4062                }
4063            } else if let Some(prop_name) = attr_name.strip_prefix(':') {
4064                if !is_component {
4065                    return Err(ParserError::Parse(format!(
4066                        ":{prop_name} complex binding is only allowed on custom elements. \
4067                         Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
4068                    )));
4069                }
4070                if Self::is_blocked_complex_property(prop_name) {
4071                    return Err(ParserError::Parse(format!(
4072                        ":{prop_name} is not allowed as a complex attribute binding \
4073                         because it enables arbitrary HTML injection. \
4074                         Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
4075                    )));
4076                }
4077                if let Some(val) = attr_value {
4078                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
4079                        let frag = Self::maybe_mark_attr_start(
4080                            WebUIFragment::attribute_complex(attr_name, signal_name),
4081                            &mut first_dynamic_emitted,
4082                        );
4083                        self.add_fragment(frag, fragments);
4084                        binding_count += 1;
4085                    }
4086                }
4087            } else if is_component && Self::is_skipped_attribute(attr_name) {
4088                if let Some(val) = attr_value {
4089                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
4090                        let frag = WebUIFragment {
4091                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4092                                WebUIFragmentAttribute {
4093                                    name: attr_name.to_string(),
4094                                    value: signal_name.to_string(),
4095                                    attr_skip: true,
4096                                    ..Default::default()
4097                                },
4098                            )),
4099                        };
4100                        self.add_fragment(frag, fragments);
4101                        binding_count += 1;
4102                    } else if Self::contains_handlebars(val) {
4103                        let template_id = self.next_fragment_id("attr");
4104                        let parsed = self.parse_attribute_template(val)?;
4105                        self.fragment_records.insert(
4106                            template_id.clone(),
4107                            FragmentList {
4108                                fragments: parsed,
4109                                contains_boundary: false,
4110                            },
4111                        );
4112                        let frag = WebUIFragment {
4113                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4114                                WebUIFragmentAttribute {
4115                                    name: attr_name.to_string(),
4116                                    template: template_id,
4117                                    attr_skip: true,
4118                                    ..Default::default()
4119                                },
4120                            )),
4121                        };
4122                        self.add_fragment(frag, fragments);
4123                        binding_count += 1;
4124                    } else {
4125                        let frag = WebUIFragment {
4126                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4127                                WebUIFragmentAttribute {
4128                                    name: attr_name.to_string(),
4129                                    value: html_escape::decode_html_entities(val).into_owned(),
4130                                    raw_value: true,
4131                                    attr_skip: true,
4132                                    ..Default::default()
4133                                },
4134                            )),
4135                        };
4136                        self.add_fragment(frag, fragments);
4137                    }
4138                } else {
4139                    self.add_raw_fragment(" ");
4140                    self.add_raw_fragment(attr_name);
4141                }
4142            } else if let Some(val) = attr_value {
4143                if Self::contains_handlebars(val) {
4144                    if is_component {
4145                        if let Some(signal_name) = Self::extract_single_handlebars(val) {
4146                            let frag = Self::maybe_mark_attr_start(
4147                                WebUIFragment::attribute(attr_name, signal_name),
4148                                &mut first_dynamic_emitted,
4149                            );
4150                            self.add_fragment(frag, fragments);
4151                            binding_count += 1;
4152                        } else {
4153                            let template_id = self.next_fragment_id("attr");
4154                            let parsed = self.parse_attribute_template(val)?;
4155                            self.fragment_records.insert(
4156                                template_id.clone(),
4157                                FragmentList {
4158                                    fragments: parsed,
4159                                    contains_boundary: false,
4160                                },
4161                            );
4162                            let frag = Self::maybe_mark_attr_start(
4163                                WebUIFragment::attribute_template(attr_name, template_id),
4164                                &mut first_dynamic_emitted,
4165                            );
4166                            self.add_fragment(frag, fragments);
4167                            binding_count += 1;
4168                        }
4169                    } else {
4170                        self.process_dynamic_attribute(attr_name, val, fragments)?;
4171                        binding_count += 1;
4172                    }
4173                } else if is_component {
4174                    let frag = Self::maybe_mark_attr_start(
4175                        WebUIFragment {
4176                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4177                                WebUIFragmentAttribute {
4178                                    name: attr_name.to_string(),
4179                                    value: html_escape::decode_html_entities(val).into_owned(),
4180                                    raw_value: true,
4181                                    ..Default::default()
4182                                },
4183                            )),
4184                        },
4185                        &mut first_dynamic_emitted,
4186                    );
4187                    self.add_fragment(frag, fragments);
4188                } else {
4189                    self.add_raw_fragment(" ");
4190                    self.add_raw_fragment(attr.raw);
4191                }
4192            } else if is_component {
4193                let fragment = Self::maybe_mark_attr_start(
4194                    WebUIFragment::attribute_boolean(attr_name, ConditionExpr::identifier("true")),
4195                    &mut first_dynamic_emitted,
4196                );
4197                self.add_fragment(fragment, fragments);
4198            } else {
4199                self.add_raw_fragment(" ");
4200                self.add_raw_fragment(attr_name);
4201            }
4202        }
4203        Ok(binding_count)
4204    }
4205
4206    fn parse_attribute_template(&mut self, value: &str) -> Result<Vec<WebUIFragment>> {
4207        let mut fragments = self.handlebars_parser.parse(value)?;
4208        for fragment in &mut fragments {
4209            if let Some(web_ui_fragment::Fragment::Raw(raw)) = &mut fragment.fragment {
4210                if let std::borrow::Cow::Owned(decoded) =
4211                    html_escape::decode_html_entities(&raw.value)
4212                {
4213                    raw.value = decoded;
4214                }
4215            }
4216        }
4217        Ok(fragments)
4218    }
4219
4220    /// Set `attr_start = true` on the first non-skipped attribute fragment for
4221    /// a component element.
4222    fn maybe_mark_attr_start(
4223        mut frag: WebUIFragment,
4224        first_dynamic_emitted: &mut bool,
4225    ) -> WebUIFragment {
4226        if !*first_dynamic_emitted {
4227            if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
4228                a.attr_start = true;
4229            }
4230            *first_dynamic_emitted = true;
4231        }
4232        frag
4233    }
4234
4235    /// Process a boolean attribute (?prefix). Silently drops if value is not a
4236    /// pure handlebars expression.
4237    fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
4238        if let Some(val) = value {
4239            if let Some(expr_str) = Self::extract_single_handlebars(val) {
4240                return Some(
4241                    self.condition_parser
4242                        .parse(expr_str)
4243                        .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
4244                );
4245            }
4246        }
4247
4248        None
4249    }
4250
4251    fn process_boolean_attribute(
4252        &mut self,
4253        name: &str,
4254        value: Option<&str>,
4255        fragments: &mut Vec<WebUIFragment>,
4256    ) -> Result<bool> {
4257        if let Some(condition) = self.parse_boolean_condition(value) {
4258            self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
4259            return Ok(true);
4260        }
4261        // Invalid boolean attribute — silently drop (no output at all)
4262        Ok(false)
4263    }
4264
4265    /// Process a dynamic attribute (regular name with handlebars in value).
4266    fn process_dynamic_attribute(
4267        &mut self,
4268        name: &str,
4269        value: &str,
4270        fragments: &mut Vec<WebUIFragment>,
4271    ) -> Result<()> {
4272        if let Some(signal_name) = Self::extract_single_handlebars(value) {
4273            // Pure handlebars — simple attribute fragment
4274            self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
4275        } else {
4276            // Mixed static + dynamic — create a template sub-stream
4277            let template_id = self.next_fragment_id("attr");
4278            let parsed = self.handlebars_parser.parse(value)?;
4279
4280            self.fragment_records.insert(
4281                template_id.clone(),
4282                FragmentList {
4283                    fragments: parsed,
4284                    contains_boundary: false,
4285                },
4286            );
4287
4288            self.add_fragment(
4289                WebUIFragment::attribute_template(name, template_id),
4290                fragments,
4291            );
4292        }
4293        Ok(())
4294    }
4295
4296    fn process_style_element(
4297        &mut self,
4298        element: &Element<'_>,
4299        fragments: &mut Vec<WebUIFragment>,
4300    ) -> Result<()> {
4301        self.add_raw_fragment(element.opening());
4302        let inner = element.inner();
4303        let style_content = &element.source()[inner.start..inner.end];
4304        let (defs, requirements, comments) = self
4305            .css_parser
4306            .extract_definitions_requirements_and_comments(
4307                style_content,
4308                self.options.legal_comments,
4309            )
4310            .map_err(|e| self.css_diagnostic(e))?;
4311        self.record_fragment_css_tokens(defs, requirements);
4312        self.process_style_content(style_content, &comments, fragments);
4313        if element.close_end() > element.content_end() {
4314            // Reconstruct the closing tag from the parsed name so the emitted
4315            // case mirrors the source (e.g. `<STYLE>` closes with `</STYLE>`),
4316            // matching how regular elements are closed via `ParseOp::EmitClose`.
4317            self.add_raw_fragment("</");
4318            self.add_raw_fragment(element.name());
4319            self.add_raw_fragment(">");
4320        }
4321        Ok(())
4322    }
4323
4324    /// Process a `<route>` directive.
4325    ///
4326    /// Emits a `Fragment::Route` protocol fragment. The handler renders
4327    /// `<webui-route>` elements with server-side route matching — matched
4328    /// routes get `active` + component content, non-matched get `display:none`.
4329    fn process_route_directive(
4330        &mut self,
4331        element: &Element<'_>,
4332        fragments: &mut Vec<WebUIFragment>,
4333        depth: usize,
4334    ) -> Result<()> {
4335        let attrs = Self::route_attrs_from_element(element);
4336        let path = attrs.path.clone();
4337        let component = attrs.component.clone();
4338        route_parser::validate_attributes(&attrs)?;
4339
4340        let route_params: std::collections::HashSet<String> =
4341            route_parser::extract_params(&path)?.into_iter().collect();
4342        if !attrs.cache_tags.is_empty() {
4343            route_parser::validate_tag_placeholders(
4344                &attrs.cache_tags,
4345                &route_params,
4346                "cache-tags",
4347                &path,
4348            )?;
4349        }
4350        if !attrs.invalidates.is_empty() {
4351            route_parser::validate_tag_placeholders(
4352                &attrs.invalidates,
4353                &route_params,
4354                "invalidates",
4355                &path,
4356            )?;
4357        }
4358
4359        if !attrs.pending_component.is_empty() {
4360            self.ensure_route_component_parsed(&attrs.pending_component)?;
4361        }
4362        if !attrs.error_component.is_empty() {
4363            self.ensure_route_component_parsed(&attrs.error_component)?;
4364        }
4365        self.ensure_route_component_parsed(&component)?;
4366        self.flush_raw_buffer(fragments);
4367        let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4368
4369        let mut all_params = std::collections::HashSet::new();
4370        all_params.extend(route_params);
4371        self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
4372        let children =
4373            self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
4374
4375        let mut route_fragment =
4376            route_parser::build_route_fragment(&attrs, component.clone(), children);
4377        route_fragment.content_fragment_id = content_fragment_id;
4378        fragments.push(WebUIFragment::route_from(route_fragment));
4379
4380        Ok(())
4381    }
4382
4383    fn parse_route_boundary_content(
4384        &mut self,
4385        route: &Element<'_>,
4386        depth: usize,
4387    ) -> Result<String> {
4388        let inner = route.inner();
4389        if !route.source()[inner.clone()].contains("<boundary") {
4390            return Ok(String::new());
4391        }
4392        let saved_buffer = std::mem::take(&mut self.raw_buffer);
4393        let previous_route_depth = self.route_depth;
4394        self.route_depth += 1;
4395        let mut content = Vec::new();
4396        let parse_result: Result<()> = (|| {
4397            for event in Walker::new_range(route.source(), inner.start, inner.end) {
4398                if let Event::Element(element) = event {
4399                    if element.name() == "boundary" {
4400                        self.parse_range(
4401                            element.source(),
4402                            element.start..element.close_end(),
4403                            &mut content,
4404                            depth + 1,
4405                        )?;
4406                    }
4407                }
4408            }
4409            self.flush_raw_buffer(&mut content);
4410            Ok(())
4411        })();
4412        self.route_depth = previous_route_depth;
4413        self.raw_buffer = saved_buffer;
4414        parse_result?;
4415
4416        if content.is_empty() {
4417            return Ok(String::new());
4418        }
4419        let fragment_id = self.next_fragment_id("route-content");
4420        self.fragment_records.insert(
4421            fragment_id.clone(),
4422            FragmentList {
4423                fragments: content,
4424                contains_boundary: false,
4425            },
4426        );
4427        Ok(fragment_id)
4428    }
4429
4430    /// Parse nested `<route>` children into route fragments.
4431    ///
4432    /// `depth` is the nesting level of the routes produced here (top-level
4433    /// `<route>` children are depth 1). Nested routes are validated against
4434    /// [`MAX_TEMPLATE_DEPTH`] so pathological route trees cannot exhaust the
4435    /// call stack via `parse_route_as_fragment` recursion.
4436    fn parse_child_routes(
4437        &mut self,
4438        source: &str,
4439        range: Range<usize>,
4440        ancestor_params: &std::collections::HashSet<String>,
4441        depth: usize,
4442    ) -> Result<Vec<WebUiFragmentRoute>> {
4443        if depth > MAX_TEMPLATE_DEPTH {
4444            return Err(self
4445                .html_error(
4446                    codes::EXCESSIVE_NESTING,
4447                    format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4448                    source,
4449                    range.start,
4450                )
4451                .help("flatten deeply nested <route> trees before building")
4452                .into());
4453        }
4454
4455        let mut children = Vec::new();
4456        for event in Walker::new_range(source, range.start, range.end) {
4457            match event {
4458                Event::Element(element) => {
4459                    self.validate_closed_element(&element)?;
4460                    self.reject_boundary_or_hydrate_in_route(&element, true)?;
4461                    if element.name() == "route" {
4462                        children.push(self.parse_route_as_fragment(
4463                            &element,
4464                            ancestor_params,
4465                            depth,
4466                        )?);
4467                    } else if element.name().eq_ignore_ascii_case("style") {
4468                        self.validate_style_element(&element)?;
4469                    } else if !element.self_closing() && !element.is_void() {
4470                        self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
4471                    }
4472                }
4473                Event::Text(text) => {
4474                    if text.contains('<') {
4475                        return Err(self
4476                            .authoring_error(
4477                                codes::MALFORMED_HTML_TAG,
4478                                "malformed HTML tag in <route>",
4479                            )
4480                            .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
4481                            .into());
4482                    }
4483                }
4484                Event::Comment(comment_range) => {
4485                    self.validate_comment_range(source, comment_range)?;
4486                }
4487                Event::Declaration(declaration_range) => {
4488                    self.validate_declaration_range(source, declaration_range)?;
4489                }
4490                Event::ClosingTag(closing_range) => {
4491                    return Err(self.unexpected_closing_tag_error(source, closing_range));
4492                }
4493            }
4494        }
4495
4496        Ok(children)
4497    }
4498
4499    fn validate_route_nesting_depth(
4500        &self,
4501        source: &str,
4502        range: Range<usize>,
4503        depth: usize,
4504    ) -> Result<()> {
4505        let mut stack = vec![(range, depth)];
4506        while let Some((range, depth)) = stack.pop() {
4507            if depth > MAX_TEMPLATE_DEPTH {
4508                return Err(self
4509                    .html_error(
4510                        codes::EXCESSIVE_NESTING,
4511                        format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4512                        source,
4513                        range.start,
4514                    )
4515                    .help("flatten deeply nested <route> trees before building")
4516                    .into());
4517            }
4518
4519            for event in Walker::new_range(source, range.start, range.end) {
4520                if let Event::Element(element) = event {
4521                    self.validate_closed_element(&element)?;
4522                    if element.name() == "route" && !element.self_closing() {
4523                        stack.push((element.inner(), depth + 1));
4524                    }
4525                }
4526            }
4527        }
4528        Ok(())
4529    }
4530
4531    fn validate_ignored_route_html(
4532        &mut self,
4533        source: &str,
4534        range: Range<usize>,
4535        depth: usize,
4536    ) -> Result<()> {
4537        let mut stack = vec![(range, depth)];
4538        while let Some((range, depth)) = stack.pop() {
4539            if depth > MAX_TEMPLATE_DEPTH {
4540                return Err(self
4541                    .html_error(
4542                        codes::EXCESSIVE_NESTING,
4543                        format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4544                        source,
4545                        range.start,
4546                    )
4547                    .help("split deeply nested markup into components, or reduce generated nesting")
4548                    .into());
4549            }
4550
4551            for event in Walker::new_range(source, range.start, range.end) {
4552                match event {
4553                    Event::Element(element) => {
4554                        self.validate_closed_element(&element)?;
4555                        self.reject_boundary_or_hydrate_in_route(&element, false)?;
4556                        if element.name().eq_ignore_ascii_case("style") {
4557                            self.validate_style_element(&element)?;
4558                        } else if !element.self_closing() && !element.is_void() {
4559                            stack.push((element.inner(), depth + 1));
4560                        }
4561                    }
4562                    Event::Text(text) => {
4563                        if text.contains('<') {
4564                            return Err(self
4565                                .authoring_error(
4566                                    codes::MALFORMED_HTML_TAG,
4567                                    "malformed HTML tag in <route>",
4568                                )
4569                                .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
4570                                .into());
4571                        }
4572                    }
4573                    Event::Comment(comment_range) => {
4574                        self.validate_comment_range(source, comment_range)?;
4575                    }
4576                    Event::Declaration(declaration_range) => {
4577                        self.validate_declaration_range(source, declaration_range)?;
4578                    }
4579                    Event::ClosingTag(closing_range) => {
4580                        return Err(self.unexpected_closing_tag_error(source, closing_range));
4581                    }
4582                }
4583            }
4584        }
4585        Ok(())
4586    }
4587
4588    /// Reject `<webui-hydrate>` in route markup and boundaries that are not
4589    /// direct route children.
4590    ///
4591    /// `<route>` children are validated by a separate well-formedness walker
4592    /// ([`Self::parse_child_routes`]/[`Self::validate_ignored_route_html`]),
4593    /// not the main fragment dispatch in [`Self::parse_range`], so both
4594    /// reserved directives need an explicit check here to produce the same
4595    /// structured diagnostics as everywhere else instead of silently passing
4596    /// through as ignored HTML.
4597    fn reject_boundary_or_hydrate_in_route(
4598        &self,
4599        element: &Element<'_>,
4600        allow_boundary: bool,
4601    ) -> Result<()> {
4602        if element.name() == "boundary" && !allow_boundary {
4603            Err(self.route_boundary_placement_error(element))
4604        } else if element.name().eq_ignore_ascii_case("webui-hydrate") {
4605            Err(self.authored_webui_hydrate_error(element))
4606        } else {
4607            Ok(())
4608        }
4609    }
4610
4611    fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
4612        if !element.self_closing()
4613            && !element.is_void()
4614            && element.close_end() == element.content_end()
4615        {
4616            return Err(self
4617                .html_error(
4618                    codes::UNCLOSED_HTML_TAG,
4619                    format!("unclosed <{}> tag", element.name()),
4620                    element.source(),
4621                    element.start,
4622                )
4623                .help(format!(
4624                    "add the matching </{}> closing tag, or make the element self-closing",
4625                    element.name()
4626                ))
4627                .into());
4628        }
4629        Ok(())
4630    }
4631
4632    fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
4633        let inner = element.inner();
4634        let style_content = &element.source()[inner.start..inner.end];
4635        self.css_parser
4636            .extract_definitions_requirements_and_comments(
4637                style_content,
4638                self.options.legal_comments,
4639            )
4640            .map_err(|e| self.css_diagnostic(e))?;
4641        Ok(())
4642    }
4643
4644    fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4645        let start = range.start;
4646        if source[range].ends_with("-->") {
4647            return Ok(());
4648        }
4649        Err(self
4650            .html_error(
4651                codes::UNTERMINATED_HTML_COMMENT,
4652                "unterminated HTML comment in <route>",
4653                source,
4654                start,
4655            )
4656            .help("close the comment with `-->`")
4657            .into())
4658    }
4659
4660    fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4661        let start = range.start;
4662        if source[range].ends_with('>') {
4663            return Ok(());
4664        }
4665        Err(self
4666            .html_error(
4667                codes::UNTERMINATED_HTML_DECLARATION,
4668                "unterminated HTML declaration in <route>",
4669                source,
4670                start,
4671            )
4672            .help("close the declaration with `>`")
4673            .into())
4674    }
4675
4676    fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
4677        let start = range.start;
4678        let title = match html::parse_tag(&source[range]) {
4679            Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
4680            None => "unexpected closing tag in <route>".to_string(),
4681        };
4682        self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
4683            .help("remove it, or add the matching opening tag before it")
4684            .into()
4685    }
4686
4687    fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
4688        route_parser::RouteAttributes {
4689            path: element.attr("path").unwrap_or_default().to_string(),
4690            component: element.attr("component").unwrap_or_default().to_string(),
4691            exact: element.has_attr("exact"),
4692            query: element.attr("query").unwrap_or_default().to_string(),
4693            keep_alive: element.has_attr("keep-alive"),
4694            cache_tags: element
4695                .attr("cache-tags")
4696                .map(route_parser::parse_tag_list)
4697                .unwrap_or_default(),
4698            invalidates: element
4699                .attr("invalidates")
4700                .map(route_parser::parse_tag_list)
4701                .unwrap_or_default(),
4702            pending_component: element.attr("pending").unwrap_or_default().to_string(),
4703            error_component: element.attr("error").unwrap_or_default().to_string(),
4704        }
4705    }
4706
4707    fn parse_route_as_fragment(
4708        &mut self,
4709        element: &Element<'_>,
4710        ancestor_params: &std::collections::HashSet<String>,
4711        depth: usize,
4712    ) -> Result<WebUiFragmentRoute> {
4713        let attrs = Self::route_attrs_from_element(element);
4714        let path = attrs.path.clone();
4715        let component = attrs.component.clone();
4716
4717        route_parser::validate_attributes(&attrs)?;
4718
4719        let own_params: std::collections::HashSet<String> =
4720            route_parser::extract_params(&path)?.into_iter().collect();
4721        let mut all_params = ancestor_params.clone();
4722        all_params.extend(own_params.iter().cloned());
4723        if !attrs.cache_tags.is_empty() {
4724            route_parser::validate_tag_placeholders(
4725                &attrs.cache_tags,
4726                &all_params,
4727                "cache-tags",
4728                &path,
4729            )?;
4730        }
4731        if !attrs.invalidates.is_empty() {
4732            route_parser::validate_tag_placeholders(
4733                &attrs.invalidates,
4734                &all_params,
4735                "invalidates",
4736                &path,
4737            )?;
4738        }
4739
4740        if !attrs.pending_component.is_empty() {
4741            self.ensure_route_component_parsed(&attrs.pending_component)?;
4742        }
4743        if !attrs.error_component.is_empty() {
4744            self.ensure_route_component_parsed(&attrs.error_component)?;
4745        }
4746
4747        self.ensure_route_component_parsed(&component)?;
4748        let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4749        let children =
4750            self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
4751
4752        let mut route = route_parser::build_route_fragment(&attrs, component, children);
4753        route.content_fragment_id = content_fragment_id;
4754        Ok(route)
4755    }
4756
4757    /// Ensure a route-referenced component is parsed and registered.
4758    fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
4759        if component.is_empty()
4760            || !self.component_registry.contains(component)
4761            || self.fragment_records.contains_key(component)
4762        {
4763            return Ok(());
4764        }
4765
4766        let component_data = self
4767            .component_registry
4768            .get(component)
4769            .ok_or_else(|| {
4770                self.authoring_error(
4771                    codes::UNKNOWN_COMPONENT,
4772                    format!("unknown component <{component}>"),
4773                )
4774                .help(self.unknown_component_help(component))
4775            })?
4776            .clone();
4777        let artifact_html = self
4778            .component_registry
4779            .component_artifact_source(component)
4780            .map(str::to_string);
4781        let authored_html = self.component_registry.component_authored_source(component);
4782
4783        let built = self.build_component_templates(ComponentTemplateInput {
4784            tag_name: component,
4785            html: &component_data.html_content,
4786            artifact_html: artifact_html.as_deref(),
4787            authored_html: authored_html.as_deref(),
4788            css_content: component_data.css_content.as_deref(),
4789            artifact_needed: self.plugin.is_some(),
4790        })?;
4791
4792        if let Some(ref mut p) = self.plugin {
4793            let component_data = self
4794                .component_registry
4795                .get(component)
4796                .ok_or_else(|| {
4797                    ParserError::NotFound(format!(
4798                        "component <{component}> disappeared during CSS compilation"
4799                    ))
4800                })?
4801                .clone();
4802            p.component_built(built.plugin_context(&component_data))?;
4803        }
4804
4805        let saved_buffer = std::mem::take(&mut self.raw_buffer);
4806        self.parse(component, &built.ssr)?;
4807        self.raw_buffer = saved_buffer;
4808
4809        Ok(())
4810    }
4811
4812    /// Skipped attribute names for components.
4813    const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
4814    /// Skipped attribute prefixes for components.
4815    const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-"];
4816    const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
4817
4818    fn is_skipped_attribute(name: &str) -> bool {
4819        Self::SKIPPED_ATTRIBUTES.contains(&name)
4820            || Self::SKIPPED_ATTRIBUTE_PREFIXES
4821                .iter()
4822                .any(|prefix| name.starts_with(prefix))
4823    }
4824
4825    /// Properties that must never be set via `:attr` complex bindings.
4826    /// These enable XSS (HTML injection) or arbitrary code execution.
4827    fn is_blocked_complex_property(name: &str) -> bool {
4828        matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
4829    }
4830
4831    /// Build both SSR-facing and plugin-facing component template views.
4832    fn build_component_templates(
4833        &mut self,
4834        input: ComponentTemplateInput<'_>,
4835    ) -> Result<BuiltComponentTemplate> {
4836        let tag_name = input.tag_name;
4837        let synthesize_fast_shadow =
4838            input.artifact_html.is_some() && self.options.dom_strategy == DomStrategy::Shadow;
4839        let fast_html = synthesize_fast_shadow
4840            .then(|| Self::append_shadow_mode_if_missing(input.html.to_string()));
4841        let html = fast_html.as_deref().unwrap_or(input.html);
4842        let css_content = input.css_content;
4843        let dom_analysis = self.analyze_component_dom(tag_name, html, input.authored_html)?;
4844        let is_light = !dom_analysis.uses_shadow_dom;
4845        let mut inline_style_ranges = Vec::new();
4846        if is_light {
4847            let mut comment_ranges = Vec::new();
4848            Self::collect_html_comment_and_style_ranges(
4849                html,
4850                &mut comment_ranges,
4851                &mut inline_style_ranges,
4852            )?;
4853            if let Some(css) = css_content {
4854                css_light::validate_global_css(tag_name, css)?;
4855            }
4856            for (start, end) in &inline_style_ranges {
4857                css_light::validate_global_css(tag_name, &html[*start..*end])?;
4858            }
4859        }
4860        if self.component_registry.contains(tag_name) {
4861            self.component_registry
4862                .prepare_policy_css(tag_name, dom_analysis.uses_shadow_dom)?;
4863        }
4864        let css_content = self
4865            .component_registry
4866            .get(tag_name)
4867            .and_then(|component| component.css_content.as_deref())
4868            .or(css_content);
4869        let adopted_specifier = match self.options.css_strategy {
4870            CssStrategy::Module if css_content.is_some() => Some(tag_name),
4871            _ => None,
4872        };
4873        // CSS resources are installed by the handler from the precomputed
4874        // tree-local closure. Keeping template serialization style-free avoids
4875        // duplicating Light descendants and lets repeated Shadow instances each
4876        // receive an exact, claimable resource set.
4877        //
4878        // The resolved delivery is reported to the plugin as build context; a
4879        // plugin whose client runtime builds its own roots from the captured
4880        // template decides for itself what to do with it. Light CSS is
4881        // Document/tree-owned and is installed by the precomputed closure.
4882        let style = if dom_analysis.uses_shadow_dom && self.plugin.is_some() {
4883            self.component_style_delivery(tag_name, css_content)
4884        } else {
4885            None
4886        };
4887
4888        let runtime_attr_source = match dom_analysis.authored_shadow_root {
4889            Some((start, end)) => &html[start..end],
4890            None => html,
4891        };
4892        let artifact_source = if input.artifact_needed {
4893            input.artifact_html.or_else(|| {
4894                Self::template_has_stripped_runtime_attrs(runtime_attr_source).then_some(html)
4895            })
4896        } else {
4897            None
4898        };
4899        // A `w-render`/`w-hydrate` component authors its policy on a plain
4900        // `<template>` wrapper. The policy lands on the host element as
4901        // generated CSS, so the wrapper itself must not survive into a Light
4902        // template, where its contents would never render.
4903        let policy_wrapper = parse_component_render_policy(tag_name, html)?.is_authored();
4904        // Keep root runtime attributes only when the plugin processes them.
4905        let preserve_root_bindings = self.component_processing.process_root_template_attributes;
4906        // Keep CSS braces after client bindings when requested by the plugin.
4907        let styles_at_end = matches!(self.options.css_strategy, CssStrategy::Style)
4908            && self.component_processing.inline_styles_after_content;
4909        let ssr = self.process_component_template_with_mode(
4910            html,
4911            ComponentStyleInjection {
4912                css_snippet: None,
4913                adopted_specifier,
4914            },
4915            dom_analysis,
4916            ComponentTemplateMode {
4917                preserve_runtime_attrs: preserve_root_bindings,
4918                policy_wrapper,
4919                styles_at_end,
4920            },
4921        )?;
4922        let artifact = match artifact_source {
4923            Some(source) => {
4924                let fast_artifact = synthesize_fast_shadow
4925                    .then(|| Self::append_shadow_mode_if_missing(source.to_string()));
4926                let source = fast_artifact.as_deref().unwrap_or(source);
4927                let artifact_dom_analysis = analyze_component_dom_with_diagnostic_source(
4928                    tag_name,
4929                    source,
4930                    self.options.dom_strategy,
4931                    input.authored_html,
4932                )?;
4933                Some(self.process_component_template_with_mode(
4934                    source,
4935                    ComponentStyleInjection {
4936                        css_snippet: None,
4937                        adopted_specifier,
4938                    },
4939                    artifact_dom_analysis,
4940                    ComponentTemplateMode {
4941                        // Client artifacts preserve runtime-only attributes
4942                        // (`@event`, `:bind`, `?cond`) for the plugin runtime.
4943                        preserve_runtime_attrs: true,
4944                        policy_wrapper,
4945                        styles_at_end,
4946                    },
4947                )?)
4948            }
4949            None => None,
4950        };
4951        Ok(BuiltComponentTemplate {
4952            ssr,
4953            artifact,
4954            uses_shadow_dom: dom_analysis.uses_shadow_dom,
4955            style,
4956        })
4957    }
4958
4959    /// Resolve how a component's compiled CSS reaches the browser.
4960    ///
4961    /// Build-time only, once per component definition, and only when a plugin
4962    /// can observe it.
4963    fn component_style_delivery(
4964        &self,
4965        tag_name: &str,
4966        css_content: Option<&str>,
4967    ) -> Option<OwnedComponentStyle> {
4968        let css = css_content?;
4969        Some(match self.options.css_strategy {
4970            CssStrategy::Link => {
4971                OwnedComponentStyle::Link(self.options.css_link_options.resolve(tag_name, css).href)
4972            }
4973            CssStrategy::Style => OwnedComponentStyle::Inline(css.trim().to_string()),
4974            CssStrategy::Module => OwnedComponentStyle::Adopted(tag_name.to_string()),
4975        })
4976    }
4977
4978    /// Process component template HTML for SSR output.
4979    ///
4980    /// An authored declarative Shadow DOM wrapper is the source of truth.
4981    ///
4982    /// - **Dev supplied `<template shadowrootmode="open">`:** preserved verbatim — including
4983    ///   `shadowrootmode`, `shadowrootadoptedstylesheets`, signal fragments,
4984    ///   and any other custom attributes. SSR strips runtime-only attributes
4985    ///   (`@event`, `:bind`, `?cond`) from the opening tag, since those are
4986    ///   protocol metadata and never appear in HTML output. Plugin-facing
4987    ///   artifacts preserve them. If a CSS snippet is supplied, it is injected
4988    ///   immediately inside the opening tag (before the dev's children) so
4989    ///   styles still apply. For `CssStrategy::Module`, the parser appends
4990    ///   `shadowrootadoptedstylesheets="<tag>"` when it is missing.
4991    ///
4992    /// - **Sole bare `<template>` root:** unwraps the wrapper and emits its
4993    ///   contents as explicit Light DOM.
4994    ///
4995    /// - **No authored root:** follows the configured fallback, generating an
4996    ///   open Shadow wrapper for `DomStrategy::Shadow` or emitting direct Light
4997    ///   DOM for `DomStrategy::Light`.
4998    ///
4999    /// Performance: zero recursion, zero regex. The dev-template path uses
5000    /// quote-aware scanners for opening-tag queries and pre-sizes the output
5001    /// buffer to avoid reallocation in the hot path.
5002    #[cfg(test)]
5003    fn process_component_template(
5004        &mut self,
5005        html: &str,
5006        css_snippet: Option<&str>,
5007        adopted_specifier: Option<&str>,
5008    ) -> Result<String> {
5009        let dom_analysis = analyze_component_dom("component", html, self.options.dom_strategy)?;
5010        self.process_component_template_with_mode(
5011            html,
5012            ComponentStyleInjection {
5013                css_snippet,
5014                adopted_specifier,
5015            },
5016            dom_analysis,
5017            ComponentTemplateMode {
5018                preserve_runtime_attrs: false,
5019                policy_wrapper: false,
5020                styles_at_end: false,
5021            },
5022        )
5023    }
5024
5025    fn process_component_template_with_mode(
5026        &mut self,
5027        html: &str,
5028        style: ComponentStyleInjection<'_>,
5029        dom_analysis: ComponentDomAnalysis,
5030        mode: ComponentTemplateMode,
5031    ) -> Result<String> {
5032        let trimmed_end = html.trim_end();
5033        let (trimmed, content_start) = html::leading_content(trimmed_end);
5034        let snippet = style.css_snippet.unwrap_or_default();
5035
5036        let processed = if let Some((root_start, root_end)) = dom_analysis.authored_shadow_root {
5037            let trim_start = content_start;
5038            let trim_end = html.trim_end().len();
5039            if root_start < trim_start || root_end > trim_end || root_start >= root_end {
5040                return Err(ParserError::Html(
5041                    "invalid declarative Shadow DOM source range".to_string(),
5042                ));
5043            }
5044            let root = &html[root_start..root_end];
5045            // Compiler-owned policy attributes are stripped from both views;
5046            // runtime attributes survive only in the captured client template.
5047            let base = self.strip_template_build_attrs(root, mode.preserve_runtime_attrs);
5048            let with_adopted = Self::append_adopted_attr_if_missing(base, style.adopted_specifier);
5049            let root =
5050                Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end);
5051            let mut result =
5052                String::with_capacity(trimmed.len() + root.len() - (root_end - root_start));
5053            result.push_str(&html[trim_start..root_start]);
5054            result.push_str(&root);
5055            result.push_str(&html[root_end..trim_end]);
5056            result
5057        } else if let Some((content_start, content_end)) = dom_analysis.authored_light_root {
5058            let base = html[content_start..content_end].to_string();
5059            if snippet.is_empty() {
5060                base
5061            } else {
5062                let mut result = String::with_capacity(snippet.len() + base.len());
5063                result.push_str(snippet);
5064                result.push_str(&base);
5065                result
5066            }
5067        } else if dom_analysis.uses_shadow_dom {
5068            if mode.policy_wrapper {
5069                let base = self.strip_template_build_attrs(trimmed, mode.preserve_runtime_attrs);
5070                let with_shadow = Self::append_shadow_mode_if_missing(base);
5071                let with_adopted =
5072                    Self::append_adopted_attr_if_missing(with_shadow, style.adopted_specifier);
5073                Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end)
5074            } else {
5075                let adopted = style.adopted_specifier.unwrap_or_default();
5076                let adopted_extra = if adopted.is_empty() {
5077                    0
5078                } else {
5079                    Self::adopted_attr_len(adopted)
5080                };
5081                let mut result =
5082                    String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
5083                result.push_str("<template shadowrootmode=\"open\"");
5084                if !adopted.is_empty() {
5085                    Self::push_adopted_attr(&mut result, adopted);
5086                }
5087                result.push('>');
5088                if mode.styles_at_end {
5089                    result.push_str(trimmed);
5090                    result.push_str(snippet);
5091                } else {
5092                    result.push_str(snippet);
5093                    result.push_str(trimmed);
5094                }
5095                result.push_str("</template>");
5096                result
5097            }
5098        } else {
5099            // A policy component authors its `w-render`/`w-hydrate` on a plain
5100            // `<template>` wrapper. The policy is applied to the host element
5101            // through generated CSS, so a Light template must drop the wrapper
5102            // or its contents would never render.
5103            let base = if mode.policy_wrapper {
5104                Self::unwrap_component_template(trimmed)
5105            } else {
5106                trimmed.to_string()
5107            };
5108            if snippet.is_empty() {
5109                base
5110            } else {
5111                let mut result = String::with_capacity(snippet.len() + base.len());
5112                result.push_str(snippet);
5113                result.push_str(&base);
5114                result
5115            }
5116        };
5117
5118        self.strip_template_comments(processed)
5119    }
5120
5121    fn inject_css_snippet_into_template(html: String, snippet: &str, at_end: bool) -> String {
5122        if snippet.is_empty() {
5123            return html;
5124        }
5125
5126        // Trail the snippet after the template body (before the closing
5127        // `</template>`) so a client runtime that scans the body for `{`/`}`
5128        // bindings (FAST's declarative TemplateParser) never mistakes raw CSS
5129        // rule blocks for bindings. Styles apply regardless of shadow-root
5130        // position. Falls back to opening-tag injection when no closing tag is
5131        // present so styles are never silently dropped.
5132        if at_end {
5133            if let Some(close_start) = html.rfind("</template>") {
5134                let mut result = String::with_capacity(html.len() + snippet.len());
5135                result.push_str(&html[..close_start]);
5136                result.push_str(snippet);
5137                result.push_str(&html[close_start..]);
5138                return result;
5139            }
5140        }
5141
5142        match html::find_tag_close(&html) {
5143            Some(open_end) => {
5144                let mut result = String::with_capacity(html.len() + snippet.len());
5145                result.push_str(&html[..=open_end]);
5146                result.push_str(snippet);
5147                result.push_str(&html[open_end + 1..]);
5148                result
5149            }
5150            // Malformed opening tag (no closing `>`): emit as-is rather than
5151            // panic. The downstream parser surfaces the error.
5152            None => html,
5153        }
5154    }
5155
5156    fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
5157        let Some(adopted) = adopted_specifier else {
5158            return html;
5159        };
5160        let Some(tag) = html::parse_tag(&html) else {
5161            return html;
5162        };
5163        if !tag.name.eq_ignore_ascii_case("template")
5164            || tag.closing
5165            || tag.attrs().any(|attr| {
5166                attr.name
5167                    .eq_ignore_ascii_case(Self::ADOPTED_STYLESHEETS_ATTR)
5168            })
5169        {
5170            return html;
5171        }
5172
5173        let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
5174        result.push_str(&html[..tag.close]);
5175        Self::push_adopted_attr(&mut result, adopted);
5176        result.push_str(&html[tag.close..]);
5177        result
5178    }
5179
5180    fn append_shadow_mode_if_missing(html: String) -> String {
5181        let Some(tag) = html::parse_tag(&html) else {
5182            return html;
5183        };
5184        if !tag.name.eq_ignore_ascii_case("template")
5185            || tag.closing
5186            || tag
5187                .attrs()
5188                .any(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
5189        {
5190            return html;
5191        }
5192
5193        let mut result = String::with_capacity(html.len() + 22);
5194        result.push_str(&html[..tag.close]);
5195        result.push_str(" shadowrootmode=\"open\"");
5196        result.push_str(&html[tag.close..]);
5197        result
5198    }
5199
5200    fn unwrap_component_template(html: &str) -> String {
5201        let Some(tag) = html::parse_tag(html) else {
5202            return html.to_string();
5203        };
5204        let inner_start = tag.close + 1;
5205        let inner_end = html.rfind("</template>").unwrap_or(html.len());
5206        if inner_start >= inner_end {
5207            return String::new();
5208        }
5209        html[inner_start..inner_end].to_string()
5210    }
5211
5212    fn push_adopted_attr(out: &mut String, adopted: &str) {
5213        out.push(' ');
5214        out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
5215        out.push_str("=\"");
5216        out.push_str(adopted);
5217        out.push('"');
5218    }
5219
5220    fn adopted_attr_len(adopted: &str) -> usize {
5221        4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
5222    }
5223
5224    fn template_has_stripped_runtime_attrs(html: &str) -> bool {
5225        let (trimmed, _) = html::leading_content(html);
5226        let Some(tag) = html::parse_tag(trimmed) else {
5227            return false;
5228        };
5229        if !tag.name.eq_ignore_ascii_case("template") || tag.closing {
5230            return false;
5231        }
5232        tag.attrs().any(|attr| {
5233            attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
5234        })
5235    }
5236
5237    fn strip_template_comments(&mut self, html: String) -> Result<String> {
5238        let mut ranges = Vec::new();
5239        let mut style_ranges = Vec::new();
5240        Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
5241
5242        for (style_start, style_end) in style_ranges {
5243            let css = &html[style_start..style_end];
5244            let (_defs, _requirements, comments) = self
5245                .css_parser
5246                .extract_definitions_requirements_and_comments(css, self.options.legal_comments)
5247                .map_err(|e| self.css_diagnostic(e))?;
5248            for comment in comments {
5249                let comment_text = &css[comment.start_byte..comment.end_byte];
5250                if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
5251                    continue;
5252                }
5253                ranges.push((
5254                    style_start + comment.start_byte,
5255                    style_start + comment.end_byte,
5256                ));
5257            }
5258        }
5259
5260        if ranges.is_empty() {
5261            return Ok(html);
5262        }
5263
5264        Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
5265    }
5266
5267    /// Collect HTML comment ranges and `<style>` raw-text ranges in one scan.
5268    fn collect_html_comment_and_style_ranges(
5269        html: &str,
5270        comment_ranges: &mut Vec<(usize, usize)>,
5271        style_ranges: &mut Vec<(usize, usize)>,
5272    ) -> Result<()> {
5273        let mut index = 0usize;
5274        while index < html.len() {
5275            let remaining = &html[index..];
5276            if remaining.starts_with("<!--") {
5277                let Some(end) = html::find_comment_close(remaining) else {
5278                    return Err(Diagnostic::error("unterminated HTML comment")
5279                        .code(codes::UNTERMINATED_HTML_COMMENT)
5280                        .at_offset(html, index)
5281                        .help("close the comment with `-->`")
5282                        .into());
5283                };
5284                comment_ranges.push((index, index + end));
5285                index += end;
5286                continue;
5287            }
5288
5289            if remaining.starts_with('<') {
5290                if let Some((style_start, style_end, close_end)) =
5291                    html::style_element_bounds(remaining)
5292                {
5293                    style_ranges.push((index + style_start, index + style_end));
5294                    index += close_end;
5295                    continue;
5296                }
5297
5298                if let Some(tag) = html::parse_tag(remaining) {
5299                    index += tag.close + 1;
5300                    continue;
5301                }
5302            }
5303
5304            let Some(ch) = remaining.chars().next() else {
5305                break;
5306            };
5307            index += ch.len_utf8();
5308        }
5309        Ok(())
5310    }
5311
5312    /// Strip compiler-owned component policy attributes and, for the SSR view,
5313    /// runtime attributes starting with `@`, `:`, or `?` from the opening
5314    /// `<template>` tag.
5315    ///
5316    /// Uses the same quote-aware tag scanner as the main HTML pipeline.
5317    fn strip_template_build_attrs(&mut self, html: &str, preserve_runtime_attrs: bool) -> String {
5318        let Some(tag) = html::parse_tag(html) else {
5319            return html.to_string();
5320        };
5321
5322        let mut removals: Vec<(usize, usize)> = Vec::new();
5323        for attr in tag.attrs() {
5324            let runtime_attr = attr.name.starts_with('@')
5325                || attr.name.starts_with(':')
5326                || attr.name.starts_with('?');
5327            let policy_attr = matches!(
5328                attr.name,
5329                COMPONENT_RENDER_ATTR | COMPONENT_HYDRATE_ATTR | COMPONENT_RESERVE_BLOCK_SIZE_ATTR
5330            );
5331            if policy_attr || (!preserve_runtime_attrs && runtime_attr) {
5332                let mut start = attr.raw_range.start;
5333                while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
5334                    start -= 1;
5335                }
5336                removals.push((start, attr.raw_range.end));
5337            }
5338        }
5339
5340        if removals.is_empty() {
5341            return html.to_string();
5342        }
5343
5344        // Rebuild the string, skipping removed ranges
5345        let mut result = String::with_capacity(html.len());
5346        let mut pos = 0;
5347        for (start, end) in &removals {
5348            result.push_str(&html[pos..*start]);
5349            pos = *end;
5350        }
5351        result.push_str(&html[pos..]);
5352        result
5353    }
5354}
5355
5356#[cfg(test)]
5357mod tests {
5358    use webui_test_utils::*;
5359
5360    use super::*;
5361
5362    #[derive(Default)]
5363    struct ContextArtifactPlugin {
5364        artifacts: Vec<crate::plugin::ComponentTemplateArtifact>,
5365    }
5366
5367    impl ParserPlugin for ContextArtifactPlugin {
5368        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5369            self.artifacts
5370                .push(crate::plugin::ComponentTemplateArtifact::template(
5371                    context.component.tag_name.clone(),
5372                    context.template.to_string(),
5373                    context.uses_shadow_dom,
5374                ));
5375            Ok(())
5376        }
5377
5378        fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
5379            Ok(ParserPluginArtifacts::ComponentTemplates(self.artifacts))
5380        }
5381    }
5382
5383    fn structural_matcher(value: &str) -> FragmentMatcher {
5384        signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
5385    }
5386
5387    mod fast_parser;
5388
5389    fn boundary_matcher(name: &str, declaration: u32) -> FragmentMatcher {
5390        webui_test_utils::boundary(name, declaration)
5391    }
5392
5393    fn boundary_end_matcher(declaration: u32) -> FragmentMatcher {
5394        webui_test_utils::boundary_end(declaration)
5395    }
5396
5397    #[test]
5398    fn unwrapped_component_uses_configured_dom_strategy() {
5399        assert!(
5400            analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Shadow)
5401                .expect("valid component")
5402                .uses_shadow_dom
5403        );
5404        assert!(
5405            !analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Light)
5406                .expect("valid component")
5407                .uses_shadow_dom
5408        );
5409    }
5410
5411    #[test]
5412    fn bare_template_explicitly_selects_light_dom_over_shadow_fallback() {
5413        let analysis = analyze_component_dom(
5414            "x-card",
5415            "<template><div>content</div></template>",
5416            DomStrategy::Shadow,
5417        )
5418        .expect("valid explicit Light wrapper");
5419        assert!(!analysis.uses_shadow_dom);
5420        assert!(analysis.authored_light_root.is_some());
5421
5422        let attributed = analyze_component_dom(
5423            "x-card",
5424            r#"<template data-purpose="content"><div>content</div></template>"#,
5425            DomStrategy::Shadow,
5426        )
5427        .expect("ordinary template remains valid");
5428        assert!(attributed.uses_shadow_dom);
5429        assert!(attributed.authored_light_root.is_none());
5430    }
5431
5432    #[test]
5433    fn plugin_artifacts_preserve_resolved_shadow_mode() {
5434        let component_html = r#"<template shadowrootmode="open"><p>shadow</p></template>"#;
5435        {
5436            let mut parser = HtmlParser::with_plugin_options(
5437                Box::new(ContextArtifactPlugin::default()),
5438                CssStrategy::Style,
5439            );
5440            parser
5441                .component_registry_mut()
5442                .register_component(ComponentRegistration::new(
5443                    "x-card",
5444                    component_html,
5445                    None,
5446                    false,
5447                ))
5448                .expect("register component");
5449            parser
5450                .parse("index.html", "<x-card></x-card>")
5451                .expect("parse component");
5452
5453            let ParserPluginArtifacts::ComponentTemplates(artifacts) =
5454                parser.take_plugin_artifacts().expect("consume artifacts")
5455            else {
5456                panic!("expected component artifacts");
5457            };
5458            assert!(artifacts[0].uses_shadow_dom);
5459        }
5460    }
5461
5462    #[test]
5463    fn open_shadow_wrapper_marks_component_shadow() {
5464        let html =
5465            "<!-- lead --><template shadowrootmode='open'><slot></slot></template><!-- tail -->";
5466        assert!(
5467            analyze_component_dom("x-card", html, DomStrategy::Light)
5468                .expect("valid Shadow wrapper")
5469                .uses_shadow_dom
5470        );
5471    }
5472
5473    #[test]
5474    fn closed_shadow_wrapper_is_rejected() {
5475        let error = analyze_component_dom(
5476            "x-card",
5477            "<template shadowrootmode=\"closed\"><p>content</p></template>",
5478            DomStrategy::Shadow,
5479        )
5480        .expect_err("closed mode must fail");
5481        assert!(matches!(
5482            error,
5483            ParserError::Template(ref diagnostic)
5484                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5485                    && diagnostic.component_name() == Some("x-card")
5486        ));
5487    }
5488
5489    #[test]
5490    fn shadow_wrapper_must_be_the_only_top_level_element() {
5491        let error = analyze_component_dom(
5492            "x-card",
5493            "<template shadowrootmode=\"open\"><p>content</p></template><span>extra</span>",
5494            DomStrategy::Light,
5495        )
5496        .expect_err("multiple roots must fail");
5497        assert!(matches!(
5498            error,
5499            ParserError::Template(ref diagnostic)
5500                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5501        ));
5502    }
5503
5504    #[test]
5505    fn shadowrootmode_rejects_invalid_value_and_placement() {
5506        for html in [
5507            "<template shadowrootmode=\"invalid\"><p>content</p></template>",
5508            "<template shadowrootmode=\" open \"><p>content</p></template>",
5509            "<template :shadowrootmode=\"{{mode}}\"><p>content</p></template>",
5510            "<template shadowrootmode=\"open\" />",
5511            "<div><template shadowrootmode=\"open\"><p>content</p></template></div>",
5512            "<div shadowrootmode=\"open\">content</div>",
5513        ] {
5514            let error = analyze_component_dom("x-card", html, DomStrategy::Shadow)
5515                .expect_err("invalid shadowrootmode must fail");
5516            assert!(matches!(
5517                error,
5518                ParserError::Template(ref diagnostic)
5519                    if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5520                        && diagnostic.component_name() == Some("x-card")
5521                        && diagnostic.help_text().is_some()
5522                        && diagnostic.snippet_text().is_some()
5523            ));
5524        }
5525    }
5526
5527    #[test]
5528    fn light_component_slot_is_rejected() {
5529        let source = "<div><slot name=\"label\"></slot></div>";
5530        let error = analyze_component_dom("x-card", source, DomStrategy::Light)
5531            .expect_err("Light slot must fail");
5532        assert!(matches!(
5533            error,
5534            ParserError::Template(ref diagnostic)
5535                if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5536                    && diagnostic.help_text().is_some_and(|help| help.contains("shadowrootmode"))
5537        ));
5538        assert!(
5539            analyze_component_dom("x-card", source, DomStrategy::Shadow)
5540                .expect("implicit Shadow components support slots")
5541                .uses_shadow_dom
5542        );
5543
5544        let explicit_light = "<template><slot name=\"label\"></slot></template>";
5545        let error = analyze_component_dom("x-card", explicit_light, DomStrategy::Shadow)
5546            .expect_err("bare template explicitly selects Light");
5547        assert!(matches!(
5548            error,
5549            ParserError::Template(ref diagnostic)
5550                if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5551        ));
5552    }
5553
5554    #[test]
5555    fn webui_plugin_records_authored_shadow_wrapper() {
5556        let mut parser =
5557            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5558        parser
5559            .component_registry_mut()
5560            .register_component(ComponentRegistration::new(
5561                "x-card",
5562                "<!-- lead --><template shadowrootmode=\"open\" @click=\"{onClick()}\"><slot></slot></template><!-- tail -->",
5563                None,
5564                true,
5565            ))
5566            .expect("register component");
5567        parser
5568            .parse("index.html", "<x-card>projected</x-card>")
5569            .expect("parse component");
5570
5571        let ParserPluginArtifacts::ComponentTemplates(templates) =
5572            parser.take_plugin_artifacts().expect("plugin artifacts")
5573        else {
5574            panic!("expected component templates");
5575        };
5576        assert!(templates[0].uses_shadow_dom);
5577        assert!(templates[0].template_json.contains("\"sd\":1"));
5578        assert!(templates[0]
5579            .template_json
5580            .contains(r#""re":[["click","onClick",[]]]"#));
5581    }
5582
5583    /// Records the build context so tests can assert what the parser reports
5584    /// without asserting how any particular plugin reacts to it.
5585    struct StyleContextPlugin {
5586        captured: std::rc::Rc<std::cell::RefCell<Vec<(bool, Option<String>, String)>>>,
5587    }
5588
5589    impl crate::plugin::ParserPlugin for StyleContextPlugin {
5590        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5591            self.captured.borrow_mut().push((
5592                context.uses_shadow_dom,
5593                context.style.map(|style| format!("{style:?}")),
5594                context.template.to_string(),
5595            ));
5596            Ok(())
5597        }
5598    }
5599
5600    fn capture_style_context(
5601        strategy: CssStrategy,
5602        dom_strategy: DomStrategy,
5603        template: &str,
5604        css: Option<&str>,
5605    ) -> (bool, Option<String>, String) {
5606        let captured = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
5607        let mut parser = HtmlParser::with_plugin_options(
5608            Box::new(StyleContextPlugin {
5609                captured: std::rc::Rc::clone(&captured),
5610            }),
5611            ParserOptions {
5612                css_strategy: strategy,
5613                dom_strategy,
5614                ..ParserOptions::default()
5615            },
5616        );
5617        parser
5618            .component_registry_mut()
5619            .register_component(ComponentRegistration::new("x-card", template, css, true))
5620            .expect("register component");
5621        parser
5622            .parse("index.html", "<x-card></x-card>")
5623            .expect("parse component");
5624        let captured = captured.borrow();
5625        captured.first().cloned().expect("component registered")
5626    }
5627
5628    /// The parser reports the resolved delivery as data and injects nothing:
5629    /// a plugin that ignores `context.style` gets a style-free template even
5630    /// for an authored Shadow component.
5631    #[test]
5632    fn parser_reports_shadow_style_delivery_without_injecting_it() {
5633        for (strategy, expected) in [
5634            (CssStrategy::Link, "Link { href: \"x-card.css\" }"),
5635            (CssStrategy::Style, "Inline { css: \".card{color:red}\" }"),
5636            (CssStrategy::Module, "Adopted { specifier: \"x-card\" }"),
5637        ] {
5638            let (uses_shadow_dom, style, template) = capture_style_context(
5639                strategy,
5640                DomStrategy::Shadow,
5641                "<template shadowrootmode=\"open\" shadowrootadoptedstylesheets=\"x-card\"><div class=\"card\"></div></template>",
5642                Some(".card{color:red}"),
5643            );
5644            assert!(uses_shadow_dom, "{strategy:?} should be shadow");
5645            assert_eq!(style.as_deref(), Some(expected), "{strategy:?} delivery");
5646            assert!(
5647                !template.contains("<style>") && !template.contains("<link"),
5648                "{strategy:?} template must stay injection-free: {template}"
5649            );
5650        }
5651    }
5652
5653    /// Light CSS belongs to the owning CSS tree, so there is nothing for a
5654    /// plugin to place inside a runtime-created root.
5655    #[test]
5656    fn parser_reports_no_style_delivery_for_light_components() {
5657        let (uses_shadow_dom, style, _) = capture_style_context(
5658            CssStrategy::Style,
5659            DomStrategy::Light,
5660            "<div class=\"card\"></div>",
5661            Some(".card{color:red}"),
5662        );
5663        assert!(!uses_shadow_dom);
5664        assert_eq!(style, None);
5665    }
5666
5667    /// A component without CSS reports no delivery at all.
5668    #[test]
5669    fn parser_reports_no_style_delivery_without_css() {
5670        let (uses_shadow_dom, style, _) = capture_style_context(
5671            CssStrategy::Style,
5672            DomStrategy::Shadow,
5673            "<template shadowrootmode=\"open\"><div></div></template>",
5674            None,
5675        );
5676        assert!(uses_shadow_dom);
5677        assert_eq!(style, None);
5678    }
5679
5680    #[test]
5681    fn test_plugin_display_names() {
5682        assert_eq!(Plugin::Fast.to_string(), "fast");
5683        assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
5684        assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
5685        assert_eq!(Plugin::WebUI.to_string(), "webui");
5686    }
5687
5688    #[test]
5689    fn test_plugin_from_str_names() {
5690        assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
5691        assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
5692        assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
5693        assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
5694    }
5695
5696    #[test]
5697    fn test_plugin_from_str_unknown_mentions_supported_names() {
5698        let err = "legacy".parse::<Plugin>().unwrap_err();
5699        assert!(
5700            err.contains("fast-v3"),
5701            "error should mention fast-v3: {err}"
5702        );
5703        assert!(
5704            err.contains("fast-v2"),
5705            "error should mention fast-v2: {err}"
5706        );
5707        assert!(err.contains("fast"), "error should mention fast: {err}");
5708    }
5709
5710    #[test]
5711    fn test_parse_signal() {
5712        let mut parser = HtmlParser::new();
5713        let html = "Hello, {{name}}!";
5714        let result = parser.parse("test.html", html);
5715
5716        assert!(result.is_ok());
5717        let fragment_records = parser.into_fragment_records();
5718
5719        assert_stream!(
5720            fragment_records,
5721            "test.html",
5722            [raw("Hello, "), signal("name"), raw("!"),]
5723        );
5724    }
5725
5726    #[test]
5727    fn test_parse_raw_signal() {
5728        let mut parser = HtmlParser::new();
5729        let html = "Hello, {{{html_content}}}!";
5730        let result = parser.parse("test.html", html);
5731
5732        assert!(result.is_ok());
5733        let fragment_records = parser.into_fragment_records();
5734
5735        assert_stream!(
5736            fragment_records,
5737            "test.html",
5738            [raw("Hello, "), signal_raw("html_content"), raw("!"),]
5739        );
5740    }
5741
5742    #[test]
5743    fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
5744        let mut parser = HtmlParser::new();
5745        let html = "<nav>{{sectionName}} &gt; {{topicName}}</nav>";
5746        let result = parser.parse("test.html", html);
5747
5748        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5749        let fragment_records = parser.into_fragment_records();
5750
5751        assert_stream!(
5752            fragment_records,
5753            "test.html",
5754            [
5755                raw("<nav>"),
5756                signal("sectionName"),
5757                raw(" &gt; "),
5758                signal("topicName"),
5759                raw("</nav>"),
5760            ]
5761        );
5762    }
5763
5764    #[test]
5765    fn test_parse_preserves_whitespace_inside_bound_text_runs() -> Result<()> {
5766        let mut parser = HtmlParser::new();
5767        parser.parse(
5768            "test.html",
5769            "\n  <button>\n    {{title}}\n  </button>\n  <pre>  {{details}}\n  </pre>\n",
5770        )?;
5771        let records = parser.into_fragment_records();
5772        assert_stream!(
5773            records,
5774            "test.html",
5775            [
5776                raw("<button>\n    "),
5777                signal("title"),
5778                raw("\n  </button><pre>  "),
5779                signal("details"),
5780                raw("\n  </pre>"),
5781            ]
5782        );
5783        Ok(())
5784    }
5785
5786    #[test]
5787    fn test_invalid_for_each_is_template_diagnostic() {
5788        let mut parser = HtmlParser::new();
5789        let err = parser
5790            .parse(
5791                "index.html",
5792                "<div>\n  <for each=\"person inpeople\"><p>x</p></for>\n</div>",
5793            )
5794            .expect_err("invalid for-each must error");
5795        assert!(matches!(err, ParserError::Template(_)));
5796        let msg = err.to_string();
5797        assert!(msg.contains("invalid <for> each expression"), "{msg}");
5798        assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
5799        // The <for> sits on line 2, column 3 (after the two-space indent),
5800        // reported rustc-style as `--> index.html:2:3`.
5801        assert!(
5802            msg.contains("--> index.html:2:3"),
5803            "missing line:column — {msg}"
5804        );
5805        assert!(msg.contains("help:"), "{msg}");
5806    }
5807
5808    #[test]
5809    fn test_invalid_if_condition_is_template_diagnostic() {
5810        let mut parser = HtmlParser::new();
5811        let err = parser
5812            .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
5813            .expect_err("invalid condition must error");
5814        assert!(matches!(err, ParserError::Template(_)));
5815        let msg = err.to_string();
5816        assert!(msg.contains("invalid <if> condition expression"), "{msg}");
5817        assert!(
5818            msg.contains("--> index.html:1:1"),
5819            "missing line:column — {msg}"
5820        );
5821    }
5822
5823    #[test]
5824    fn test_parse_for_directive() {
5825        let mut parser = HtmlParser::new();
5826        let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
5827
5828        let result = parser.parse("test.html", html);
5829        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5830        let fragment_records = parser.into_fragment_records();
5831
5832        assert_stream!(
5833            fragment_records,
5834            "test.html",
5835            [for_loop("item", "items", "for-1"),]
5836        );
5837
5838        // Verify the sub-fragment contains our item content
5839        assert_stream!(
5840            fragment_records,
5841            "for-1",
5842            [
5843                raw("<div class=\"item\">"),
5844                signal("item.name"),
5845                raw("</div>"),
5846            ]
5847        );
5848    }
5849
5850    #[test]
5851    fn test_parse_if_directive() {
5852        let mut parser = HtmlParser::new();
5853        let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
5854
5855        let result = parser.parse("test.html", html);
5856        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5857        let fragment_records = parser.into_fragment_records();
5858
5859        assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
5860
5861        // Verify the sub-fragment contains our content
5862        assert_stream!(
5863            fragment_records,
5864            "if-1",
5865            [
5866                raw("<div>Welcome back, "),
5867                signal("username"),
5868                raw("!</div>"),
5869            ]
5870        );
5871    }
5872
5873    #[test]
5874    fn test_component_directive() {
5875        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5876        parser
5877            .component_registry
5878            .register_component(ComponentRegistration::new(
5879                "my-component",
5880                "<div>My Component</div>",
5881                Some("div { color: blue; }"),
5882                true,
5883            ))
5884            .expect("Failed to register component");
5885
5886        let result = parser.parse("test.html", "<my-component></my-component>");
5887        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5888        let records = parser.into_fragment_records();
5889
5890        assert_stream!(
5891            records,
5892            "test.html",
5893            [
5894                raw("<my-component"),
5895                structural_matcher("streaming_root:my-component"),
5896                raw(">"),
5897                component("my-component"),
5898                raw("</my-component>"),
5899            ]
5900        );
5901
5902        // Component template stream should contain the component content with
5903        // no compiler-owned Light DOM markers.
5904        let comp = &records["my-component"].fragments;
5905        assert_eq!(comp.len(), 1);
5906        assert!(
5907            matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5908                !raw.value.contains("<template shadowrootmode")
5909                    && raw.value.contains("<div")
5910                    && raw.value.contains(">My Component</div>"))
5911        );
5912    }
5913
5914    #[test]
5915    fn shadow_component_emits_style_hook_inside_declarative_root() {
5916        let mut parser = HtmlParser::new();
5917        parser
5918            .component_registry
5919            .register_component(ComponentRegistration::new(
5920                "my-component",
5921                r#"<template shadowrootmode="open"><div>My Component</div></template>"#,
5922                Some("div { color: blue; }"),
5923                true,
5924            ))
5925            .expect("register");
5926
5927        parser
5928            .parse("test.html", "<my-component></my-component>")
5929            .expect("parse");
5930        let records = parser.into_fragment_records();
5931        assert_stream!(
5932            records,
5933            "my-component",
5934            [
5935                raw("<template shadowrootmode=\"open\">"),
5936                structural_matcher("shadow_styles:my-component"),
5937                raw("<div>My Component</div></template>"),
5938            ]
5939        );
5940        assert!(
5941            records["my-component"]
5942                .fragments
5943                .iter()
5944                .all(|fragment| !matches!(
5945                    fragment.fragment.as_ref(),
5946                    Some(Fragment::Raw(raw)) if raw.value.contains("color: blue")
5947                )),
5948            "component CSS is delivered by the handler closure"
5949        );
5950    }
5951
5952    #[test]
5953    fn authored_shadow_attribute_name_is_ascii_case_insensitive_for_style_hook() {
5954        let mut parser = HtmlParser::new();
5955        parser
5956            .component_registry
5957            .register_component(ComponentRegistration::new(
5958                "my-component",
5959                r#"<template shadowRootMode="open"><slot></slot></template>"#,
5960                None,
5961                true,
5962            ))
5963            .expect("register");
5964
5965        parser
5966            .parse("test.html", "<my-component></my-component>")
5967            .expect("parse");
5968        let records = parser.into_fragment_records();
5969        assert_stream!(
5970            records,
5971            "my-component",
5972            [
5973                raw(r#"<template shadowRootMode="open">"#),
5974                structural_matcher("shadow_styles:my-component"),
5975                raw("<slot></slot></template>"),
5976            ]
5977        );
5978    }
5979
5980    #[test]
5981    fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
5982        // The compiler-owned streamed SSR root signal must sit between the
5983        // component's attribute run and the closing `>`, carrying the tag,
5984        // so the streaming handler can inject ` data-ws` inside the tag before
5985        // custom-element upgrade. Ordinary rendering ignores the signal.
5986        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5987        parser
5988            .component_registry
5989            .register_component(ComponentRegistration::new(
5990                "my-widget",
5991                "<div>w</div>",
5992                None,
5993                true,
5994            ))
5995            .expect("register");
5996
5997        let result = parser.parse("test.html", "<my-widget></my-widget>");
5998        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5999        let records = parser.into_fragment_records();
6000
6001        assert_stream!(
6002            records,
6003            "test.html",
6004            [
6005                raw("<my-widget"),
6006                structural_matcher("streaming_root:my-widget"),
6007                raw(">"),
6008                component("my-widget"),
6009                raw("</my-widget>"),
6010            ]
6011        );
6012    }
6013
6014    #[test]
6015    fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
6016        // Each distinct component host emits its own streaming_root signal,
6017        // including nested hosts and hosts repeated across sibling positions.
6018        use webui_protocol::web_ui_fragment::Fragment;
6019        let mut parser = HtmlParser::new();
6020        for name in ["outer-box", "inner-pill"] {
6021            parser
6022                .component_registry
6023                .register_component(ComponentRegistration::new(
6024                    name,
6025                    "<div>content</div>",
6026                    None,
6027                    true,
6028                ))
6029                .expect("register");
6030        }
6031
6032        let result = parser.parse(
6033            "test.html",
6034            "<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
6035        );
6036        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6037        let records = parser.into_fragment_records();
6038        let fragments = &records["test.html"].fragments;
6039
6040        let signals: Vec<&str> = fragments
6041            .iter()
6042            .filter_map(|f| match f.fragment.as_ref() {
6043                Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
6044                _ => None,
6045            })
6046            .collect();
6047        assert_eq!(
6048            signals,
6049            [
6050                "}}}webui:streaming_root:outer-box",
6051                "}}}webui:streaming_root:inner-pill",
6052                "}}}webui:streaming_root:inner-pill",
6053            ]
6054        );
6055
6056        // Every streaming_root signal is immediately followed by a `>` raw and
6057        // preceded by a raw ending in the host tag name (never a bare `<tag>`).
6058        for (i, frag) in fragments.iter().enumerate() {
6059            let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
6060                continue;
6061            };
6062            if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
6063                continue;
6064            }
6065            match fragments[i + 1].fragment.as_ref() {
6066                Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
6067                other => panic!("expected raw(\">\") after signal, got {other:?}"),
6068            }
6069        }
6070    }
6071
6072    #[test]
6073    fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
6074        let mut parser = HtmlParser::new();
6075        parser
6076            .component_registry
6077            .register_component(ComponentRegistration::new(
6078                "mp-button",
6079                "<button>b</button>",
6080                None,
6081                true,
6082            ))
6083            .expect("Failed to register component");
6084
6085        // `<mp-buton>` is a same-namespace one-character typo of `mp-button`.
6086        let err = parser
6087            .parse("test.html", "<mp-buton></mp-buton>")
6088            .expect_err("a near-typo component tag should error");
6089        let ParserError::Template(diag) = err else {
6090            panic!("expected ParserError::Template, got {err:?}");
6091        };
6092        assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
6093        let help = diag
6094            .help_text()
6095            .expect("diagnostic should carry a help line");
6096        assert!(
6097            help.contains("did you mean <mp-button>?"),
6098            "expected a suggestion, got: {help}"
6099        );
6100    }
6101
6102    #[test]
6103    fn external_custom_element_in_other_namespace_passes_through() {
6104        let mut parser = HtmlParser::new();
6105        parser
6106            .component_registry
6107            .register_component(ComponentRegistration::new(
6108                "mp-button",
6109                "<button>b</button>",
6110                None,
6111                true,
6112            ))
6113            .expect("Failed to register component");
6114
6115        // `<md-button>` is a different namespace (md- vs mp-): a genuine
6116        // third-party custom element must pass through as raw HTML, not error.
6117        let result = parser.parse("test.html", "<md-button></md-button>");
6118        assert!(
6119            result.is_ok(),
6120            "external custom element should pass through, got: {:?}",
6121            result.err()
6122        );
6123    }
6124
6125    #[test]
6126    fn for_missing_each_suggests_typoed_attribute() {
6127        let mut parser = HtmlParser::new();
6128        // `eahc` is a transposition of the required `each` attribute.
6129        let err = parser
6130            .parse("test.html", "<for eahc=\"todo in todos\"></for>")
6131            .expect_err("a <for> without `each` should error");
6132        let ParserError::Template(diag) = err else {
6133            panic!("expected ParserError::Template, got {err:?}");
6134        };
6135        assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
6136        let help = diag
6137            .help_text()
6138            .expect("diagnostic should carry a help line");
6139        assert!(
6140            help.contains("found `eahc`") && help.contains("did you mean"),
6141            "expected an attribute-typo suggestion, got: {help}"
6142        );
6143    }
6144
6145    #[test]
6146    fn parent_diagnostic_owner_survives_nested_component_parse() {
6147        // Regression: a component parse recurses through `self.parse(child, …)`,
6148        // which used to leave `current_fragment_id` pointing at the child. A
6149        // later error in the PARENT template must still be attributed to the
6150        // parent (here `index.html`), not the nested component.
6151        let mut parser = HtmlParser::new();
6152        parser
6153            .component_registry
6154            .register_component(ComponentRegistration::new(
6155                "my-card",
6156                "<div>card</div>",
6157                None,
6158                true,
6159            ))
6160            .expect("register");
6161
6162        // A registered component first, then a broken <for> in the same template.
6163        let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
6164        let err = parser
6165            .parse("index.html", html)
6166            .expect_err("the malformed <for> should error");
6167        let ParserError::Template(diag) = err else {
6168            panic!("expected ParserError::Template, got {err:?}");
6169        };
6170        assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
6171        assert_eq!(
6172            diag.component_name(),
6173            Some("index.html"),
6174            "parent diagnostic must be owned by index.html, not the nested component"
6175        );
6176    }
6177
6178    #[test]
6179    fn test_component_directive_with_slots() {
6180        let mut parser = HtmlParser::new();
6181        parser
6182            .component_registry
6183            .register_component(ComponentRegistration::new(
6184                "my-component",
6185                "<div>My Component</div>",
6186                Some("div { color: blue; }"),
6187                true,
6188            ))
6189            .expect("Failed to register component");
6190
6191        let result = parser.parse(
6192            "test.html",
6193            "Hello<my-component><p>World</p></my-component>",
6194        );
6195        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6196        let records = parser.into_fragment_records();
6197        let fragments = &records["test.html"].fragments;
6198
6199        // Entry: raw(Hello<my-component>) + component + raw(<p>World</p></my-component>)
6200        assert!(fragments.len() >= 3);
6201        // First fragment should contain "Hello" and "<my-component" (the opening
6202        // tag is split by the compiler-owned streaming_root signal before `>`).
6203        assert!(
6204            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
6205        );
6206        // Should have component fragment
6207        assert!(fragments.iter().any(|f| matches!(
6208            f.fragment.as_ref(),
6209            Some(Fragment::Component(c)) if c.fragment_id == "my-component"
6210        )));
6211        // Should end with closing tag
6212        let last = fragments.last().unwrap();
6213        assert!(
6214            matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
6215        );
6216    }
6217
6218    #[test]
6219    fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
6220        // Reproduces commerce app: mp-app has a shadow DOM template containing
6221        // child components (mp-navbar, mp-cart-panel, mp-footer) plus an <outlet>.
6222        // The parser must emit Fragment::Component entries for ALL child components
6223        // so the inventory walk finds them.
6224        let mut parser = HtmlParser::new();
6225
6226        parser
6227            .component_registry
6228            .register_component(ComponentRegistration::new(
6229                "app-shell",
6230                r#"<template shadowrootmode="open">
6231                  <my-navbar></my-navbar>
6232                  <main><outlet /></main>
6233                  <cart-panel></cart-panel>
6234                  <my-footer></my-footer>
6235                </template>"#,
6236                Some(":host{display:flex}"),
6237                true,
6238            ))
6239            .expect("register app-shell");
6240        parser
6241            .component_registry
6242            .register_component(ComponentRegistration::new(
6243                "my-navbar",
6244                "<nav>Nav</nav>",
6245                Some("nav{color:red}"),
6246                true,
6247            ))
6248            .expect("register my-navbar");
6249        parser
6250            .component_registry
6251            .register_component(ComponentRegistration::new(
6252                "cart-panel",
6253                "<aside>Cart</aside>",
6254                Some("aside{color:green}"),
6255                true,
6256            ))
6257            .expect("register cart-panel");
6258        parser
6259            .component_registry
6260            .register_component(ComponentRegistration::new(
6261                "my-footer",
6262                "<footer>Footer</footer>",
6263                Some("footer{color:blue}"),
6264                true,
6265            ))
6266            .expect("register my-footer");
6267
6268        parser
6269            .parse("index.html", "<app-shell></app-shell>")
6270            .expect("parse index.html");
6271        let records = parser.into_fragment_records();
6272
6273        // app-shell's fragment list must contain Component entries for all children
6274        let app_frags = &records["app-shell"].fragments;
6275        let component_ids: Vec<&str> = app_frags
6276            .iter()
6277            .filter_map(|f| match f.fragment.as_ref() {
6278                Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
6279                _ => None,
6280            })
6281            .collect();
6282
6283        assert!(
6284            component_ids.contains(&"my-navbar"),
6285            "app-shell fragments should contain my-navbar: {component_ids:?}"
6286        );
6287        assert!(
6288            component_ids.contains(&"cart-panel"),
6289            "app-shell fragments should contain cart-panel: {component_ids:?}"
6290        );
6291        assert!(
6292            component_ids.contains(&"my-footer"),
6293            "app-shell fragments should contain my-footer: {component_ids:?}"
6294        );
6295    }
6296
6297    // ── Component template wrapping tests ────────────────────────────
6298
6299    #[test]
6300    fn test_component_no_double_wrap_template() {
6301        // Developer-authored <template foo="bar"> must be preserved verbatim
6302        // in Light DOM. The framework only strips runtime-only attrs;
6303        // every other attribute is the developer's responsibility.
6304        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6305        parser
6306            .component_registry
6307            .register_component(ComponentRegistration::new(
6308                "custom-element",
6309                r#"<template foo="bar"><div>content</div></template>"#,
6310                None,
6311                true,
6312            ))
6313            .expect("register");
6314        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6315        assert!(result.is_ok());
6316        let records = parser.into_fragment_records();
6317
6318        assert_fragments!(
6319            records["index.html"].fragments,
6320            [
6321                raw("<custom-element"),
6322                structural_matcher("streaming_root:custom-element"),
6323                raw(">"),
6324                component("custom-element"),
6325                raw("Hello</custom-element>"),
6326            ]
6327        );
6328
6329        assert_stream!(
6330            records,
6331            "custom-element",
6332            [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6333        );
6334    }
6335
6336    #[test]
6337    fn test_component_styled_no_double_wrap() {
6338        // Light DOM with a developer-supplied ordinary <template> wrapper
6339        // preserves the wrapper verbatim. CSS is delivered by the owning
6340        // closure rather than spliced into the template.
6341        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6342        parser
6343            .component_registry
6344            .register_component(ComponentRegistration::new(
6345                "custom-element",
6346                r#"<template foo="bar"><div>content</div></template>"#,
6347                Some("div { color: red; }"),
6348                true,
6349            ))
6350            .expect("register");
6351        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6352        assert!(result.is_ok());
6353        let records = parser.into_fragment_records();
6354
6355        assert_stream!(
6356            records,
6357            "custom-element",
6358            [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6359        );
6360    }
6361
6362    #[test]
6363    fn test_component_strip_runtime_attrs() {
6364        // Runtime-only attributes (`@event`, `:bind`, `?cond`) are stripped
6365        // from the opening <template> tag, but the wrapper itself is
6366        // preserved. The declarative Shadow DOM attribute remains.
6367        let mut parser = HtmlParser::new();
6368        parser
6369            .component_registry
6370            .register_component(ComponentRegistration::new(
6371                "custom-element",
6372                r#"<template shadowrootmode="open" @click={foo} :bar="baz" ?bool="true"><div>content</div></template>"#,
6373                None,
6374                true,
6375            ))
6376            .expect("register");
6377        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6378        assert!(result.is_ok());
6379        let records = parser.into_fragment_records();
6380
6381        assert_stream!(
6382            records,
6383            "custom-element",
6384            [
6385                raw(r#"<template shadowrootmode="open">"#),
6386                structural_matcher("shadow_styles:custom-element"),
6387                raw("<div>content</div></template>"),
6388            ]
6389        );
6390    }
6391
6392    #[test]
6393    fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
6394        let mut parser = HtmlParser::new();
6395        parser
6396            .component_registry
6397            .register_component(ComponentRegistration::new(
6398                "custom-element",
6399                r#"<template shadowrootmode="open" data-note="@click={foo}" @click={foo}><div>content</div></template>"#,
6400                None,
6401                true,
6402            ))
6403            .expect("register");
6404        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6405        assert!(result.is_ok());
6406        let records = parser.into_fragment_records();
6407
6408        assert_stream!(
6409            records,
6410            "custom-element",
6411            [
6412                raw(r#"<template shadowrootmode="open" data-note="@click={foo}">"#),
6413                structural_matcher("shadow_styles:custom-element"),
6414                raw("<div>content</div></template>"),
6415            ]
6416        );
6417    }
6418
6419    #[test]
6420    fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
6421        let mut parser = HtmlParser::new();
6422        parser
6423            .component_registry
6424            .register_component(ComponentRegistration::new(
6425                "custom-element",
6426                r#"<template shadowrootmode="open" @click={foo} @click={bar}><div>content</div></template>"#,
6427                None,
6428                true,
6429            ))
6430            .expect("register");
6431        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6432        assert!(result.is_ok());
6433        let records = parser.into_fragment_records();
6434
6435        assert_stream!(
6436            records,
6437            "custom-element",
6438            [
6439                raw(r#"<template shadowrootmode="open">"#),
6440                structural_matcher("shadow_styles:custom-element"),
6441                raw("<div>content</div></template>"),
6442            ]
6443        );
6444    }
6445
6446    #[test]
6447    fn test_component_with_slots_and_attrs() {
6448        let mut parser = HtmlParser::new();
6449        parser
6450            .component_registry
6451            .register_component(ComponentRegistration::new(
6452                "custom-element",
6453                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6454                None,
6455                true,
6456            ))
6457            .expect("register");
6458        let result = parser.parse(
6459            "index.html",
6460            r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
6461        );
6462        assert!(result.is_ok());
6463        let records = parser.into_fragment_records();
6464        assert_fragments!(
6465            records["index.html"].fragments,
6466            [
6467                raw("<custom-element"),
6468                attr_raw_start("appearance", "subtle"),
6469                structural_matcher("streaming_root:custom-element"),
6470                raw(">"),
6471                component("custom-element"),
6472                raw("Hello World</custom-element>"),
6473            ]
6474        );
6475    }
6476
6477    #[test]
6478    fn test_component_legacy_no_styles() {
6479        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6480        parser
6481            .component_registry
6482            .register_component(ComponentRegistration::new(
6483                "custom-element",
6484                "<div>Custom Element</div>",
6485                None,
6486                true,
6487            ))
6488            .expect("register");
6489        let result = parser.parse("index.html", "<custom-element></custom-element>");
6490        assert!(result.is_ok());
6491        let records = parser.into_fragment_records();
6492
6493        assert_fragments!(
6494            records["index.html"].fragments,
6495            [
6496                raw("<custom-element"),
6497                structural_matcher("streaming_root:custom-element"),
6498                raw(">"),
6499                component("custom-element"),
6500                raw("</custom-element>"),
6501            ]
6502        );
6503
6504        assert_stream!(
6505            records,
6506            "custom-element",
6507            [raw("<div>Custom Element</div>"),]
6508        );
6509    }
6510
6511    #[test]
6512    fn test_component_self_closing() {
6513        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6514        parser
6515            .component_registry
6516            .register_component(ComponentRegistration::new(
6517                "custom-widget",
6518                "<div>Widget Content</div>",
6519                None,
6520                true,
6521            ))
6522            .expect("register");
6523        let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
6524        assert!(result.is_ok());
6525        let records = parser.into_fragment_records();
6526
6527        assert_fragments!(
6528            records["index.html"].fragments,
6529            [
6530                raw("<custom-widget"),
6531                attr_start("config", "settings"),
6532                structural_matcher("streaming_root:custom-widget"),
6533                raw("/>"),
6534                component("custom-widget"),
6535            ]
6536        );
6537    }
6538
6539    #[test]
6540    fn test_component_nested_self_closing_in_slot() {
6541        let mut parser = HtmlParser::new();
6542        parser
6543            .component_registry
6544            .register_component(ComponentRegistration::new(
6545                "custom-icon",
6546                "<template shadowrootmode=\"open\"><svg><slot></slot></svg></template>",
6547                None,
6548                true,
6549            ))
6550            .expect("register");
6551        let result = parser.parse(
6552            "index.html",
6553            r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
6554        );
6555        assert!(result.is_ok());
6556        let records = parser.into_fragment_records();
6557
6558        assert_fragments!(
6559            records["index.html"].fragments,
6560            [
6561                raw("<custom-icon"),
6562                structural_matcher("streaming_root:custom-icon"),
6563                raw(">"),
6564                component("custom-icon"),
6565                raw("<use"),
6566                attr_template("href", "attr-1"),
6567                raw("/></custom-icon>"),
6568            ]
6569        );
6570
6571        assert_stream!(
6572            records,
6573            "custom-icon",
6574            [
6575                raw("<template shadowrootmode=\"open\">"),
6576                structural_matcher("shadow_styles:custom-icon"),
6577                raw("<svg><slot></slot></svg></template>"),
6578            ]
6579        );
6580    }
6581
6582    #[test]
6583    fn test_component_leading_boolean_attr_start() {
6584        let mut parser = HtmlParser::new();
6585        parser
6586            .component_registry
6587            .register_component(ComponentRegistration::new(
6588                "custom-element",
6589                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6590                None,
6591                true,
6592            ))
6593            .expect("register");
6594        let result = parser.parse(
6595            "index.html",
6596            r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
6597        );
6598        assert!(result.is_ok());
6599        let records = parser.into_fragment_records();
6600
6601        assert_fragments!(
6602            records["index.html"].fragments,
6603            [
6604                raw("<custom-element"),
6605                // First dynamic attr: boolean with attrStart
6606                bool_attr_start("disabled", "isDisabled"),
6607                // Static attr after dynamic: rawValue
6608                attr_raw("title", "Hello"),
6609                structural_matcher("streaming_root:custom-element"),
6610                raw(">"),
6611                component("custom-element"),
6612                raw("</custom-element>"),
6613            ]
6614        );
6615    }
6616
6617    #[test]
6618    fn test_component_boolean_predicate_preserves_condition_tree() {
6619        let mut parser = HtmlParser::new();
6620        parser
6621            .component_registry
6622            .register_component(ComponentRegistration::new(
6623                "custom-element",
6624                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6625                None,
6626                true,
6627            ))
6628            .expect("register");
6629        let result = parser.parse(
6630            "index.html",
6631            r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
6632        );
6633        assert!(result.is_ok());
6634
6635        let records = parser.into_fragment_records();
6636        let fragments = &records["index.html"].fragments;
6637        match fragments
6638            .get(1)
6639            .and_then(|fragment| fragment.fragment.as_ref())
6640        {
6641            Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
6642                assert_eq!(attr.name, "disabled");
6643                assert!(attr.attr_start);
6644                match attr
6645                    .condition_tree
6646                    .as_ref()
6647                    .and_then(|condition| condition.expr.as_ref())
6648                {
6649                    Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
6650                        assert_eq!(pred.left, "page");
6651                        assert_eq!(pred.operator, 3);
6652                        assert_eq!(pred.right, "'dashboard'");
6653                    }
6654                    other => panic!("expected predicate condition tree, got {:?}", other),
6655                }
6656            }
6657            other => panic!("expected attribute fragment, got {:?}", other),
6658        }
6659    }
6660
6661    #[test]
6662    fn test_component_meta_link_tags() {
6663        let (fragments, _) = parse_and_get_fragments(
6664            r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
6665        );
6666        assert!(fragments.len() >= 7);
6667        assert!(
6668            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
6669        );
6670        assert!(
6671            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
6672                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
6673        );
6674        assert!(
6675            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
6676        );
6677        assert!(
6678            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
6679        );
6680        assert!(
6681            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
6682        );
6683        assert!(
6684            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
6685                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
6686        );
6687        assert!(
6688            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
6689        );
6690    }
6691
6692    #[test]
6693    fn test_nested_directives() {
6694        let mut parser = HtmlParser::new();
6695        let html = r#"<for each="category in categories">
6696            <if condition="category.hasItems">
6697                <for each="item in category.items">
6698                   {{item.title}}
6699                </for>
6700            </if>
6701        </for>"#;
6702
6703        let result = parser.parse("test.html", html);
6704
6705        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6706        let fragment_records = parser.into_fragment_records();
6707
6708        assert_stream!(
6709            fragment_records,
6710            "test.html",
6711            [for_loop("category", "categories", "for-1"),]
6712        );
6713
6714        assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
6715
6716        assert_stream!(
6717            fragment_records,
6718            "if-1",
6719            [for_loop("item", "category.items", "for-2"),]
6720        );
6721
6722        assert_stream!(
6723            fragment_records,
6724            "for-2",
6725            [
6726                raw("\n                   "),
6727                signal("item.title"),
6728                raw("\n                "),
6729            ]
6730        );
6731    }
6732
6733    #[test]
6734    fn test_complex_directives() {
6735        let mut parser = HtmlParser::new();
6736        let html = r#"<for each="category in categories">
6737            <div class="category">
6738                <h2>{{category.name}}</h2>
6739                <if condition="category.hasItems">
6740                    <ul>
6741                        <for each="item in category.items">
6742                            <li>{{item.title}}</li>
6743                        </for>
6744                    </ul>
6745                </if>
6746            </div>
6747        </for>"#;
6748
6749        let result = parser.parse("test.html", html);
6750
6751        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6752        let fragment_records = parser.into_fragment_records();
6753
6754        assert_stream!(
6755            fragment_records,
6756            "test.html",
6757            [for_loop("category", "categories", "for-1"),]
6758        );
6759
6760        // Verify for fragments contains the category.name signal
6761        assert_stream!(
6762            fragment_records,
6763            "for-1",
6764            [
6765                raw("<div class=\"category\"><h2>"),
6766                signal("category.name"),
6767                raw("</h2>"),
6768                if_cond("if-1"),
6769                raw("</div>"),
6770            ]
6771        );
6772
6773        // Verify nested if condition.
6774        assert_stream!(
6775            fragment_records,
6776            "if-1",
6777            [
6778                raw("<ul>"),
6779                for_loop("item", "category.items", "for-2"),
6780                raw("</ul>"),
6781            ]
6782        );
6783
6784        // Verify nested for each.
6785        assert_stream!(
6786            fragment_records,
6787            "for-2",
6788            [raw("<li>"), signal("item.title"), raw("</li>"),]
6789        );
6790    }
6791
6792    // ── Attribute fragment tests ─────────────────────────────────────────
6793
6794    /// Helper to parse HTML and return the fragments for the entry stream.
6795    fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6796        let mut parser = HtmlParser::new();
6797        let result = parser.parse("index.html", html);
6798        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6799        let records = parser.into_fragment_records();
6800        let fragments = records
6801            .get("index.html")
6802            .expect("Failed to get index.html fragment")
6803            .fragments
6804            .clone();
6805        (fragments, records)
6806    }
6807
6808    /// Helper to parse HTML with a pre-registered component.
6809    fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6810        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6811        parser
6812            .component_registry
6813            .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
6814            .expect("register");
6815        let result = parser.parse("index.html", html);
6816        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6817        let records = parser.into_fragment_records();
6818        let fragments = records
6819            .get("index.html")
6820            .expect("Failed to get index.html fragment")
6821            .fragments
6822            .clone();
6823        (fragments, records)
6824    }
6825
6826    #[test]
6827    fn test_attribute_handlebars_in_href() {
6828        // Port of: 'should process handlebars from attributes as signals'
6829        let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
6830        assert_fragments!(
6831            fragments,
6832            [
6833                raw("<a"),
6834                attr("href", "url"),
6835                raw(">"),
6836                signal("name"),
6837                raw("</a>"),
6838            ]
6839        );
6840    }
6841
6842    #[test]
6843    fn test_attribute_boolean_with_handlebars() {
6844        // Port of: 'should process boolean attribute with handlebars expression'
6845        let (fragments, _) =
6846            parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
6847        assert_fragments!(
6848            fragments,
6849            [
6850                raw("<button"),
6851                bool_attr("disabled", "isDisabled"),
6852                raw(">Click</button>"),
6853            ]
6854        );
6855    }
6856
6857    #[test]
6858    fn test_attribute_multiple_boolean() {
6859        // Port of: 'should process multiple boolean attributes'
6860        // <input ?checked={{isChecked}} ?disabled={{isDisabled}} />
6861        let (fragments, _) =
6862            parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
6863
6864        assert_fragments!(
6865            fragments,
6866            [
6867                raw("<input"),
6868                bool_attr("checked", "isChecked"),
6869                bool_attr("disabled", "isDisabled"),
6870                raw("/>"),
6871            ]
6872        );
6873    }
6874
6875    #[test]
6876    fn test_attribute_boolean_and_regular_together() {
6877        // Port of: 'should process a boolean attribute and a regular attribute together'
6878        // <button ?disabled="{{isDisabled}}" type="button">Hi</button>
6879        let (fragments, _) = parse_and_get_fragments(
6880            r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6881        );
6882
6883        assert_fragments!(
6884            fragments,
6885            [
6886                raw("<button"),
6887                bool_attr("disabled", "isDisabled"),
6888                raw(" type=\"button\">Hi</button>"),
6889            ]
6890        );
6891    }
6892
6893    #[test]
6894    fn test_attribute_boolean_sandwiched() {
6895        // Port of: 'should process a boolean attribute sandwiched between regular attributes'
6896        // <button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>
6897        let (fragments, _) = parse_and_get_fragments(
6898            r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6899        );
6900
6901        assert_fragments!(
6902            fragments,
6903            [
6904                raw("<button"),
6905                attr("version", "edition"),
6906                bool_attr("disabled", "isDisabled"),
6907                raw(" type=\"button\">Hi</button>"),
6908            ]
6909        );
6910    }
6911
6912    #[test]
6913    fn test_attribute_boolean_ending() {
6914        // Port of: 'should process html ending with boolean attribute correctly'
6915        // <button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>
6916        let (fragments, _) = parse_and_get_fragments(
6917            r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
6918        );
6919
6920        assert_fragments!(
6921            fragments,
6922            [
6923                raw("<button"),
6924                attr("version", "edition"),
6925                bool_attr("disabled", "isDisabled"),
6926                raw(">Hi</button>"),
6927            ]
6928        );
6929    }
6930
6931    #[test]
6932    fn test_attribute_boolean_dotted_path() {
6933        // Port of: 'should process boolean attribute with dotted path'
6934        // <div ?checked={{layout.isPinned}}>Content</div>
6935        let (fragments, _) =
6936            parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
6937
6938        assert_fragments!(
6939            fragments,
6940            [
6941                raw("<div"),
6942                bool_attr("checked", "layout.isPinned"),
6943                raw(">Content</div>"),
6944            ]
6945        );
6946    }
6947
6948    #[test]
6949    fn test_attribute_colon_prefixed_complex() {
6950        // Port of: 'should process colon-prefixed attribute with handlebars'
6951        // <my-component :config="{{settings}}"></my-component>
6952        let (fragments, _) = parse_with_component(
6953            "my-component",
6954            r#"<my-component :config="{{settings}}"></my-component>"#,
6955        );
6956
6957        assert_fragments!(
6958            fragments,
6959            [
6960                raw("<my-component"),
6961                attr_complex_start(":config", "settings"),
6962                structural_matcher("streaming_root:my-component"),
6963                raw(">"),
6964                component("my-component"),
6965                raw("</my-component>"),
6966            ]
6967        );
6968    }
6969
6970    #[test]
6971    fn test_attribute_multiple_colon_prefixed() {
6972        // Port of: 'should process multiple colon-prefixed complex attributes'
6973        // <my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>
6974        let (fragments, _) = parse_with_component(
6975            "my-component",
6976            r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
6977        );
6978
6979        assert_fragments!(
6980            fragments,
6981            [
6982                raw("<my-component"),
6983                attr_complex_start(":prop1", "val1"),
6984                attr_complex(":prop2", "val2"),
6985                structural_matcher("streaming_root:my-component"),
6986                raw(">"),
6987                component("my-component"),
6988                raw("</my-component>"),
6989            ]
6990        );
6991    }
6992
6993    #[test]
6994    fn test_blocked_complex_property_innerhtml() {
6995        let mut parser = HtmlParser::new();
6996        let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
6997        assert!(
6998            result.is_err(),
6999            "Expected error for :innerHTML on native element"
7000        );
7001        let err = result.unwrap_err().to_string();
7002        assert!(
7003            err.contains("only allowed on custom elements"),
7004            "Error: {err}"
7005        );
7006    }
7007
7008    #[test]
7009    fn test_blocked_complex_on_native_element() {
7010        let mut parser = HtmlParser::new();
7011        let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
7012        assert!(
7013            result.is_err(),
7014            "Expected error for :data on native element"
7015        );
7016        let err = result.unwrap_err().to_string();
7017        assert!(
7018            err.contains("only allowed on custom elements"),
7019            "Error: {err}"
7020        );
7021    }
7022
7023    #[test]
7024    fn test_blocked_complex_property_on_component() {
7025        let mut parser = HtmlParser::new();
7026        parser
7027            .component_registry
7028            .register_component(ComponentRegistration::new(
7029                "my-widget",
7030                "<div></div>",
7031                None,
7032                true,
7033            ))
7034            .expect("register");
7035        let result = parser.parse(
7036            "index.html",
7037            r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
7038        );
7039        assert!(
7040            result.is_err(),
7041            "Expected error for :innerHTML on component"
7042        );
7043        let err = result.unwrap_err().to_string();
7044        assert!(err.contains("HTML injection"), "Error: {err}");
7045    }
7046
7047    #[test]
7048    fn test_allowed_complex_property() {
7049        // :config on a component should still work
7050        let (fragments, _) = parse_with_component(
7051            "my-component",
7052            r#"<my-component :config="{{settings}}"></my-component>"#,
7053        );
7054        assert_fragments!(
7055            fragments,
7056            [
7057                raw("<my-component"),
7058                attr_complex_start(":config", "settings"),
7059                structural_matcher("streaming_root:my-component"),
7060                raw(">"),
7061                component("my-component"),
7062                raw("</my-component>"),
7063            ]
7064        );
7065    }
7066
7067    #[test]
7068    fn test_attribute_mixed_normal_boolean_colon() {
7069        // Port of: 'should process mixed normal, boolean, and colon-prefixed attributes'
7070        // <my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>
7071        let (fragments, _) = parse_with_component(
7072            "my-component",
7073            r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
7074        );
7075
7076        assert_fragments!(
7077            fragments,
7078            [
7079                raw("<my-component"),
7080                attr_raw_start("id", "comp"),
7081                attr_complex(":config", "settings"),
7082                bool_attr("enabled", "isEnabled"),
7083                structural_matcher("streaming_root:my-component"),
7084                raw(">"),
7085                component("my-component"),
7086                raw("</my-component>"),
7087            ]
7088        );
7089    }
7090
7091    #[test]
7092    fn test_attribute_reject_boolean_without_handlebars() {
7093        // Port of: 'should reject boolean attribute without handlebars'
7094        // <button ?checked="name"></button>
7095        let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
7096
7097        // Boolean attribute is silently dropped
7098        assert_fragments!(fragments, [raw("<button></button>"),]);
7099    }
7100
7101    #[test]
7102    fn test_attribute_reject_boolean_with_partial_handlebars() {
7103        // Port of: 'should reject boolean attribute with partial handlebars'
7104        // <button ?checked="Hello {{name}}"></button>
7105        let (fragments, _) =
7106            parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
7107
7108        // Boolean attribute is silently dropped
7109        assert_fragments!(fragments, [raw("<button></button>"),]);
7110    }
7111
7112    #[test]
7113    fn test_attribute_reject_boolean_with_plain_value() {
7114        // Port of: 'should reject boolean attribute with plain value'
7115        // <button ?disabled="true">Click</button>
7116        let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
7117
7118        // Boolean attribute is silently dropped
7119        assert_fragments!(fragments, [raw("<button>Click</button>"),]);
7120    }
7121
7122    #[test]
7123    fn test_attribute_boolean_predicate_equal() {
7124        // Boolean attribute with == predicate expression
7125        let (fragments, _) =
7126            parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
7127        assert_fragments!(
7128            fragments,
7129            [
7130                raw("<div"),
7131                bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
7132                raw(">X</div>"),
7133            ]
7134        );
7135    }
7136
7137    #[test]
7138    fn test_attribute_boolean_predicate_greater_than() {
7139        // Boolean attribute with > predicate expression
7140        let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
7141        assert_fragments!(
7142            fragments,
7143            [
7144                raw("<span"),
7145                bool_attr_predicate("hidden", "num", 1, "9"),
7146                raw(">X</span>"),
7147            ]
7148        );
7149    }
7150
7151    #[test]
7152    fn test_attribute_boolean_predicate_not_equal() {
7153        // Boolean attribute with != predicate expression
7154        let (fragments, _) =
7155            parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
7156        assert_fragments!(
7157            fragments,
7158            [
7159                raw("<a"),
7160                bool_attr_predicate("data-active", "status", 4, "'inactive'"),
7161                raw(">X</a>"),
7162            ]
7163        );
7164    }
7165
7166    #[test]
7167    fn test_attribute_boolean_negation() {
7168        // Boolean attribute with negated expression
7169        let (fragments, _) =
7170            parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
7171        assert_fragments!(
7172            fragments,
7173            [
7174                raw("<button"),
7175                bool_attr_not("disabled", "isReady"),
7176                raw(">X</button>"),
7177            ]
7178        );
7179    }
7180
7181    #[test]
7182    fn test_attribute_mixed_static_dynamic() {
7183        // Port of: 'should process mixed attributes correctly'
7184        // <textarea value="hello {{world}}">Hi</textarea>
7185        let (fragments, records) =
7186            parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
7187
7188        assert_fragments!(
7189            fragments,
7190            [
7191                raw("<textarea"),
7192                attr_template("value", "attr-1"),
7193                raw(">Hi</textarea>"),
7194            ]
7195        );
7196
7197        // Verify the template sub-stream
7198        assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
7199    }
7200
7201    // ── Body signal tests ─────────────────────────────────────────────
7202
7203    #[test]
7204    fn test_body_signals() {
7205        let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
7206        assert_fragments!(
7207            fragments,
7208            [
7209                raw("<body>"),
7210                structural_matcher("body_start"),
7211                raw("<app-shell></app-shell>"),
7212                structural_matcher("body_end"),
7213                raw("</body>"),
7214            ]
7215        );
7216    }
7217
7218    // ── Empty for handling tests ──────────────────────────────────────
7219
7220    #[test]
7221    fn test_empty_for_produces_nothing() {
7222        let (fragments, records) =
7223            parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
7224        assert_fragments!(fragments, [raw("<div></div>"),]);
7225        assert!(!records.contains_key("for-1"));
7226    }
7227
7228    // ── Self-closing / void element tests ─────────────────────────────
7229
7230    #[test]
7231    fn test_self_closing_svg_path() {
7232        let (fragments, _) =
7233            parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
7234        assert_fragments!(
7235            fragments,
7236            [raw(
7237                r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
7238            ),]
7239        );
7240    }
7241
7242    #[test]
7243    fn test_html5_void_elements() {
7244        let (fragments, _) = parse_and_get_fragments(
7245            r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
7246        );
7247        assert_fragments!(
7248            fragments,
7249            [raw(
7250                r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
7251            ),]
7252        );
7253    }
7254
7255    #[test]
7256    fn test_self_closing_with_dynamic_attributes() {
7257        let (fragments, _) =
7258            parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
7259        assert_fragments!(
7260            fragments,
7261            [
7262                raw("<img"),
7263                attr("src", "imageUrl"),
7264                attr("alt", "imageAlt"),
7265                raw("/>"),
7266            ]
7267        );
7268    }
7269
7270    #[test]
7271    fn test_self_closing_with_boolean_attributes() {
7272        let (fragments, _) = parse_and_get_fragments(
7273            r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
7274        );
7275        assert_fragments!(
7276            fragments,
7277            [
7278                raw("<input type=\"checkbox\""),
7279                bool_attr("checked", "isSelected"),
7280                bool_attr("disabled", "isDisabled"),
7281                raw("/>"),
7282            ]
7283        );
7284    }
7285
7286    #[test]
7287    fn test_multiple_self_closing_in_sequence() {
7288        let (fragments, _) =
7289            parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
7290        assert_fragments!(
7291            fragments,
7292            [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
7293        );
7294    }
7295
7296    #[test]
7297    fn test_self_closing_with_mixed_content() {
7298        let (fragments, _) =
7299            parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
7300        assert_fragments!(
7301            fragments,
7302            [
7303                raw("<div>Text before<img"),
7304                attr("src", "url"),
7305                raw("/>Text after</div>"),
7306            ]
7307        );
7308    }
7309
7310    #[test]
7311    fn test_self_closing_svg_elements() {
7312        let (fragments, _) = parse_and_get_fragments(
7313            r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
7314        );
7315        assert_fragments!(
7316            fragments,
7317            [
7318                raw("<svg><circle"),
7319                attr("cx", "x"),
7320                attr("cy", "y"),
7321                raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
7322            ]
7323        );
7324    }
7325
7326    #[test]
7327    fn test_self_closing_inside_for_loop() {
7328        let (fragments, records) = parse_and_get_fragments(
7329            r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
7330        );
7331        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7332        assert_stream!(
7333            records,
7334            "for-1",
7335            [raw("<img"), attr("src", "item.url"), raw("/>"),]
7336        );
7337    }
7338
7339    #[test]
7340    fn test_self_closing_whitespace_variations() {
7341        let (fragments, _) =
7342            parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
7343        assert_fragments!(
7344            fragments,
7345            [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
7346        );
7347    }
7348
7349    #[test]
7350    fn test_deeply_nested_self_closing() {
7351        let (fragments, _) = parse_and_get_fragments(
7352            r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
7353        );
7354        assert_fragments!(
7355            fragments,
7356            [raw(
7357                r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
7358            ),]
7359        );
7360    }
7361
7362    #[test]
7363    fn test_self_closing_vs_empty_regular_tags() {
7364        let (fragments, _) =
7365            parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
7366        assert_fragments!(
7367            fragments,
7368            [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
7369        );
7370    }
7371
7372    // ── Feature 1: Named <for> bodies ─────────────────────────────────
7373
7374    #[test]
7375    fn test_for_custom_id_attribute() {
7376        let (fragments, records) = parse_and_get_fragments(
7377            r#"<for each="item in items" id="static"><span>Item</span></for>"#,
7378        );
7379        assert_fragments!(fragments, [for_loop("item", "items", "static-1"),]);
7380        assert_stream!(records, "static-1", [raw("<span>Item</span>"),]);
7381    }
7382
7383    #[test]
7384    fn test_for_recursive_template() {
7385        let mut parser = HtmlParser::new();
7386        let html = r#"<for id="static" each="item in items"><div><span>{{item.name}}</span><for id="static" each="item in item.children" /></div></for>"#;
7387        let result = parser.parse("index.html", html);
7388        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7389        let records = parser.into_fragment_records();
7390
7391        assert_fragments!(
7392            records["index.html"].fragments,
7393            [for_loop("item", "items", "static-1"),]
7394        );
7395
7396        assert_stream!(
7397            records,
7398            "static-1",
7399            [
7400                raw("<div><span>"),
7401                signal("item.name"),
7402                raw("</span>"),
7403                for_loop("item", "item.children", "static-1"),
7404                raw("</div>"),
7405            ]
7406        );
7407    }
7408
7409    #[test]
7410    fn each_accepts_only_direct_item_in_collection_syntax() {
7411        assert_eq!(
7412            parse_for_each("test-tree", "child in items").unwrap(),
7413            ("child", "items")
7414        );
7415        assert_eq!(
7416            parse_for_each("test-tree", " \tchild \n in \r child.children ").unwrap(),
7417            ("child", "child.children")
7418        );
7419    }
7420
7421    #[test]
7422    fn braced_each_is_rejected_by_server_and_client_compilation() {
7423        use crate::plugin::webui::generate_compiled_template;
7424
7425        for source in [
7426            r#"<for each="{{child in items}}"><span>{{child.name}}</span></for>"#,
7427            r#"<for id="tree" each="{{child in items}}"><span>{{child.name}}</span></for>"#,
7428            r#"<for id="tree" each="child in items"><for id="tree" each="{{child in child.children}}" /></for>"#,
7429            r#"<for id="tree" each="{{ child in items }}"><span>{{child.name}}</span></for>"#,
7430            r#"<for id="tree" each="{child in items}"><span>{{child.name}}</span></for>"#,
7431        ] {
7432            let server = HtmlParser::new().parse("test-tree", source).unwrap_err();
7433            let client = generate_compiled_template("test-tree", source).unwrap_err();
7434            let ParserError::Template(server) = server else {
7435                panic!("expected a server authoring diagnostic");
7436            };
7437            let ParserError::Template(client) = client else {
7438                panic!("expected a client authoring diagnostic");
7439            };
7440            assert_eq!(server.error_code(), client.error_code(), "{source}");
7441            assert!(matches!(
7442                server.error_code(),
7443                Some(codes::INVALID_FOR_EACH | codes::INVALID_FOR_IDENTIFIER)
7444            ));
7445            assert!(server.position_line_column().is_some());
7446            assert!(server.help_text().unwrap().contains("without braces"));
7447            assert!(client.help_text().unwrap().contains("without braces"));
7448        }
7449    }
7450
7451    #[test]
7452    fn named_for_recursion_reuses_one_scoped_record() {
7453        let mut parser = HtmlParser::new();
7454        parser
7455            .parse(
7456                "file-a.html",
7457                r#"<for id="tree-item" each="child in items"><li>{{child.name}}</li><for id="tree-item" each="child in child.children" /></for>"#,
7458            )
7459            .unwrap();
7460        let records = parser.into_fragment_records();
7461        assert_eq!(records.len(), 2);
7462        assert_stream!(
7463            records,
7464            "file-a.html",
7465            [for_loop("child", "items", "tree-item-1")]
7466        );
7467        assert_stream!(
7468            records,
7469            "tree-item-1",
7470            [
7471                raw("<li>"),
7472                signal("child.name"),
7473                raw("</li>"),
7474                for_loop("child", "child.children", "tree-item-1"),
7475            ]
7476        );
7477    }
7478
7479    #[test]
7480    fn named_for_forward_references_and_empty_definitions() {
7481        let mut parser = HtmlParser::new();
7482        parser
7483            .parse(
7484                "file-a.html",
7485                r#"<for id="loop" each="item in earlier" /><for id="loop" each="item in later"></for>"#,
7486            )
7487            .unwrap();
7488        let records = parser.into_fragment_records();
7489        assert_stream!(
7490            records,
7491            "file-a.html",
7492            [
7493                for_loop("item", "earlier", "loop-1"),
7494                for_loop("item", "later", "loop-1")
7495            ]
7496        );
7497        assert!(records["loop-1"].fragments.is_empty());
7498    }
7499
7500    #[test]
7501    fn named_for_scope_survives_component_parsing_and_reparsing() {
7502        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7503        parser
7504            .component_registry_mut()
7505            .register_component(ComponentRegistration::new(
7506                "other-tree",
7507                r#"<for id="loop" each="item in other"><b>{{item.label}}</b></for>"#,
7508                None,
7509                false,
7510            ))
7511            .unwrap();
7512        let source = r#"<for id="loop" each="item in items"><other-tree></other-tree><i>{{item.name}}</i><for id="loop" each="item in item.children" /></for>"#;
7513        parser.parse("file-a.html", source).unwrap();
7514        parser
7515            .parse(
7516                "file-b.html",
7517                r#"<for id="loop" each="item in items">B</for>"#,
7518            )
7519            .unwrap();
7520        parser.parse("file-a.html", source).unwrap();
7521        let records = parser.into_fragment_records();
7522        assert_stream!(
7523            records,
7524            "file-a.html",
7525            [for_loop("item", "items", "loop-1")]
7526        );
7527        assert_stream!(records, "other-tree", [for_loop("item", "other", "loop-2")]);
7528        assert_stream!(
7529            records,
7530            "file-b.html",
7531            [for_loop("item", "items", "loop-3")]
7532        );
7533        assert_stream!(records, "loop-3", [raw("B")]);
7534        assert!(records["loop-1"].fragments.iter().any(|fragment| {
7535            matches!(fragment.fragment.as_ref(), Some(Fragment::ForLoop(repeat)) if repeat.fragment_id == "loop-1")
7536        }));
7537    }
7538
7539    #[test]
7540    fn named_for_reports_authoring_errors() {
7541        for (source, code) in [
7542            (r#"<for id each="x in xs" />"#, codes::INVALID_FOR_ID),
7543            (r#"<for id="" each="x in xs" />"#, codes::INVALID_FOR_ID),
7544            (
7545                r#"<for id="{{name}}" each="x in xs" />"#,
7546                codes::INVALID_FOR_ID,
7547            ),
7548            (
7549                r#"<for id="tree" template="tree" each="x in xs" />"#,
7550                codes::INVALID_FOR_ID,
7551            ),
7552            (
7553                r#"<for id="tree" id="tree" each="x in xs" />"#,
7554                codes::INVALID_FOR_ID,
7555            ),
7556            (r#"<for id="tree" each="x in xs" />"#, codes::UNKNOWN_FOR_ID),
7557            (
7558                r#"<for id="tree" each="x in xs">a</for><for id="tree" each="x in ys">b</for>"#,
7559                codes::DUPLICATE_FOR_ID,
7560            ),
7561            (
7562                r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children"><b>{{y.name}}</b></for></for>"#,
7563                codes::DUPLICATE_FOR_ID,
7564            ),
7565            (
7566                r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children" /></for>"#,
7567                codes::INCOMPATIBLE_FOR_ITEM,
7568            ),
7569        ] {
7570            let error = HtmlParser::new().parse("file-a.html", source).unwrap_err();
7571            let ParserError::Template(diagnostic) = error else {
7572                panic!("expected structured diagnostic for {source}");
7573            };
7574            assert_eq!(diagnostic.error_code(), Some(code), "{source}");
7575            assert!(!diagnostic.to_string().contains('\u{1b}'));
7576            assert!(diagnostic.to_string().contains("help:"));
7577            assert!(diagnostic.to_string().contains("file-a.html"));
7578            assert!(diagnostic.position_line_column().is_some(), "{source}");
7579        }
7580    }
7581
7582    #[test]
7583    fn named_for_cannot_reference_a_definition_from_another_file() {
7584        let mut parser = HtmlParser::new();
7585        parser
7586            .parse("a.html", r#"<for id="tree" each="x in xs">a</for>"#)
7587            .unwrap();
7588        let error = parser
7589            .parse("b.html", r#"<for id="tree" each="x in xs" />"#)
7590            .unwrap_err();
7591        assert!(
7592            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::UNKNOWN_FOR_ID))
7593        );
7594    }
7595
7596    #[test]
7597    fn named_for_does_not_overwrite_generated_fragments() {
7598        let mut parser = HtmlParser::new();
7599        parser
7600            .parse(
7601                "a.html",
7602                r#"<for id="for" each="x in xs">named</for><for each="x in xs">ordinary</for>"#,
7603            )
7604            .unwrap();
7605        let records = parser.into_fragment_records();
7606        assert_stream!(records, "for-1", [raw("named")]);
7607        assert_stream!(records, "for-2", [raw("ordinary")]);
7608        let error = HtmlParser::new()
7609            .parse(
7610                "a.html",
7611                r#"<for each="x in xs">ordinary</for><for id="for" each="x in xs">named</for>"#,
7612            )
7613            .unwrap_err();
7614        assert!(
7615            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::INVALID_FOR_ID))
7616        );
7617    }
7618
7619    #[test]
7620    fn named_for_css_analysis_terminates_without_losing_sibling_scopes() {
7621        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7622        for (name, source, css) in [
7623            ("token-leaf", "<b>leaf</b>", "b { color: var(--accent); }"),
7624            (
7625                "defined-tree",
7626                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
7627                ":root { --accent: red; }",
7628            ),
7629            (
7630                "undefined-tree",
7631                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
7632                "",
7633            ),
7634        ] {
7635            parser
7636                .component_registry_mut()
7637                .register_component(ComponentRegistration::new(name, source, Some(css), false))
7638                .unwrap();
7639        }
7640        parser
7641            .parse(
7642                "index.html",
7643                "<defined-tree></defined-tree><undefined-tree></undefined-tree>",
7644            )
7645            .unwrap();
7646        let analysis = parser.token_analysis();
7647        assert_eq!(analysis.protocol_tokens, ["accent"]);
7648        assert_eq!(analysis.fallback_chains.len(), 1);
7649    }
7650
7651    #[test]
7652    fn named_for_css_analysis_deduplicates_equivalent_diamond_paths() {
7653        use std::fmt::Write;
7654
7655        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7656        parser
7657            .component_registry_mut()
7658            .register_component(ComponentRegistration::new(
7659                "token-leaf",
7660                "<b>leaf</b>",
7661                Some("b { color: var(--accent); }"),
7662                false,
7663            ))
7664            .unwrap();
7665        let mut source = String::with_capacity(12 * 150);
7666        for index in 0..12 {
7667            write!(source, r#"<for id="b{index}" each="item in items">"#).unwrap();
7668            if index < 11 {
7669                let next = index + 1;
7670                write!(source, r#"<for id="b{next}" each="item in item.children"/><for id="b{next}" each="item in item.children"/>"#).unwrap();
7671            } else {
7672                source.push_str("<token-leaf></token-leaf>");
7673            }
7674            source.push_str("</for>");
7675        }
7676        parser.parse("index.html", &source).unwrap();
7677        let analysis = parser.token_analysis();
7678        assert_eq!(analysis.protocol_tokens, ["accent"]);
7679        assert_eq!(analysis.fallback_chains.len(), 1);
7680    }
7681
7682    #[test]
7683    fn named_for_css_analysis_preserves_distinct_same_size_definition_sets() {
7684        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7685        for (name, source, css) in [
7686            (
7687                "token-leaf",
7688                "<b>leaf</b>",
7689                "b { color: var(--accent); background: var(--shade); }",
7690            ),
7691            (
7692                "shared-tree",
7693                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children"/></for>"#,
7694                "",
7695            ),
7696            (
7697                "accent-tree",
7698                "<shared-tree></shared-tree>",
7699                ":root { --accent: red; }",
7700            ),
7701            (
7702                "shade-tree",
7703                "<shared-tree></shared-tree>",
7704                ":root { --shade: blue; }",
7705            ),
7706        ] {
7707            parser
7708                .component_registry_mut()
7709                .register_component(ComponentRegistration::new(name, source, Some(css), false))
7710                .unwrap();
7711        }
7712        parser
7713            .parse(
7714                "index.html",
7715                "<accent-tree></accent-tree><shade-tree></shade-tree>",
7716            )
7717            .unwrap();
7718        assert_eq!(parser.token_analysis().protocol_tokens, ["accent", "shade"]);
7719    }
7720
7721    #[test]
7722    fn legacy_for_template_attribute_is_rejected() {
7723        for source in [
7724            r#"<for template="legacy" each="x in xs"><span>{{x.name}}</span></for>"#,
7725            r#"<for template="legacy" each="x in xs" />"#,
7726            r#"<for template each="x in xs"></for>"#,
7727            r#"<for template="" each="x in xs"></for>"#,
7728        ] {
7729            let error = HtmlParser::new().parse("index.html", source).unwrap_err();
7730            let ParserError::Template(diagnostic) = error else {
7731                panic!("expected an authoring diagnostic for {source}");
7732            };
7733            assert_eq!(diagnostic.error_code(), Some(codes::INVALID_FOR_ID));
7734            assert!(diagnostic
7735                .help_text()
7736                .unwrap()
7737                .contains("replace template with id"));
7738            assert!(diagnostic.position_line_column().is_some());
7739        }
7740    }
7741
7742    #[test]
7743    fn named_for_recursion_still_rejects_reachable_boundaries() {
7744        let error = HtmlParser::new().parse("index.html", r#"<for id="tree" each="x in xs"><for id="tree" each="x in x.children" /><if condition="{{x.name}}"><boundary name="invalid"><b>body</b></boundary></if></for>"#).unwrap_err();
7745        assert!(
7746            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::BOUNDARY_IN_REPEAT))
7747        );
7748    }
7749
7750    #[test]
7751    fn named_for_fast_components_reject_references_but_keep_plain_loops() {
7752        use crate::plugin::{
7753            fast_v2::FastV2ParserPlugin, fast_v3::FastV3ParserPlugin, ParserPlugin,
7754            ParserPluginArtifacts,
7755        };
7756
7757        for fast_v2 in [true, false] {
7758            for references in [true, false] {
7759                let plugin: Box<dyn ParserPlugin> = if fast_v2 {
7760                    Box::new(FastV2ParserPlugin::new())
7761                } else {
7762                    Box::new(FastV3ParserPlugin::new())
7763                };
7764                let mut parser = HtmlParser::with_plugin(plugin);
7765                let body = if references {
7766                    r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#
7767                } else {
7768                    r#"<for id="tree" each="item in items"><if condition="{{item.name}}"><b>{{item.name}}</b></if></for>"#
7769                };
7770                parser
7771                    .component_registry_mut()
7772                    .register_component(ComponentRegistration::new("my-tree", body, None, true))
7773                    .unwrap();
7774                let result = parser.parse("index.html", "<my-tree></my-tree>");
7775                if references {
7776                    let error = result.unwrap_err();
7777                    assert!(
7778                        matches!(error, ParserError::Template(diag) if diag.error_code() == Some("fast-named-for-unsupported") && diag.position_line_column().is_some() && diag.help_text().is_some())
7779                    );
7780                } else {
7781                    result.unwrap();
7782                    let ParserPluginArtifacts::ComponentTemplates(templates) =
7783                        parser.take_plugin_artifacts().unwrap()
7784                    else {
7785                        panic!("FAST produces component templates");
7786                    };
7787                    assert!(templates[0]
7788                        .template
7789                        .contains(r#"<f-repeat value="{{item in items}}">"#));
7790                    assert!(templates[0]
7791                        .template
7792                        .contains(r#"<f-when value="{{item.name}}">"#));
7793                }
7794            }
7795        }
7796    }
7797
7798    // ── Feature 2: <if> / <for> with multiple children ──────────────────
7799
7800    #[test]
7801    fn test_if_multiple_children() {
7802        // Port of: 'should handle <if> with multiple children'
7803        let (fragments, records) =
7804            parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
7805        assert_fragments!(fragments, [if_cond("if-1"),]);
7806        assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
7807    }
7808
7809    #[test]
7810    fn if_condition_with_double_quoted_literal_round_trips_through_parser() {
7811        // The FAST converter emits a single-quoted `<if condition='…'>` when the
7812        // expression contains a double-quoted literal. The WebUI parser must
7813        // extract the full expression (no truncation at the embedded quote), so
7814        // SSR and client see the same predicate.
7815        let (fragments, _) =
7816            parse_and_get_fragments(r#"<if condition='status == "ready"'><span>ok</span></if>"#);
7817        let condition = fragments
7818            .iter()
7819            .find_map(|fragment| match fragment.fragment.as_ref() {
7820                Some(webui_protocol::web_ui_fragment::Fragment::IfCond(if_cond)) => {
7821                    if_cond.condition.as_ref()
7822                }
7823                _ => None,
7824            })
7825            .expect("if-condition fragment with a parsed condition");
7826        match condition.expr.as_ref() {
7827            Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
7828                assert_eq!(pred.left, "status");
7829                assert_eq!(pred.right, "\"ready\"");
7830            }
7831            other => panic!("expected a predicate condition, got {other:?}"),
7832        }
7833    }
7834
7835    #[test]
7836    fn test_for_multiple_children() {
7837        // Port of: 'should handle <for> with multiple children'
7838        let (fragments, records) =
7839            parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
7840        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7841        assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
7842    }
7843
7844    // ── Feature 3: Handlebars at beginning/end of text ──────────────────
7845
7846    #[test]
7847    fn test_handlebars_at_beginning() {
7848        // Port of: 'should process handlebars from text at beginning'
7849        let (fragments, _) = parse_and_get_fragments("{{first}}");
7850        assert_fragments!(fragments, [signal("first"),]);
7851    }
7852
7853    #[test]
7854    fn test_handlebars_at_beginning_and_raw() {
7855        // Port of: 'should process handlebars from text at beginning and raw'
7856        let (fragments, _) = parse_and_get_fragments("{{first}}test");
7857        assert_fragments!(fragments, [signal("first"), raw("test"),]);
7858    }
7859
7860    #[test]
7861    fn test_handlebars_raw_and_end() {
7862        // Port of: 'should process handlebars from text at raw and end'
7863        let (fragments, _) = parse_and_get_fragments("test{{first}}");
7864        assert_fragments!(fragments, [raw("test"), signal("first"),]);
7865    }
7866
7867    // ── Feature 4: Handlebars edge cases ────────────────────────────────
7868
7869    #[test]
7870    fn test_handlebars_invalid_triple_open() {
7871        // Port of: 'should not process handlebars when invalid'
7872        let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
7873        assert_fragments!(fragments, [raw("{{{invalid}}"),]);
7874    }
7875
7876    #[test]
7877    fn test_handlebars_four_open_braces() {
7878        // Port of: 'should not process handlebars when invalid since triple exists'
7879        let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
7880        assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
7881    }
7882
7883    #[test]
7884    fn test_handlebars_five_open_with_valid_double() {
7885        // Port of: 'should not process handlebars when invalid but with valid triple'
7886        let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
7887        assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
7888    }
7889
7890    #[test]
7891    fn test_entities_preserved() {
7892        // Port of: 'should process entities correctly'
7893        let (fragments, _) = parse_and_get_fragments("<p>Hello&#125;World</p>");
7894        assert_fragments!(fragments, [raw("<p>Hello&#125;World</p>"),]);
7895    }
7896
7897    // ── Feature 5: DOCTYPE handling ─────────────────────────────────────
7898
7899    #[test]
7900    fn test_doctype_preserved() {
7901        // DOCTYPE should be preserved as raw content
7902        let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
7903        assert!(!fragments.is_empty());
7904        assert!(
7905            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
7906        );
7907    }
7908
7909    // ── Feature 6: Component attribute skip / multiple nested ───────────
7910
7911    #[test]
7912    fn test_component_attr_skip() {
7913        // Port of: 'should set attrSkip for skipped component attributes'
7914        let mut parser = HtmlParser::new();
7915        parser
7916            .component_registry
7917            .register_component(ComponentRegistration::new(
7918                "custom-element",
7919                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7920                None,
7921                true,
7922            ))
7923            .expect("register");
7924        let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
7925        let result = parser.parse("index.html", html);
7926        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7927        let records = parser.into_fragment_records();
7928
7929        // <custom-element, :config(attrStart), class(attrSkip), style(attrSkip),
7930        // role(attrSkip), data-test(attrSkip), aria-test, >, component, </custom-element>
7931        assert_fragments!(
7932            records["index.html"].fragments,
7933            [
7934                raw("<custom-element"),
7935                // :config with attrStart
7936                attr_complex_start(":config", "config"),
7937                // Skipped attrs
7938                attr_skip("class", "value0"),
7939                attr_skip("style", "value1"),
7940                attr_skip("role", "value2"),
7941                attr_skip("data-test", "value3"),
7942                attr("aria-test", "value4"),
7943                structural_matcher("streaming_root:custom-element"),
7944                raw(">"),
7945                component("custom-element"),
7946                raw("</custom-element>"),
7947            ]
7948        );
7949    }
7950
7951    #[test]
7952    fn test_component_attr_skip_static_and_embedded() {
7953        // Skipped attrs with static values and embedded bindings should
7954        // emit fragments (not be silently dropped).
7955        let mut parser = HtmlParser::new();
7956        parser
7957            .component_registry
7958            .register_component(ComponentRegistration::new(
7959                "item-group",
7960                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7961                None,
7962                true,
7963            ))
7964            .expect("register");
7965
7966        let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
7967        let result = parser.parse("index.html", html);
7968        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7969        let records = parser.into_fragment_records();
7970
7971        assert_fragments!(
7972            records["index.html"].fragments,
7973            [
7974                raw("<item-group"),
7975                attr_skip_raw("role", "list"),
7976                FragmentMatcher::Attribute(AttrMatcher {
7977                    name: "aria-labelledby".into(),
7978                    template: Some("attr-1".into()),
7979                    attr_start: true,
7980                    ..Default::default()
7981                }),
7982                attr_skip_template("data-testid", "attr-2"),
7983                attr_skip_raw("class", "fixed-class"),
7984                structural_matcher("streaming_root:item-group"),
7985                raw(">"),
7986                component("item-group"),
7987                raw("</item-group>"),
7988            ]
7989        );
7990
7991        // Verify the embedded-binding template sub-streams exist and
7992        // contain the expected static + signal fragments.
7993        assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
7994        assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
7995    }
7996
7997    #[test]
7998    fn test_component_multiple_nested() {
7999        // Port of: 'handle multiple nested web components'
8000        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8001        parser
8002            .component_registry
8003            .register_component(ComponentRegistration::new(
8004                "custom-element",
8005                "<custom-child></custom-child><span></span>",
8006                None,
8007                true,
8008            ))
8009            .expect("register");
8010        parser
8011            .component_registry
8012            .register_component(ComponentRegistration::new(
8013                "custom-button",
8014                "<span></span>",
8015                None,
8016                true,
8017            ))
8018            .expect("register");
8019        parser
8020            .component_registry
8021            .register_component(ComponentRegistration::new(
8022                "custom-child",
8023                "<h1>Hello World!</h1>",
8024                None,
8025                true,
8026            ))
8027            .expect("register");
8028
8029        let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
8030        let result = parser.parse("index.html", html);
8031        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8032        let records = parser.into_fragment_records();
8033
8034        // Entry stream
8035        assert_fragments!(
8036            records["index.html"].fragments,
8037            [for_loop("item", "items", "for-1"),]
8038        );
8039
8040        // For stream
8041        assert_stream!(
8042            records,
8043            "for-1",
8044            [
8045                raw("<custom-element"),
8046                structural_matcher("streaming_root:custom-element"),
8047                raw(">"),
8048                component("custom-element"),
8049                raw("<custom-button"),
8050                structural_matcher("streaming_root:custom-button"),
8051                raw(">"),
8052                component("custom-button"),
8053                raw("Ok</custom-button></custom-element>"),
8054            ]
8055        );
8056
8057        // Component streams — custom-element has contains() checks, keep manual
8058        let ce = &records["custom-element"].fragments;
8059        assert_eq!(ce.len(), 5);
8060        assert!(
8061            matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
8062        );
8063        assert!(
8064            matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
8065        );
8066        assert!(
8067            matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><span></span>"))
8068        );
8069
8070        assert_stream!(records, "custom-button", [raw("<span></span>"),]);
8071
8072        assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
8073    }
8074
8075    // ── Error handling tests ──────────────────────────────────────────
8076
8077    #[test]
8078    fn test_invalid_markup_returns_error() {
8079        // Port of: 'should fail with invalid markup'
8080        let mut parser = HtmlParser::new();
8081        let result = parser.parse("index.html", "<div><span>Unclosed div");
8082        assert!(matches!(result, Err(ParserError::Template(diag)) if
8083            diag.to_string().contains("unclosed <div> tag")
8084        ));
8085    }
8086
8087    #[test]
8088    fn test_unterminated_opening_tag_returns_error() {
8089        let mut parser = HtmlParser::new();
8090        let result = parser.parse("index.html", "before <div");
8091        assert!(matches!(result, Err(ParserError::Template(diag)) if
8092            diag.to_string().contains("malformed HTML tag")
8093        ));
8094    }
8095
8096    #[test]
8097    fn test_unterminated_html_comment_returns_error() {
8098        let mut parser = HtmlParser::new();
8099        let result = parser.parse("index.html", "<div><!-- missing close</div>");
8100        assert!(matches!(result, Err(ParserError::Template(diag)) if
8101            diag.to_string().contains("unclosed <div> tag")
8102                || diag.to_string().contains("unterminated HTML comment")
8103        ));
8104    }
8105
8106    #[test]
8107    fn test_unexpected_closing_tag_returns_error() {
8108        let mut parser = HtmlParser::new();
8109        let result = parser.parse("index.html", "</span>");
8110        assert!(matches!(result, Err(ParserError::Template(diag)) if
8111            diag.to_string().contains("unexpected closing tag </span>")
8112        ));
8113    }
8114
8115    #[test]
8116    fn test_rejects_templates_over_size_limit() {
8117        let mut parser = HtmlParser::new();
8118        let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
8119        let result = parser.parse("index.html", &html);
8120
8121        assert!(matches!(result, Err(ParserError::Html(message)) if
8122            message.contains("exceeds") && message.contains("parser limit")
8123        ));
8124    }
8125
8126    #[test]
8127    fn test_rejects_templates_over_depth_limit() {
8128        let mut parser = HtmlParser::new();
8129        let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
8130        for _ in 0..=MAX_TEMPLATE_DEPTH {
8131            html.push_str("<div>");
8132        }
8133        for _ in 0..=MAX_TEMPLATE_DEPTH {
8134            html.push_str("</div>");
8135        }
8136
8137        let result = parser.parse("index.html", &html);
8138
8139        assert!(matches!(result, Err(ParserError::Template(diag)) if
8140            diag.to_string().contains("nesting exceeds")
8141        ));
8142    }
8143
8144    #[test]
8145    fn test_rejects_routes_over_depth_limit() {
8146        let mut parser = HtmlParser::new();
8147        parser
8148            .component_registry
8149            .register_component(ComponentRegistration::new(
8150                "route-page",
8151                r#"<template shadowrootmode="open"><slot></slot></template>"#,
8152                None,
8153                true,
8154            ))
8155            .expect("register");
8156
8157        let depth = MAX_TEMPLATE_DEPTH + 2;
8158        let mut html = String::with_capacity(depth * 48);
8159        for _ in 0..depth {
8160            html.push_str(r#"<route path="x" component="route-page">"#);
8161        }
8162        for _ in 0..depth {
8163            html.push_str("</route>");
8164        }
8165
8166        let result = parser.parse("index.html", &html);
8167
8168        assert!(matches!(result, Err(ParserError::Template(diag)) if
8169            diag.error_code() == Some(codes::EXCESSIVE_NESTING)
8170                && diag.to_string().contains("route nesting exceeds")
8171        ));
8172    }
8173
8174    #[test]
8175    fn test_rejects_recursive_component_templates() {
8176        let mut parser = HtmlParser::new();
8177        parser
8178            .component_registry
8179            .register_component(ComponentRegistration::new(
8180                "self-card",
8181                "<self-card></self-card>",
8182                None,
8183                true,
8184            ))
8185            .expect("register");
8186
8187        let result = parser.parse("index.html", "<self-card></self-card>");
8188
8189        assert!(matches!(result, Err(ParserError::Template(diag)) if
8190            diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
8191                && diag.to_string().contains("recursive template reference")
8192        ));
8193    }
8194
8195    // ── Integration tests ─────────────────────────────────────────────
8196
8197    #[test]
8198    fn test_complex_raw_text_full_page() {
8199        // Port of: 'should process a complex raw text'
8200        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>"#;
8201        let (fragments, _) = parse_and_get_fragments(html);
8202
8203        // DOCTYPE + <head>, head_start, head content, head_end, </head><body>,
8204        // body_start, body content, body_end, </body></html>
8205        assert!(fragments.len() >= 9);
8206        assert!(
8207            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8208                raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
8209        );
8210        assert!(
8211            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
8212                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
8213        );
8214        assert!(
8215            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8216                raw.value.contains("<title>Test</title>"))
8217        );
8218        assert!(
8219            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
8220                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
8221        );
8222        assert!(
8223            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8224                raw.value.contains("</head>") && raw.value.ends_with("<body>"))
8225        );
8226        assert!(
8227            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
8228                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
8229        );
8230        assert!(
8231            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8232                raw.value.contains("<app-shell>"))
8233        );
8234        assert!(
8235            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
8236                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
8237        );
8238        assert!(
8239            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8240                raw.value.contains("</body>") && raw.value.contains("</html>"))
8241        );
8242    }
8243
8244    #[test]
8245    fn raw_bindings_in_html_text_contexts_do_not_own_html_ranges() {
8246        for element in ["title", "textarea", "script", "xmp"] {
8247            let html = format!("<{element}>{{{{{{value}}}}}}</{element}>");
8248            let (fragments, _) = parse_and_get_fragments(&html);
8249            let signal = fragments
8250                .iter()
8251                .find_map(|fragment| match fragment.fragment.as_ref() {
8252                    Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
8253                    _ => None,
8254                });
8255            let signal = signal
8256                .unwrap_or_else(|| panic!("expected raw signal inside <{element}>: {fragments:?}"));
8257            assert!(
8258                signal.raw,
8259                "<{element}> should preserve raw brace semantics"
8260            );
8261            assert!(
8262                signal.raw_text_context,
8263                "<{element}> should suppress HTML ownership markers"
8264            );
8265        }
8266    }
8267
8268    #[test]
8269    fn raw_bindings_in_template_content_own_html_ranges() {
8270        let (fragments, _) = parse_and_get_fragments("<template>{{{value}}}</template>");
8271        let signal = fragments
8272            .iter()
8273            .find_map(|fragment| match fragment.fragment.as_ref() {
8274                Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
8275                _ => None,
8276            });
8277        let signal =
8278            signal.unwrap_or_else(|| panic!("expected raw signal inside template: {fragments:?}"));
8279        assert!(signal.raw);
8280        assert!(!signal.raw_text_context);
8281    }
8282
8283    #[test]
8284    fn test_css_strategy_external_defers_link_to_server_closure() {
8285        let mut parser = HtmlParser::new();
8286        parser
8287            .component_registry_mut()
8288            .register_component(ComponentRegistration::new(
8289                "my-card",
8290                r#"<template shadowrootmode="open"><p>content</p></template>"#,
8291                Some("p { color: red; }"),
8292                true,
8293            ))
8294            .ok();
8295        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
8296        let records = parser.into_fragment_records();
8297        let my_card = &records["my-card"].fragments;
8298        let raw_text: String = my_card
8299            .iter()
8300            .filter_map(|f| match &f.fragment {
8301                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8302                _ => None,
8303            })
8304            .collect();
8305        assert!(
8306            !raw_text.contains(r#"<link rel="stylesheet""#),
8307            "component templates must not own tree-local links: {}",
8308            raw_text
8309        );
8310        assert!(my_card.iter().any(|fragment| matches!(
8311            fragment.fragment.as_ref(),
8312            Some(Fragment::Signal(signal))
8313                if signal.value == "}}}webui:shadow_styles:my-card"
8314        )));
8315    }
8316
8317    #[test]
8318    fn test_uppercase_style_element_is_processed_as_css() {
8319        // HTML tag names are ASCII case-insensitive: an uppercase `<STYLE>`
8320        // block must be CSS-processed exactly like `<style>`, so a CSS signal
8321        // comment becomes a Signal fragment (not inert raw text) and the
8322        // reconstructed closing tag mirrors the source case.
8323        let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
8324        assert!(
8325            fragments.iter().any(|f| matches!(
8326                f.fragment.as_ref(),
8327                Some(Fragment::Signal(s))
8328                    if s.value == "tokens" && !s.raw && s.raw_text_context
8329            )),
8330            "uppercase <STYLE> should be CSS-processed, got: {:?}",
8331            fragments
8332        );
8333        let raw: String = fragments
8334            .iter()
8335            .filter_map(|f| match f.fragment.as_ref() {
8336                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8337                _ => None,
8338            })
8339            .collect();
8340        assert!(
8341            raw.contains("</STYLE>"),
8342            "closing tag should preserve source case, got: {raw}"
8343        );
8344    }
8345
8346    #[test]
8347    fn test_css_strategy_inline_defers_style_to_server_closure() {
8348        let mut parser = HtmlParser::with_options(CssStrategy::Style);
8349        parser
8350            .component_registry_mut()
8351            .register_component(ComponentRegistration::new(
8352                "my-card",
8353                "<p>content</p>",
8354                Some("p { color: red; }"),
8355                true,
8356            ))
8357            .expect("register component");
8358        parser
8359            .parse("index.html", "<my-card>Hello</my-card>")
8360            .expect("parse component");
8361        let records = parser.into_fragment_records();
8362        let my_card = &records["my-card"].fragments;
8363        let raw_text: String = my_card
8364            .iter()
8365            .filter_map(|f| match &f.fragment {
8366                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8367                _ => None,
8368            })
8369            .collect();
8370        assert!(
8371            !raw_text.contains("<style>"),
8372            "component templates must not own tree-local styles: {}",
8373            raw_text
8374        );
8375        assert!(
8376            !raw_text.contains("<link"),
8377            "Should not have <link> tag in inline mode: {}",
8378            raw_text
8379        );
8380    }
8381
8382    #[test]
8383    fn test_css_strategy_module_emits_adopted_stylesheets() {
8384        let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
8385        parser
8386            .component_registry_mut()
8387            .register_component(ComponentRegistration::new(
8388                "my-card",
8389                "<p>content</p>",
8390                Some("p { color: red; }"),
8391                true,
8392            ))
8393            .expect("register component");
8394        parser
8395            .parse("index.html", "<my-card>Hello</my-card>")
8396            .expect("parse component");
8397        let records = parser.into_fragment_records();
8398
8399        // Component template should have shadowrootadoptedstylesheets, no CSS
8400        // module in raw fragments (CSS lives on the component fragment's css field)
8401        let my_card = &records["my-card"].fragments;
8402        let template_text: String = my_card
8403            .iter()
8404            .filter_map(|f| match &f.fragment {
8405                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8406                _ => None,
8407            })
8408            .collect();
8409        assert!(
8410            !template_text.contains("shadowrootadoptedstylesheets"),
8411            "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
8412        );
8413        assert!(
8414            !template_text.contains("<link"),
8415            "Should not have <link> in module mode: {template_text}"
8416        );
8417        // No CSS module baked into raw fragments — CSS is stored in
8418        // protocol.components, populated by the build system, and emitted
8419        // by the handler (SSR inline + SPA partials) as importmap scripts.
8420        assert!(
8421            !template_text.contains(r#"<style type="module""#),
8422            "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
8423        );
8424        assert!(
8425            !template_text.contains(r#"<script type="importmap""#),
8426            "CSS module importmap should NOT be in raw fragments: {template_text}"
8427        );
8428    }
8429
8430    #[test]
8431    fn light_component_css_remains_authored_and_unscoped() {
8432        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8433        parser
8434            .component_registry_mut()
8435            .register_component(ComponentRegistration::new(
8436                "my-card",
8437                "<p class=\"label\">content</p>",
8438                Some(".label{color:red}"),
8439                false,
8440            ))
8441            .expect("register component");
8442        parser
8443            .parse("index.html", "<my-card></my-card>")
8444            .expect("parse component");
8445
8446        let css = parser
8447            .component_registry()
8448            .get("my-card")
8449            .and_then(|component| component.css_content.as_deref())
8450            .expect("authored CSS");
8451        assert_eq!(css, ".label{color:red}");
8452    }
8453
8454    #[test]
8455    fn light_component_inline_styles_remain_unscoped_and_unmarked() {
8456        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8457        let built = parser
8458            .build_component_templates(ComponentTemplateInput {
8459                tag_name: "my-card",
8460                html: "<style>.label{color:red}</style><p class=\"label\">content</p>",
8461                artifact_html: None,
8462                authored_html: None,
8463                css_content: None,
8464                artifact_needed: false,
8465            })
8466            .expect("build Light component");
8467
8468        assert_eq!(
8469            built.ssr,
8470            "<style>.label{color:red}</style><p class=\"label\">content</p>"
8471        );
8472        assert!(!built.ssr.contains("data-wl"));
8473    }
8474
8475    #[test]
8476    fn light_component_rejects_shadow_only_selectors() {
8477        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8478        for selector in [":host", ":host-context(body.dark)", "::slotted(*)"] {
8479            let html = format!("<style>{selector}{{color:red}}</style><p>content</p>");
8480            let result = parser.build_component_templates(ComponentTemplateInput {
8481                tag_name: "my-card",
8482                html: &html,
8483                artifact_html: None,
8484                authored_html: None,
8485                css_content: None,
8486                artifact_needed: false,
8487            });
8488            let error = match result {
8489                Ok(_) => panic!("Shadow-only selector must fail in Light DOM"),
8490                Err(error) => error,
8491            };
8492            assert!(matches!(
8493                error,
8494                ParserError::Template(ref diagnostic)
8495                    if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
8496            ));
8497        }
8498    }
8499
8500    #[test]
8501    fn light_component_external_css_rejects_host_selector() {
8502        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8503        parser
8504            .component_registry_mut()
8505            .register_component(ComponentRegistration::new(
8506                "my-card",
8507                "<p>content</p>",
8508                Some(":host{display:block}"),
8509                false,
8510            ))
8511            .expect("register component");
8512        let error = parser
8513            .parse("index.html", "<my-card></my-card>")
8514            .expect_err("external :host must fail in Light DOM");
8515        assert!(matches!(
8516            error,
8517            ParserError::Template(ref diagnostic)
8518                if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
8519        ));
8520    }
8521
8522    #[test]
8523    fn shadow_component_inline_styles_remain_tree_local_and_unchanged() {
8524        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
8525        let built = parser
8526            .build_component_templates(ComponentTemplateInput {
8527                tag_name: "my-card",
8528                html: "<style>:host{display:block}.label{color:red}</style><p class=\"label\">content</p>",
8529                artifact_html: None,
8530                authored_html: None,
8531                css_content: None,
8532                artifact_needed: false,
8533            })
8534            .expect("build Shadow component");
8535
8536        assert!(built.ssr.contains(
8537            "<template shadowrootmode=\"open\"><style>:host{display:block}.label{color:red}</style>"
8538        ));
8539        assert!(!built.ssr.contains("data-wl"));
8540    }
8541
8542    #[test]
8543    fn nested_light_hosts_are_unmarked() {
8544        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8545        parser
8546            .component_registry_mut()
8547            .register_component(ComponentRegistration::new(
8548                "my-child",
8549                "<p class=\"inner\">child</p>",
8550                Some(".inner{color:green}"),
8551                false,
8552            ))
8553            .expect("register child");
8554        parser
8555            .component_registry_mut()
8556            .register_component(ComponentRegistration::new(
8557                "my-card",
8558                "<my-child></my-child>",
8559                Some("my-child{display:block}"),
8560                false,
8561            ))
8562            .expect("register parent");
8563        parser
8564            .parse("index.html", "<my-card></my-card>")
8565            .expect("parse component");
8566
8567        let records = parser.into_fragment_records();
8568        let raw: String = records["my-card"]
8569            .fragments
8570            .iter()
8571            .filter_map(|fragment| match &fragment.fragment {
8572                Some(web_ui_fragment::Fragment::Raw(raw)) => Some(raw.value.as_str()),
8573                _ => None,
8574            })
8575            .collect();
8576        assert!(raw.contains("<my-child>"), "nested host was changed: {raw}");
8577        assert!(!raw.contains("data-wl"), "Light markers remain: {raw}");
8578    }
8579
8580    #[test]
8581    fn raw_html_binding_does_not_change_global_css() {
8582        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8583        parser
8584            .component_registry_mut()
8585            .register_component(ComponentRegistration::new(
8586                "my-card",
8587                "<div class=\"body\">{{{descriptionHtml}}}</div>",
8588                Some(".body p{margin:0}"),
8589                false,
8590            ))
8591            .expect("register component");
8592        parser
8593            .parse("index.html", "<my-card></my-card>")
8594            .expect("parse component");
8595
8596        let css = parser
8597            .component_registry()
8598            .get("my-card")
8599            .and_then(|component| component.css_content.as_deref())
8600            .expect("authored CSS");
8601        assert_eq!(css, ".body p{margin:0}");
8602    }
8603
8604    #[test]
8605    fn test_css_strategy_module_no_css_no_adopted_attr() {
8606        let mut parser = HtmlParser::with_options(CssStrategy::Module);
8607        parser
8608            .component_registry_mut()
8609            .register_component(ComponentRegistration::new(
8610                "my-card",
8611                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
8612                None,
8613                true,
8614            ))
8615            .ok();
8616        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
8617        let records = parser.into_fragment_records();
8618        let my_card = &records["my-card"].fragments;
8619        let template_text: String = my_card
8620            .iter()
8621            .filter_map(|f| match &f.fragment {
8622                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8623                _ => None,
8624            })
8625            .collect();
8626        // No CSS → no shadowrootadoptedstylesheets attribute
8627        assert!(
8628            !template_text.contains("shadowrootadoptedstylesheets"),
8629            "Should not have adopted attr without CSS: {template_text}"
8630        );
8631    }
8632
8633    // ── Dev-authored <template> wrapper handling ────────────────────
8634    //
8635    // These tests distinguish an ordinary `<template>` element from a
8636    // declarative Shadow DOM root:
8637    //
8638    // An ordinary `<template ...>` is Light DOM content and is preserved
8639    // verbatim. Only a sole open declarative Shadow root selects Shadow DOM.
8640    //
8641    // Calls `process_component_template` directly so the assertions observe
8642    // the exact HTML string the framework emits for the component template
8643    // (this is what gets handed to the inner parse + plugin hooks).
8644
8645    #[test]
8646    fn light_preserves_dev_template_with_static_attrs() {
8647        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8648        let processed = parser
8649            .process_component_template(
8650                r#"<template foo="bar"><div>hi</div></template>"#,
8651                None,
8652                None,
8653            )
8654            .expect("process failed");
8655        assert!(
8656            processed.contains(r#"<template foo="bar">"#),
8657            "expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
8658        );
8659        assert!(
8660            processed.contains("</template>"),
8661            "expected closing </template> preserved, got: {processed}"
8662        );
8663    }
8664
8665    #[test]
8666    fn light_preserves_dev_template_with_signal_fragment_attrs() {
8667        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8668        let processed = parser
8669            .process_component_template(
8670                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8671                None,
8672                None,
8673            )
8674            .expect("process failed");
8675        assert!(
8676            processed.contains(r#"<template foo="{{foo}}">"#),
8677            "expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
8678        );
8679    }
8680
8681    #[test]
8682    fn light_preserves_dev_template_with_multiple_attrs() {
8683        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8684        let processed = parser.process_component_template(
8685            r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
8686            None,
8687            None,
8688        )
8689        .expect("process failed");
8690        assert!(
8691            processed.contains("autofocus")
8692                && processed.contains(r#"tabindex="0""#)
8693                && processed.contains(r#"role="region""#)
8694                && processed.contains(r#"data-x="y""#),
8695            "expected all dev template attrs preserved, got: {processed}"
8696        );
8697    }
8698
8699    #[test]
8700    fn light_no_template_no_wrapper_added() {
8701        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8702        let processed = parser
8703            .process_component_template("<div>hi</div>", None, None)
8704            .expect("process failed");
8705        assert!(
8706            !processed.contains("<template"),
8707            "framework must not add a <template> wrapper when dev omits one, got: {processed}"
8708        );
8709        assert!(
8710            processed.contains("<div>hi</div>"),
8711            "expected inner content emitted as-is, got: {processed}"
8712        );
8713    }
8714
8715    #[test]
8716    fn ordinary_template_remains_light_dom_content() {
8717        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8718        let processed = parser
8719            .process_component_template(
8720                r#"<template foo="bar"><div>hi</div></template>"#,
8721                None,
8722                None,
8723            )
8724            .expect("process failed");
8725        assert!(
8726            processed.contains(r#"<template foo="bar">"#),
8727            "ordinary template content must be preserved, got: {processed}"
8728        );
8729        assert!(
8730            !processed.contains("shadowrootmode"),
8731            "ordinary template must not gain a generated Shadow root, got: {processed}"
8732        );
8733    }
8734
8735    #[test]
8736    fn bare_template_is_unwrapped_as_explicit_light_dom() {
8737        let mut parser = HtmlParser::new();
8738        let built = parser
8739            .build_component_templates(ComponentTemplateInput {
8740                tag_name: "my-card",
8741                html: "<template><div class=\"label\">hi</div></template>",
8742                artifact_html: None,
8743                authored_html: None,
8744                css_content: Some(".label{color:red}"),
8745                artifact_needed: false,
8746            })
8747            .expect("bare template should select Light DOM");
8748        assert!(!built.uses_shadow_dom);
8749        assert_eq!(built.ssr, r#"<div class="label">hi</div>"#);
8750    }
8751
8752    #[test]
8753    fn attributed_template_keeps_the_shadow_fallback() {
8754        let mut parser = HtmlParser::new();
8755        let built = parser
8756            .build_component_templates(ComponentTemplateInput {
8757                tag_name: "my-card",
8758                html: r#"<template data-purpose="content"><div>hi</div></template>"#,
8759                artifact_html: None,
8760                authored_html: None,
8761                css_content: None,
8762                artifact_needed: false,
8763            })
8764            .expect("attributed template should remain ordinary content");
8765        assert!(built.uses_shadow_dom);
8766        assert_eq!(
8767            built.ssr,
8768            r#"<template shadowrootmode="open"><template data-purpose="content"><div>hi</div></template></template>"#
8769        );
8770    }
8771
8772    #[test]
8773    fn closed_shadow_wrapper_is_rejected_during_template_processing() {
8774        let mut parser = HtmlParser::new();
8775        let error = parser
8776            .process_component_template(
8777                r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
8778                None,
8779                None,
8780            )
8781            .expect_err("closed roots must fail");
8782        assert!(matches!(
8783            error,
8784            ParserError::Template(ref diagnostic)
8785                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
8786        ));
8787    }
8788
8789    #[test]
8790    fn ordinary_template_preserves_signal_fragment_attrs() {
8791        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8792        let processed = parser
8793            .process_component_template(
8794                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8795                None,
8796                None,
8797            )
8798            .expect("process failed");
8799        assert!(
8800            processed.starts_with(r#"<template foo="{{foo}}">"#),
8801            "ordinary template must remain Light content, got: {processed}"
8802        );
8803    }
8804
8805    #[test]
8806    fn unwrapped_component_never_gains_shadow_wrapper() {
8807        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8808        let processed = parser
8809            .process_component_template("<div>hi</div>", None, None)
8810            .expect("process failed");
8811        assert!(
8812            !processed.contains("shadowrootmode"),
8813            "framework must not generate a Shadow root, got: {processed}"
8814        );
8815        assert!(
8816            processed.contains("<div>hi</div>"),
8817            "Light content must survive processing, got: {processed}"
8818        );
8819    }
8820
8821    #[test]
8822    fn default_unwrapped_component_gains_open_shadow_wrapper() {
8823        let mut parser = HtmlParser::new();
8824        let processed = parser
8825            .process_component_template("<div>hi</div>", None, None)
8826            .expect("process failed");
8827        assert_eq!(
8828            processed,
8829            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8830        );
8831    }
8832
8833    #[test]
8834    fn leading_comments_do_not_change_root_template_detection() {
8835        const COMMENT: &str = "<!-- Copyright (C) Corporation. All rights reserved. -->";
8836        let fixtures = [
8837            (
8838                format!(
8839                    "{COMMENT}\n<template shadowrootmode=\"open\">\n  <h1>Hello</h1>\n</template>"
8840                ),
8841                "<template shadowrootmode=\"open\">\n  <h1>Hello</h1>\n</template>",
8842            ),
8843            (format!("{COMMENT}\n<h1>Hello</h1>"), "<h1>Hello</h1>"),
8844            (format!("{COMMENT}\nHello"), "Hello"),
8845        ];
8846
8847        for (input, expected) in fixtures {
8848            let mut parser = HtmlParser::with_options(DomStrategy::Light);
8849            let processed = parser
8850                .process_component_template(&input, None, None)
8851                .expect("leading-comment fixture should process");
8852            assert_eq!(processed, expected);
8853        }
8854    }
8855
8856    #[test]
8857    fn component_policy_wrapper_is_build_only_and_unwrapped_for_light() {
8858        // A policy wrapper is authored on a plain `<template>`; the policy is
8859        // applied to the host through generated CSS, so the wrapper must not
8860        // survive into a Light template.
8861        let html = r#"<template w-hydrate="lazy"><div>hi</div></template>"#;
8862        let mut shadow = HtmlParser::with_options(DomStrategy::Shadow);
8863        let shadow_built = shadow
8864            .build_component_templates(ComponentTemplateInput {
8865                tag_name: "my-comp",
8866                html,
8867                artifact_html: None,
8868                authored_html: None,
8869                css_content: None,
8870                artifact_needed: true,
8871            })
8872            .expect("shadow policy wrapper should compile");
8873        assert_eq!(
8874            shadow_built.ssr,
8875            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8876        );
8877        assert!(!shadow_built.artifact().contains("w-hydrate"));
8878        let mut light = HtmlParser::with_options(DomStrategy::Light);
8879        let light_built = light
8880            .build_component_templates(ComponentTemplateInput {
8881                tag_name: "my-comp",
8882                html,
8883                artifact_html: None,
8884                authored_html: None,
8885                css_content: None,
8886                artifact_needed: true,
8887            })
8888            .expect("light policy wrapper should compile");
8889        assert_eq!(light_built.ssr, "<div>hi</div>");
8890        assert_eq!(light_built.artifact(), "<div>hi</div>");
8891
8892        let mut implicit_shadow = HtmlParser::new();
8893        let shadow_built = implicit_shadow
8894            .build_component_templates(ComponentTemplateInput {
8895                tag_name: "my-comp",
8896                html,
8897                artifact_html: None,
8898                authored_html: None,
8899                css_content: None,
8900                artifact_needed: true,
8901            })
8902            .expect("default policy wrapper should compile as Shadow");
8903        assert_eq!(
8904            shadow_built.ssr,
8905            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8906        );
8907        assert!(!shadow_built.artifact().contains("w-hydrate"));
8908
8909        // An authored Shadow component keeps its wrapper; only the
8910        // compiler-owned policy attributes are stripped.
8911        let shadow_html =
8912            r#"<template shadowrootmode="open" w-hydrate="lazy"><div>hi</div></template>"#;
8913        let mut shadow = HtmlParser::new();
8914        let authored_shadow_built = shadow
8915            .build_component_templates(ComponentTemplateInput {
8916                tag_name: "my-comp",
8917                html: shadow_html,
8918                artifact_html: None,
8919                authored_html: None,
8920                css_content: None,
8921                artifact_needed: true,
8922            })
8923            .expect("shadow policy wrapper should compile");
8924        assert_eq!(
8925            authored_shadow_built.ssr,
8926            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8927        );
8928        assert!(!authored_shadow_built.artifact().contains("w-hydrate"));
8929    }
8930
8931    #[test]
8932    fn interaction_policy_marks_component_hosts_in_ssr_fragments() {
8933        let mut parser = HtmlParser::new();
8934        parser
8935            .component_registry
8936            .register_component(ComponentRegistration::new(
8937                "interaction-shell",
8938                r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
8939                None,
8940                true,
8941            ))
8942            .expect("component registration");
8943        parser
8944            .parse(
8945                "index.html",
8946                "<body><interaction-shell></interaction-shell></body>",
8947            )
8948            .expect("entry parse");
8949        let records = parser.into_fragment_records();
8950        let entry_html: String = records["index.html"]
8951            .fragments
8952            .iter()
8953            .filter_map(|fragment| match fragment.fragment.as_ref() {
8954                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8955                _ => None,
8956            })
8957            .collect();
8958        assert!(entry_html.contains("<interaction-shell data-webui-interaction"));
8959    }
8960
8961    #[test]
8962    fn combined_interaction_policy_marks_hosts_and_emits_render_css() {
8963        let mut parser = HtmlParser::new();
8964        parser
8965            .component_registry
8966            .register_component(ComponentRegistration::new(
8967                "interaction-panel",
8968                concat!(
8969                    r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
8970                    r#"w-hydrate="interaction"><button>Open</button></template>"#,
8971                ),
8972                None,
8973                true,
8974            ))
8975            .expect("component registration");
8976        parser
8977            .parse(
8978                "index.html",
8979                "<body><interaction-panel></interaction-panel></body>",
8980            )
8981            .expect("entry parse");
8982        assert!(parser
8983            .component_registry
8984            .render_policy_css(["interaction-panel"])
8985            .contains("content-visibility:auto"));
8986        let records = parser.into_fragment_records();
8987        let entry_html: String = records["index.html"]
8988            .fragments
8989            .iter()
8990            .filter_map(|fragment| match fragment.fragment.as_ref() {
8991                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8992                _ => None,
8993            })
8994            .collect();
8995        assert!(entry_html.contains("<interaction-panel data-webui-interaction"));
8996    }
8997
8998    // ── CSS-module adoption on dev-authored <template> wrappers ────────
8999    //
9000    // Regression guard: under `CssStrategy::Module`, the
9001    // `shadowrootadoptedstylesheets` attribute is the only wire between
9002    // the framework-emitted importmap/module-script and the shadow root.
9003    // When the developer supplies their own `<template>` wrapper (e.g.
9004    // to attach `@event` handlers), the framework preserves that wrapper
9005    // and appends the CSS-module adoption attribute when it is missing.
9006
9007    #[test]
9008    fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
9009        let mut parser = HtmlParser::new();
9010        let processed = parser
9011            .process_component_template(
9012                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
9013                None,
9014                Some("my-comp"),
9015            )
9016            .expect("process failed");
9017        assert!(
9018            processed.contains(r#"shadowrootmode="open""#),
9019            "dev-authored shadowrootmode must be preserved, got: {processed}"
9020        );
9021        assert!(
9022            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
9023            "module CSS should append adopted stylesheets, got: {processed}"
9024        );
9025    }
9026
9027    #[test]
9028    fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
9029        let mut parser = HtmlParser::new();
9030        let processed = parser
9031            .process_component_template(
9032                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
9033                None,
9034                Some("my-comp"),
9035            )
9036            .unwrap();
9037        assert!(
9038            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
9039            "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
9040        );
9041        assert_eq!(
9042            processed.matches("shadowrootadoptedstylesheets").count(),
9043            1,
9044            "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
9045        );
9046    }
9047
9048    #[test]
9049    fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
9050        for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
9051            let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
9052            let built = match parser.build_component_templates(ComponentTemplateInput {
9053                tag_name: "my-comp",
9054                html: r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
9055                artifact_html: None,
9056                authored_html: None,
9057                css_content: Some(":host { color: red; }"),
9058                artifact_needed: true,
9059            }) {
9060                Ok(built) => built,
9061                Err(err) => panic!(
9062                    "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
9063                ),
9064            };
9065            let artifact = built.artifact();
9066
9067            assert!(artifact.contains(r#"shadowrootmode="open""#));
9068            assert!(artifact.contains(r#"@click="{onClick()}""#));
9069            assert!(artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#));
9070            assert_eq!(artifact.matches("shadowrootadoptedstylesheets").count(), 1);
9071            assert!(!built.ssr.contains("@click"));
9072        }
9073    }
9074
9075    #[test]
9076    fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
9077        // Multi-specifier scenarios: the dev wants to adopt extra sheets
9078        // alongside their own component's module. Honored verbatim — the
9079        // framework's only job is to validate that *some*
9080        // `shadowrootadoptedstylesheets` is present.
9081        let mut parser = HtmlParser::new();
9082        let processed = parser
9083            .process_component_template(
9084                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
9085                None,
9086                Some("my-comp"),
9087            )
9088            .unwrap();
9089        assert!(
9090            processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
9091            "dev's multi-specifier list must be preserved verbatim, got: {processed}"
9092        );
9093    }
9094
9095    #[test]
9096    fn dev_template_no_adopted_specifier_does_not_validate() {
9097        // CssStrategy::Link or CssStrategy::Style pass `adopted_specifier=None`.
9098        // Dev's <template> must be preserved verbatim and the validation
9099        // must not fire.
9100        let mut parser = HtmlParser::new();
9101        let processed = parser
9102            .process_component_template(
9103                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
9104                None,
9105                None,
9106            )
9107            .unwrap();
9108        assert!(
9109            !processed.contains("shadowrootadoptedstylesheets"),
9110            "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
9111        );
9112    }
9113
9114    // test_signal_with_default_value — SKIPPED
9115    // The NodeJS `<f-signal value="testSignal">Default Text</f-signal>` feature
9116    // is not supported in the Rust parser. There is no `f-signal` element
9117    // handling in HtmlParser and no corresponding fragment type in
9118    // webui_protocol.
9119
9120    // test_estimated_buffer_size — SKIPPED
9121    // The NodeJS `estimatedBufferSize` field does not exist in the Rust
9122    // WebUIFragmentRecords / WebUIProtocol types. Buffer size estimation is
9123    // not part of the Rust parser output.
9124
9125    #[test]
9126    fn test_body_start_end_injection() {
9127        // Port of: 'should inject body_start and body_end signals around body content'
9128        // Verifies body_start appears immediately after <body> and body_end
9129        // appears immediately before </body> in a full HTML page.
9130        let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
9131        let (fragments, _) = parse_and_get_fragments(html);
9132
9133        assert_fragments!(
9134            fragments,
9135            [
9136                raw("<html><head>"),
9137                structural_matcher("head_start"),
9138                raw("<title>Test</title>"),
9139                structural_matcher("head_end"),
9140                raw("</head><body>"),
9141                structural_matcher("body_start"),
9142                raw("<div>Content</div><p>More</p>"),
9143                structural_matcher("body_end"),
9144                raw("</body></html>"),
9145            ]
9146        );
9147    }
9148
9149    #[test]
9150    fn test_body_preserves_static_attributes() {
9151        // The <body> tag's attributes must be carried through to the rendered
9152        // HTML, otherwise host frames (e.g. webui-press's `<body data-layout="…">`)
9153        // lose their styling hooks. Earlier versions of `process_body_with_signals`
9154        // emitted a bare `<body>` and dropped every attribute on the element.
9155        let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
9156        let (fragments, _) = parse_and_get_fragments(html);
9157
9158        assert_fragments!(
9159            fragments,
9160            [
9161                raw("<html><head>"),
9162                structural_matcher("head_start"),
9163                raw("<title>T</title>"),
9164                structural_matcher("head_end"),
9165                raw(r#"</head><body data-layout="doc" class="page">"#),
9166                structural_matcher("body_start"),
9167                raw("<p>x</p>"),
9168                structural_matcher("body_end"),
9169                raw("</body></html>"),
9170            ]
9171        );
9172    }
9173
9174    #[test]
9175    fn test_body_preserves_dynamic_attributes() {
9176        // Dynamic attributes on <body> (e.g. `data-layout="{{layout}}"`) must
9177        // emit attribute fragments rather than being inlined as literal text,
9178        // so the handler binds them to runtime state at render time.
9179        let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
9180        let (fragments, _) = parse_and_get_fragments(html);
9181
9182        assert_fragments!(
9183            fragments,
9184            [
9185                raw("<html><head>"),
9186                structural_matcher("head_start"),
9187                raw("<title>T</title>"),
9188                structural_matcher("head_end"),
9189                raw("</head><body"),
9190                attr("data-layout", "layout"),
9191                raw(">"),
9192                structural_matcher("body_start"),
9193                raw("<p>x</p>"),
9194                structural_matcher("body_end"),
9195                raw("</body></html>"),
9196            ]
9197        );
9198    }
9199
9200    #[test]
9201    fn test_head_start_signal_ordering_with_authored_script() {
9202        // `render_streaming` preflights on `head_start` before authored async
9203        // scripts in <head>, mirroring `body_start`. `head_start` must appear
9204        // immediately after the raw `<head>` opening fragment and before any
9205        // child content, including an authored `<script>`.
9206        let html = r#"<html><head><script async src="./analytics.js"></script></head><body></body></html>"#;
9207        let (fragments, _) = parse_and_get_fragments(html);
9208
9209        assert_fragments!(
9210            fragments,
9211            [
9212                raw("<html><head>"),
9213                structural_matcher("head_start"),
9214                raw(r#"<script async src="./analytics.js"></script>"#),
9215                structural_matcher("head_end"),
9216                raw("</head><body>"),
9217                structural_matcher("body_start"),
9218                structural_matcher("body_end"),
9219                raw("</body></html>"),
9220            ]
9221        );
9222    }
9223
9224    #[test]
9225    fn test_head_static_attributes_preserve_legacy_opening_bytes() {
9226        // Compatibility: origin/main emitted a literal `<head>` and ignored
9227        // authored head attributes. The structural signal must not alter that
9228        // ordinary protocol/output shape.
9229        let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
9230        let (fragments, _) = parse_and_get_fragments(html);
9231
9232        assert_fragments!(
9233            fragments,
9234            [
9235                raw("<html><head>"),
9236                structural_matcher("head_start"),
9237                raw("<title>T</title>"),
9238                structural_matcher("head_end"),
9239                raw("</head><body>"),
9240                structural_matcher("body_start"),
9241                structural_matcher("body_end"),
9242                raw("</body></html>"),
9243            ]
9244        );
9245    }
9246
9247    #[test]
9248    fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
9249        // Bound head attributes were also ignored by origin/main. In
9250        // particular, do not introduce an attribute fragment before
9251        // `head_start`, because ordinary rendering must remain byte-identical.
9252        let html =
9253            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
9254        let (fragments, _) = parse_and_get_fragments(html);
9255
9256        assert_fragments!(
9257            fragments,
9258            [
9259                raw("<html><head>"),
9260                structural_matcher("head_start"),
9261                raw("<title>T</title>"),
9262                structural_matcher("head_end"),
9263                raw("</head><body>"),
9264                structural_matcher("body_start"),
9265                structural_matcher("body_end"),
9266                raw("</body></html>"),
9267            ]
9268        );
9269    }
9270
9271    #[test]
9272    fn native_head_and_body_are_ascii_case_insensitive() {
9273        let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
9274        let (fragments, _) = parse_and_get_fragments(html);
9275
9276        assert_fragments!(
9277            fragments,
9278            [
9279                raw(r#"<html><HEAD data-theme="ignored">"#),
9280                structural_matcher("head_start"),
9281                raw("<title>T</title>"),
9282                structural_matcher("head_end"),
9283                raw("</HEAD><BODY"),
9284                attr("data-layout", "layout"),
9285                raw(">"),
9286                structural_matcher("body_start"),
9287                raw("<p>x</p>"),
9288                structural_matcher("body_end"),
9289                raw("</BODY></html>"),
9290            ]
9291        );
9292    }
9293
9294    // ── `<boundary>` / `<webui-hydrate>` tests (Progressive Streaming
9295    // Hydration) ───────────────────────────────────────────────────────────
9296
9297    #[test]
9298    fn entry_boundary_is_an_inline_start_end_tape() {
9299        let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
9300        let (fragments, records) = parse_and_get_fragments(html);
9301
9302        let boundary = match fragments[3].fragment.as_ref() {
9303            Some(Fragment::Boundary(boundary)) => boundary,
9304            other => panic!("expected typed boundary start, got {other:?}"),
9305        };
9306        assert_eq!(boundary.phase(), BoundaryPhase::Start);
9307        assert_eq!(boundary.declaration_id, 0);
9308        assert_eq!(boundary.owner_fragment_id, "index.html");
9309        assert_eq!(boundary.name, "counter-ready");
9310        assert_eq!(boundary.key, None);
9311        assert!(!boundary.may_repeat);
9312        // The body stays inline in the owner record, closed by an end marker,
9313        // so ordinary rendering never looks up a second record.
9314        assert_stream!(
9315            records,
9316            "index.html",
9317            [
9318                raw("<body>"),
9319                structural_matcher("body_start"),
9320                raw("<div>"),
9321                boundary_matcher("counter-ready", 0),
9322                raw("<my-counter></my-counter>"),
9323                boundary_end_matcher(0),
9324                raw("</div>"),
9325                structural_matcher("body_end"),
9326                raw("</body>"),
9327            ]
9328        );
9329        assert!(records["index.html"].contains_boundary);
9330        assert!(
9331            !records.contains_key("boundary-1"),
9332            "an inline tape must not create a body record"
9333        );
9334    }
9335
9336    #[test]
9337    fn boundary_names_are_scoped_per_owner_and_declaration_ids_are_protocol_wide() {
9338        let mut parser = HtmlParser::new();
9339        parser
9340            .parse(
9341                "first.html",
9342                r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
9343            )
9344            .expect("first entry parses");
9345        parser
9346            .parse(
9347                "second.html",
9348                r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
9349            )
9350            .expect("a second owner may reuse the name");
9351        let records = parser.into_fragment_records();
9352        let first = records["first.html"]
9353            .fragments
9354            .iter()
9355            .find_map(|fragment| match fragment.fragment.as_ref() {
9356                Some(Fragment::Boundary(boundary)) => Some(boundary),
9357                _ => None,
9358            })
9359            .expect("first boundary");
9360        let second = records["second.html"]
9361            .fragments
9362            .iter()
9363            .find_map(|fragment| match fragment.fragment.as_ref() {
9364                Some(Fragment::Boundary(boundary)) => Some(boundary),
9365                _ => None,
9366            })
9367            .expect("second boundary");
9368        assert_eq!((first.declaration_id, first.name.as_str()), (0, "a"));
9369        assert_eq!((second.declaration_id, second.name.as_str()), (1, "a"));
9370    }
9371
9372    #[test]
9373    fn boundary_missing_name_errors() {
9374        for html in [
9375            "<body><boundary><p>x</p></boundary></body>",
9376            r#"<body><boundary name=""><p>x</p></boundary></body>"#,
9377            r#"<body><boundary name="   "><p>x</p></boundary></body>"#,
9378        ] {
9379            let mut parser = HtmlParser::new();
9380            let err = parser
9381                .parse("index.html", html)
9382                .expect_err("a boundary without a non-empty name must error");
9383            let ParserError::Template(diag) = err else {
9384                panic!("expected ParserError::Template, got {err:?}");
9385            };
9386            assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
9387        }
9388    }
9389
9390    #[test]
9391    fn boundary_dynamic_name_errors() {
9392        let mut parser = HtmlParser::new();
9393        let err = parser
9394            .parse(
9395                "index.html",
9396                r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
9397            )
9398            .expect_err("a dynamic boundary name must error");
9399        let ParserError::Template(diag) = err else {
9400            panic!("expected ParserError::Template, got {err:?}");
9401        };
9402        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
9403    }
9404
9405    #[test]
9406    fn boundary_names_are_free_form_and_live_on_typed_fragments() {
9407        let mut parser = HtmlParser::new();
9408        let html = concat!(
9409            "<body>",
9410            r#"<boundary name="above the fold"><p>1</p></boundary>"#,
9411            r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
9412            r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
9413            "</body>",
9414        );
9415        parser
9416            .parse("index.html", html)
9417            .expect("free-form boundary names must be accepted");
9418        let records = parser.into_fragment_records();
9419        let names: Vec<&str> = records["index.html"]
9420            .fragments
9421            .iter()
9422            .filter_map(|fragment| match fragment.fragment.as_ref() {
9423                Some(Fragment::Boundary(boundary)) if boundary.phase() == BoundaryPhase::Start => {
9424                    Some(boundary.name.as_str())
9425                }
9426                _ => None,
9427            })
9428            .collect();
9429        assert_eq!(names, ["above the fold", "feed/items #2", "ダッシュボード"]);
9430    }
9431
9432    #[test]
9433    fn module_entry_srcs_records_only_preloadable_critical_entries() {
9434        let mut parser = HtmlParser::new();
9435        parser
9436            .component_registry
9437            .register_component(ComponentRegistration::new(
9438                "weather-island",
9439                r#"<script type="module" src="/nested-weather.js"></script>"#,
9440                None,
9441                true,
9442            ))
9443            .expect("register");
9444        let html = concat!(
9445            "<head>",
9446            // Recorded: a static, module-typed, non-boundary entry.
9447            r#"<script type="module" async src="/index.js"></script>"#,
9448            // Recorded: mixed case on the tag, and a padded/mixed-case `type`
9449            // value, still identify a module. (Attribute *names* are matched
9450            // case-sensitively here, as everywhere else in this parser.)
9451            r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
9452            // Skipped: classic scripts have no ES module graph to preload.
9453            r#"<script src="/legacy.js"></script>"#,
9454            // Skipped: no `src` means nothing to resolve.
9455            r#"<script type="module">import "./x.js";</script>"#,
9456            // Skipped: a per-request binding matches no build artifact.
9457            r#"<script type="module" src="/{{bundle}}.js"></script>"#,
9458            "</head>",
9459            "<body>",
9460            // Skipped: an island loader is deferred on purpose.
9461            r#"<boundary name="weather">"#,
9462            r#"<script type="module" async src="/weather-panel.js"></script>"#,
9463            "<weather-island></weather-island>",
9464            "</boundary>",
9465            // Recorded once: a bottom-of-body entry is still critical, and a
9466            // repeat of an already-seen src must not duplicate a hint.
9467            r#"<script type="module" src="/index.js"></script>"#,
9468            "</body>",
9469        );
9470        parser.parse("index.html", html).expect("parse");
9471
9472        assert_eq!(
9473            parser.module_entry_srcs(),
9474            ["/index.js", "/late.js"],
9475            "only static module entries outside boundaries are preloadable"
9476        );
9477    }
9478
9479    #[test]
9480    fn module_entry_srcs_reset_between_top_level_parses() {
9481        let mut parser = HtmlParser::new();
9482        parser
9483            .parse(
9484                "a.html",
9485                r#"<head><script type="module" src="/a.js"></script></head>"#,
9486            )
9487            .expect("parse a");
9488        assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
9489
9490        parser
9491            .parse(
9492                "b.html",
9493                r#"<head><script type="module" src="/b.js"></script></head>"#,
9494            )
9495            .expect("parse b");
9496        assert_eq!(
9497            parser.module_entry_srcs(),
9498            ["/b.js"],
9499            "each entry owns its own critical modules"
9500        );
9501    }
9502
9503    #[test]
9504    fn module_entry_srcs_preserve_low_priority_script_delivery() {
9505        let mut parser = HtmlParser::new();
9506        let html = concat!(
9507            "<head>",
9508            r#"<script type="module" async src="/streaming.js"></script>"#,
9509            r#"<script type="module" src="/low-head.js" fetchpriority="LOW"></script>"#,
9510            "</head><body>",
9511            r#"<script type="module" src="/application.js" fetchpriority="low"></script>"#,
9512            r#"<script type="module" src="/low-cased.js" FETCHPRIORITY="LoW"></script>"#,
9513            r#"<script type="module" src="/critical.js" fetchpriority="high"></script>"#,
9514            r#"<script type="module" src="/ordinary.js"></script>"#,
9515            "</body>",
9516        );
9517        parser.parse("index.html", html).expect("parse");
9518        assert_eq!(
9519            parser.module_entry_srcs(),
9520            ["/streaming.js", "/critical.js", "/ordinary.js"],
9521            "low-priority application chunks must not be pulled into head preloads"
9522        );
9523        let records = parser.into_fragment_records();
9524        let raw = records["index.html"]
9525            .fragments
9526            .iter()
9527            .filter_map(|fragment| match fragment.fragment.as_ref() {
9528                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
9529                _ => None,
9530            })
9531            .collect::<String>();
9532        assert!(
9533            raw.contains(r#"src="/application.js" fetchpriority="low""#),
9534            "excluding preloads must preserve the authored script"
9535        );
9536    }
9537
9538    #[test]
9539    fn boundary_duplicate_name_errors() {
9540        let mut parser = HtmlParser::new();
9541        let html = concat!(
9542            "<body>",
9543            r#"<boundary name="dup"><p>1</p></boundary>"#,
9544            r#"<boundary name="dup"><p>2</p></boundary>"#,
9545            "</body>",
9546        );
9547        let err = parser
9548            .parse("index.html", html)
9549            .expect_err("a duplicate boundary name must error");
9550        let ParserError::Template(diag) = err else {
9551            panic!("expected ParserError::Template, got {err:?}");
9552        };
9553        assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
9554    }
9555
9556    #[test]
9557    fn boundary_nested_boundary_errors() {
9558        let mut parser = HtmlParser::new();
9559        let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
9560        let err = parser
9561            .parse("index.html", html)
9562            .expect_err("a nested boundary must error");
9563        let ParserError::Template(diag) = err else {
9564            panic!("expected ParserError::Template, got {err:?}");
9565        };
9566        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
9567    }
9568
9569    #[test]
9570    fn boundary_transitively_nested_through_component_errors() {
9571        let mut parser = HtmlParser::new();
9572        parser
9573            .component_registry
9574            .register_component(ComponentRegistration::new(
9575                "inner-panel",
9576                r#"<boundary name="inner"><p>x</p></boundary>"#,
9577                None,
9578                true,
9579            ))
9580            .expect("register");
9581        let err = parser
9582            .parse(
9583                "index.html",
9584                r#"<body><boundary name="outer"><inner-panel></inner-panel></boundary></body>"#,
9585            )
9586            .expect_err("transitively nested boundaries must error");
9587        let ParserError::Template(diag) = err else {
9588            panic!("expected ParserError::Template, got {err:?}");
9589        };
9590        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
9591        assert!(diag
9592            .help_text()
9593            .is_some_and(|help| help.contains("runtime branches")));
9594    }
9595
9596    #[test]
9597    fn boundary_inside_if_preserves_false_branch_structure() {
9598        let mut parser = HtmlParser::new();
9599        let html =
9600            r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
9601        parser.parse("index.html", html).expect("parse");
9602        let records = parser.into_fragment_records();
9603        let if_fragment_id = records["index.html"]
9604            .fragments
9605            .iter()
9606            .find_map(|fragment| match fragment.fragment.as_ref() {
9607                Some(Fragment::IfCond(if_cond)) => Some(if_cond.fragment_id.as_str()),
9608                _ => None,
9609            })
9610            .expect("if reference");
9611        let boundary = records[if_fragment_id]
9612            .fragments
9613            .iter()
9614            .find_map(|fragment| match fragment.fragment.as_ref() {
9615                Some(Fragment::Boundary(boundary)) => Some(boundary),
9616                _ => None,
9617            })
9618            .expect("boundary remains behind the runtime if");
9619        assert_eq!(boundary.owner_fragment_id, "index.html");
9620        assert!(!boundary.may_repeat);
9621        assert!(records["index.html"].contains_boundary);
9622        assert!(records[if_fragment_id].contains_boundary);
9623        assert_stream!(
9624            records,
9625            if_fragment_id,
9626            [
9627                boundary_matcher("x", 0),
9628                raw("<p>x</p>"),
9629                boundary_end_matcher(0),
9630            ]
9631        );
9632    }
9633
9634    #[test]
9635    fn boundary_directly_inside_for_is_rejected() {
9636        let mut parser = HtmlParser::new();
9637        let err = parser
9638            .parse(
9639                "index.html",
9640                r#"<body><for each="item in items"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></for></body>"#,
9641            )
9642            .expect_err("a boundary inside a repeat must error");
9643        let ParserError::Template(diag) = err else {
9644            panic!("expected ParserError::Template, got {err:?}");
9645        };
9646        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9647        assert!(
9648            diag.help_text()
9649                .is_some_and(|help| help.contains("wrap the whole <for>")),
9650            "{diag}"
9651        );
9652    }
9653
9654    #[test]
9655    fn boundary_under_if_inside_for_is_rejected() {
9656        let mut parser = HtmlParser::new();
9657        let err = parser
9658            .parse(
9659                "index.html",
9660                r#"<body><for each="item in items"><if condition="item.ready"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></if></for></body>"#,
9661            )
9662            .expect_err("a boundary behind a runtime branch in a repeat must error");
9663        let ParserError::Template(diag) = err else {
9664            panic!("expected ParserError::Template, got {err:?}");
9665        };
9666        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9667    }
9668
9669    #[test]
9670    fn component_boundary_used_inside_for_is_rejected() {
9671        let mut parser = HtmlParser::new();
9672        parser
9673            .component_registry
9674            .register_component(ComponentRegistration::new(
9675                "row-item",
9676                r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9677                None,
9678                true,
9679            ))
9680            .expect("register");
9681        let err = parser
9682            .parse(
9683                "index.html",
9684                r#"<body><for each="item in items"><row-item></row-item></for></body>"#,
9685            )
9686            .expect_err("a component-local boundary reached from a repeat must error");
9687        let ParserError::Template(diag) = err else {
9688            panic!("expected ParserError::Template, got {err:?}");
9689        };
9690        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9691        // The diagnostic names both ends of the chain: the repeat that would
9692        // execute it and the template that declares it.
9693        assert!(diag.to_string().contains("row-ready"), "{diag}");
9694        assert_eq!(diag.component_name(), Some("index.html"));
9695        assert!(diag.to_string().contains("item in items"), "{diag}");
9696    }
9697
9698    #[test]
9699    fn transitive_repeat_boundary_diagnostic_uses_lowest_repeat_site() {
9700        for _ in 0..20 {
9701            let mut parser = HtmlParser::new();
9702            for (tag, template) in [
9703                (
9704                    "left-list",
9705                    r#"<for each="a in xs"><row-card></row-card></for>"#,
9706                ),
9707                (
9708                    "right-list",
9709                    r#"<for each="b in ys"><row-card></row-card></for>"#,
9710                ),
9711                (
9712                    "row-card",
9713                    r#"<boundary name="row-ready"><p>row</p></boundary>"#,
9714                ),
9715            ] {
9716                parser
9717                    .component_registry
9718                    .register_component(ComponentRegistration::new(tag, template, None, true))
9719                    .expect("register");
9720            }
9721            let err = parser
9722                .parse(
9723                    "index.html",
9724                    r#"<body><left-list></left-list><right-list></right-list></body>"#,
9725                )
9726                .expect_err("both repeats reach the same boundary declaration");
9727            let ParserError::Template(diag) = err else {
9728                panic!("expected ParserError::Template, got {err:?}");
9729            };
9730            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9731            assert_eq!(diag.component_name(), Some("left-list"));
9732            assert!(diag.to_string().contains("a in xs"), "{diag}");
9733        }
9734    }
9735
9736    #[test]
9737    fn route_boundary_reached_from_for_is_rejected() {
9738        let mut parser = HtmlParser::with_options(DomStrategy::Light);
9739        parser
9740            .component_registry
9741            .register_component(ComponentRegistration::new(
9742                "row-page",
9743                r#"<boundary name="row-ready"><p>row</p></boundary>"#,
9744                None,
9745                true,
9746            ))
9747            .expect("register");
9748        let err = parser
9749            .parse(
9750                "index.html",
9751                r#"<body><for each="item in items"><route path="/" component="row-page"></route></for></body>"#,
9752            )
9753            .expect_err("a route-mounted boundary inside a repeat must error");
9754        let ParserError::Template(diag) = err else {
9755            panic!("expected ParserError::Template, got {err:?}");
9756        };
9757        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9758    }
9759
9760    #[test]
9761    fn outlet_mounted_boundary_reached_from_for_is_rejected() {
9762        // The child route renders at the parent component's <outlet />, which
9763        // the repeat body reaches through <shell-page>. That edge is a runtime
9764        // mount with no lexical nesting, so only the mount analysis catches it.
9765        let mut parser = HtmlParser::with_options(DomStrategy::Light);
9766        for (tag, template) in [
9767            (
9768                "shell-page",
9769                r#"<div><for each="item in items"><outlet></outlet></for></div>"#,
9770            ),
9771            (
9772                "child-page",
9773                r#"<boundary name="child-ready"><p>child</p></boundary>"#,
9774            ),
9775        ] {
9776            parser
9777                .component_registry
9778                .register_component(ComponentRegistration::new(tag, template, None, true))
9779                .expect("register");
9780        }
9781        let err = parser
9782            .parse(
9783                "index.html",
9784                r#"<body><route path="/" component="shell-page"><route path="child" component="child-page"></route></route></body>"#,
9785            )
9786            .expect_err("an outlet-mounted boundary inside a repeat must error");
9787        let ParserError::Template(diag) = err else {
9788            panic!("expected ParserError::Template, got {err:?}");
9789        };
9790        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9791        assert!(diag.to_string().contains("child-ready"), "{diag}");
9792    }
9793
9794    #[test]
9795    fn for_inside_one_boundary_is_allowed_and_stays_atomic() {
9796        // The whole finite list is one atomic checkpoint, so the repeat body
9797        // record must not be marked as carrying a boundary of its own.
9798        let mut parser = HtmlParser::new();
9799        parser
9800            .parse(
9801                "index.html",
9802                r#"<body><boundary name="feed"><for each="item in items"><p>{{item.label}}</p></for></boundary></body>"#,
9803            )
9804            .expect("a repeat inside one boundary parses");
9805        let records = parser.into_fragment_records();
9806        assert!(records["index.html"].contains_boundary);
9807        assert!(
9808            !records["for-1"].contains_boundary,
9809            "the repeat body itself declares no boundary"
9810        );
9811        let boundary = records["index.html"]
9812            .fragments
9813            .iter()
9814            .find_map(|fragment| match fragment.fragment.as_ref() {
9815                Some(Fragment::Boundary(boundary)) => Some(boundary),
9816                _ => None,
9817            })
9818            .expect("boundary");
9819        assert!(!boundary.may_repeat);
9820    }
9821
9822    #[test]
9823    fn boundary_after_for_is_allowed() {
9824        let mut parser = HtmlParser::new();
9825        parser
9826            .parse(
9827                "index.html",
9828                r#"<body><for each="item in items"><p>{{item.label}}</p></for><boundary name="tail"><p>tail</p></boundary></body>"#,
9829            )
9830            .expect("a boundary following a repeat parses");
9831        let records = parser.into_fragment_records();
9832        assert!(records["index.html"].contains_boundary);
9833        assert!(!records["for-1"].contains_boundary);
9834    }
9835
9836    #[test]
9837    fn boundary_free_for_still_renders_without_boundary_metadata() {
9838        let mut parser = HtmlParser::new();
9839        parser
9840            .parse(
9841                "index.html",
9842                r#"<body><for each="item in items"><p>{{item.label}}</p></for></body>"#,
9843            )
9844            .expect("an ordinary repeat parses");
9845        let records = parser.into_fragment_records();
9846        assert!(!records["index.html"].contains_boundary);
9847        assert!(!records["for-1"].contains_boundary);
9848    }
9849
9850    #[test]
9851    fn empty_boundary_key_is_still_rejected() {
9852        let mut parser = HtmlParser::new();
9853        let err = parser
9854            .parse(
9855                "invalid.html",
9856                r#"<body><boundary name="x" key=""><p>x</p></boundary></body>"#,
9857            )
9858            .expect_err("an empty key must error");
9859        let ParserError::Template(diag) = err else {
9860            panic!("expected ParserError::Template, got {err:?}");
9861        };
9862        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_KEY));
9863    }
9864
9865    #[test]
9866    fn boundary_inside_reusable_component_is_component_local() {
9867        let mut parser = HtmlParser::new();
9868        parser
9869            .component_registry
9870            .register_component(ComponentRegistration::new(
9871                "my-widget",
9872                r#"<boundary name="x"><p>x</p></boundary>"#,
9873                None,
9874                true,
9875            ))
9876            .expect("register");
9877
9878        parser
9879            .parse(
9880                "index.html",
9881                r#"<body><my-widget></my-widget><boundary name="x"><p>entry</p></boundary></body>"#,
9882            )
9883            .expect("component-local boundary parses");
9884        let records = parser.into_fragment_records();
9885        let boundary = records["my-widget"]
9886            .fragments
9887            .iter()
9888            .find_map(|fragment| match fragment.fragment.as_ref() {
9889                Some(Fragment::Boundary(boundary)) => Some(boundary),
9890                _ => None,
9891            })
9892            .expect("component boundary");
9893        assert_eq!(boundary.owner_fragment_id, "my-widget");
9894        assert_eq!(boundary.name, "x");
9895        assert!(records["my-widget"].contains_boundary);
9896        assert!(records["index.html"].contains_boundary);
9897        let owners: HashSet<&str> = records
9898            .values()
9899            .flat_map(|list| list.fragments.iter())
9900            .filter_map(|fragment| match fragment.fragment.as_ref() {
9901                Some(Fragment::Boundary(boundary)) if boundary.name == "x" => {
9902                    Some(boundary.owner_fragment_id.as_str())
9903                }
9904                _ => None,
9905            })
9906            .collect();
9907        assert_eq!(owners, HashSet::from(["index.html", "my-widget"]));
9908    }
9909
9910    #[test]
9911    fn component_boundary_repeated_from_static_callsites_requires_a_key() {
9912        // `<for>` can no longer produce multiple occurrences, but two static
9913        // callsites of the same boundary-bearing component still can, so the
9914        // declaration keeps its key requirement.
9915        let mut parser = HtmlParser::new();
9916        parser
9917            .component_registry
9918            .register_component(ComponentRegistration::new(
9919                "row-item",
9920                r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9921                None,
9922                true,
9923            ))
9924            .expect("register");
9925        let err = parser
9926            .parse(
9927                "index.html",
9928                r#"<body><row-item></row-item><row-item></row-item></body>"#,
9929            )
9930            .expect_err("a repeated declaration without a key must error");
9931        let ParserError::Template(diag) = err else {
9932            panic!("expected ParserError::Template, got {err:?}");
9933        };
9934        assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_KEY));
9935        assert_eq!(diag.component_name(), Some("row-item"));
9936
9937        let mut parser = HtmlParser::new();
9938        parser
9939            .component_registry
9940            .register_component(ComponentRegistration::new(
9941                "row-item",
9942                r#"<boundary name="row-ready" key="{{rowId}}"><span>{{label}}</span></boundary>"#,
9943                None,
9944                true,
9945            ))
9946            .expect("register");
9947        parser
9948            .parse(
9949                "index.html",
9950                r#"<body><row-item row-id="a"></row-item><row-item row-id="b"></row-item></body>"#,
9951            )
9952            .expect("a keyed repeated declaration parses");
9953        let records = parser.into_fragment_records();
9954        let boundary = records["row-item"]
9955            .fragments
9956            .iter()
9957            .find_map(|fragment| match fragment.fragment.as_ref() {
9958                Some(Fragment::Boundary(boundary)) => Some(boundary),
9959                _ => None,
9960            })
9961            .expect("component boundary");
9962        assert!(boundary.may_repeat);
9963        assert_eq!(boundary.key.as_deref(), Some("{{rowId}}"));
9964    }
9965
9966    #[test]
9967    fn component_boundary_once_per_independent_entry_is_not_repeatable() {
9968        let mut parser = HtmlParser::new();
9969        parser
9970            .component_registry
9971            .register_component(ComponentRegistration::new(
9972                "shared-panel",
9973                r#"<boundary name="ready"><span>x</span></boundary>"#,
9974                None,
9975                true,
9976            ))
9977            .expect("register");
9978        for entry in ["first.html", "second.html"] {
9979            parser
9980                .parse(entry, "<body><shared-panel></shared-panel></body>")
9981                .expect("entry parses");
9982        }
9983        let records = parser.into_fragment_records();
9984        let boundary = records["shared-panel"]
9985            .fragments
9986            .iter()
9987            .find_map(|fragment| match fragment.fragment.as_ref() {
9988                Some(Fragment::Boundary(boundary)) => Some(boundary),
9989                _ => None,
9990            })
9991            .expect("component boundary");
9992        assert!(!boundary.may_repeat);
9993    }
9994
9995    #[test]
9996    fn boundary_inside_component_host_content_errors() {
9997        let mut parser = HtmlParser::new();
9998        parser
9999            .component_registry
10000            .register_component(ComponentRegistration::new(
10001                "my-widget",
10002                "<div>content</div>",
10003                None,
10004                true,
10005            ))
10006            .expect("register");
10007
10008        let err = parser
10009            .parse(
10010                "index.html",
10011                r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
10012            )
10013            .expect_err("a boundary inside component host content must error");
10014        let ParserError::Template(diag) = err else {
10015            panic!("expected ParserError::Template, got {err:?}");
10016        };
10017        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
10018        assert!(
10019            diag.to_string().contains("component host content"),
10020            "{diag}"
10021        );
10022    }
10023
10024    #[test]
10025    fn boundary_inside_raw_or_inert_html_context_errors() {
10026        for (element, expected_scope) in [
10027            ("textarea", "<textarea> text content"),
10028            ("title", "<title> text content"),
10029            ("script", "<script> raw-text content"),
10030            ("xmp", "<xmp> raw-text content"),
10031            ("iframe", "<iframe> raw-text content"),
10032            ("noembed", "<noembed> raw-text content"),
10033            ("noframes", "<noframes> raw-text content"),
10034            ("noscript", "<noscript> inert content"),
10035            ("plaintext", "<plaintext> text content"),
10036            ("template", "<template> inert content"),
10037        ] {
10038            let mut parser = HtmlParser::new();
10039            let html = format!(
10040                r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
10041            );
10042            let err = parser
10043                .parse("index.html", &html)
10044                .expect_err("a boundary in raw or inert content must error");
10045            let ParserError::Template(diag) = err else {
10046                panic!("expected ParserError::Template, got {err:?}");
10047            };
10048            assert_eq!(
10049                diag.error_code(),
10050                Some(codes::BOUNDARY_CROSSES_SCOPE),
10051                "unexpected code for <{element}>"
10052            );
10053            assert!(
10054                diag.to_string().contains(expected_scope),
10055                "unexpected scope for <{element}>: {diag}"
10056            );
10057        }
10058    }
10059
10060    #[test]
10061    fn route_contained_boundary_has_runtime_content_fragment() {
10062        let mut parser = HtmlParser::with_options(DomStrategy::Light);
10063        parser
10064            .component_registry
10065            .register_component(ComponentRegistration::new(
10066                "home-page",
10067                "<h1>Home</h1>",
10068                None,
10069                true,
10070            ))
10071            .expect("register");
10072        parser
10073            .parse(
10074                "index.html",
10075                r#"<route path="/" component="home-page"><boundary name="route-ready"><aside>Ready</aside></boundary></route>"#,
10076            )
10077            .expect("route-contained boundary parses");
10078        let records = parser.into_fragment_records();
10079        assert!(records["index.html"].contains_boundary);
10080        let route = records["index.html"]
10081            .fragments
10082            .iter()
10083            .find_map(|fragment| match fragment.fragment.as_ref() {
10084                Some(Fragment::Route(route)) => Some(route),
10085                _ => None,
10086            })
10087            .expect("route");
10088        assert!(!route.content_fragment_id.is_empty());
10089        let boundary = records[&route.content_fragment_id]
10090            .fragments
10091            .iter()
10092            .find_map(|fragment| match fragment.fragment.as_ref() {
10093                Some(Fragment::Boundary(boundary)) => Some(boundary),
10094                _ => None,
10095            })
10096            .expect("route boundary");
10097        assert_eq!(boundary.owner_fragment_id, "index.html");
10098        assert_eq!(boundary.name, "route-ready");
10099        assert_stream!(
10100            records,
10101            route.content_fragment_id.as_str(),
10102            [
10103                boundary_matcher("route-ready", 0),
10104                raw("<aside>Ready</aside>"),
10105                boundary_end_matcher(0),
10106            ]
10107        );
10108    }
10109
10110    #[test]
10111    fn route_boundary_nested_in_ignored_markup_errors() {
10112        let mut parser = HtmlParser::new();
10113        let err = parser
10114            .parse(
10115                "index.html",
10116                r#"<route path="/" component="home-page"><div><boundary name="late"><p>x</p></boundary></div></route>"#,
10117            )
10118            .expect_err("route boundaries must be direct children");
10119        let ParserError::Template(diag) = err else {
10120            panic!("expected ParserError::Template, got {err:?}");
10121        };
10122        assert_eq!(
10123            diag.error_code(),
10124            Some(codes::INVALID_ROUTE_BOUNDARY_PLACEMENT)
10125        );
10126    }
10127
10128    #[test]
10129    fn boundary_must_be_inside_an_open_body() {
10130        for html in [
10131            r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
10132            r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
10133        ] {
10134            let mut parser = HtmlParser::new();
10135            let err = parser
10136                .parse("index.html", html)
10137                .expect_err("a boundary outside body must error");
10138            let ParserError::Template(diag) = err else {
10139                panic!("expected ParserError::Template, got {err:?}");
10140            };
10141            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
10142            assert_eq!(
10143                diag.help_text(),
10144                Some("move the <boundary> between the opening <body> and its matching </body>")
10145            );
10146        }
10147
10148        let mut parser = HtmlParser::new();
10149        parser
10150            .parse(
10151                "index.html",
10152                r#"<BODY><boundary name="inside"></boundary></BODY>"#,
10153            )
10154            .expect("mixed-case native body keeps the boundary inside an open body");
10155    }
10156
10157    #[test]
10158    fn boundary_inside_foster_parenting_context_errors() {
10159        // Each of these insertion modes moves an unknown element (the
10160        // generated <webui-hydrate> sentinel) out of the subtree, which would
10161        // split it from its payload script and halt hydration at runtime.
10162        for html in [
10163            r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
10164            r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
10165            r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
10166            r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
10167            r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
10168            r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
10169            r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
10170            r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
10171            // Mixed case must be rejected identically: HTML tag names are
10172            // case-insensitive, so the browser foster-parents these too.
10173            r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
10174        ] {
10175            let mut parser = HtmlParser::new();
10176            let err = parser
10177                .parse("index.html", html)
10178                .expect_err("a boundary in a foster-parenting context must error");
10179            let ParserError::Template(diag) = err else {
10180                panic!("expected ParserError::Template, got {err:?}");
10181            };
10182            assert_eq!(
10183                diag.error_code(),
10184                Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
10185                "unexpected code for {html}"
10186            );
10187            assert!(
10188                diag.help_text()
10189                    .is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
10190                "help should suggest a safe placement for {html}"
10191            );
10192        }
10193    }
10194
10195    #[test]
10196    fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
10197        // <td>/<th>/<caption> switch back to "in body" rules, so an unknown
10198        // element is *not* foster-parented and the boundary is safe even
10199        // though <table>/<tr> ancestors are hostile.
10200        for html in [
10201            r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
10202            r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
10203            r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
10204            // Wrapping the whole table is the other documented fix.
10205            r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
10206            // A <td> barrier resets the depth, an inner table raises it again,
10207            // and closing that inner table must restore the barrier's zero —
10208            // not the outer table's non-zero depth.
10209            concat!(
10210                "<body><table><tbody><tr><td>",
10211                "<table><tbody><tr><td>inner</td></tr></tbody></table>",
10212                r#"<boundary name="after-inner"><p>x</p></boundary>"#,
10213                "</td></tr></tbody></table></body>",
10214            ),
10215            // Void and self-closing elements must not leak restore ops.
10216            r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
10217        ] {
10218            let mut parser = HtmlParser::new();
10219            parser
10220                .parse("index.html", html)
10221                .unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
10222        }
10223    }
10224
10225    #[test]
10226    fn foster_context_depth_is_restored_after_leaving_a_table() {
10227        // Regression guard for the save/restore op: a boundary *after* a table
10228        // must not inherit the table's hostile depth.
10229        let mut parser = HtmlParser::new();
10230        parser
10231            .parse(
10232                "index.html",
10233                concat!(
10234                    "<body>",
10235                    "<table><tbody><tr><td>x</td></tr></tbody></table>",
10236                    r#"<boundary name="after-table"><p>y</p></boundary>"#,
10237                    "</body>",
10238                ),
10239            )
10240            .expect("a boundary after a closed table must be accepted");
10241    }
10242
10243    #[test]
10244    fn authored_raw_bindings_cannot_forge_structural_signals() {
10245        let html = concat!(
10246            "<html><head></head><body>",
10247            "{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
10248            "{{{boundary_start:0}}}{{{boundary_end:0}}}",
10249            "{{{streaming_root:forged}}}",
10250            "</body></html>",
10251        );
10252        let (fragments, _) = parse_and_get_fragments(html);
10253        let raw_signals: Vec<&str> = fragments
10254            .iter()
10255            .filter_map(|fragment| match fragment.fragment.as_ref() {
10256                Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
10257                _ => None,
10258            })
10259            .collect();
10260
10261        assert_eq!(
10262            raw_signals,
10263            [
10264                "}}}webui:head_start",
10265                "}}}webui:head_end",
10266                "}}}webui:body_start",
10267                "head_start",
10268                "head_end",
10269                "body_start",
10270                "body_end",
10271                "boundary_start:0",
10272                "boundary_end:0",
10273                "streaming_root:forged",
10274                "}}}webui:body_end",
10275            ]
10276        );
10277    }
10278
10279    #[test]
10280    fn authored_webui_hydrate_errors_at_top_level() {
10281        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
10282            let mut parser = HtmlParser::new();
10283            let html = format!("<div><{tag}></{tag}></div>");
10284            let err = parser
10285                .parse("index.html", &html)
10286                .expect_err("an authored <webui-hydrate> must error in any ASCII casing");
10287            let ParserError::Template(diag) = err else {
10288                panic!("expected ParserError::Template, got {err:?}");
10289            };
10290            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
10291        }
10292    }
10293
10294    #[test]
10295    fn authored_webui_hydrate_errors_inside_route() {
10296        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
10297            let mut parser = HtmlParser::new();
10298            let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
10299            let err = parser
10300                .parse("index.html", &html)
10301                .expect_err("an authored <webui-hydrate> inside <route> must error");
10302            let ParserError::Template(diag) = err else {
10303                panic!("expected ParserError::Template, got {err:?}");
10304            };
10305            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
10306        }
10307    }
10308
10309    #[test]
10310    fn boundary_directive_remains_case_sensitive() {
10311        let (fragments, _) =
10312            parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
10313
10314        assert_fragments!(
10315            fragments,
10316            [raw(
10317                r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
10318            ),]
10319        );
10320    }
10321
10322    #[test]
10323    fn templates_without_webui_boundary_are_unaffected() {
10324        // Regression: ordinary templates with no `<boundary>` directive
10325        // must parse exactly as before — no boundary signals, no behavior
10326        // change for the non-streaming path.
10327        let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
10328        let (fragments, _) = parse_and_get_fragments(html);
10329
10330        assert_fragments!(
10331            fragments,
10332            [
10333                raw("<div><my-counter"),
10334                attr("count", "count"),
10335                raw("></my-counter></div>"),
10336            ]
10337        );
10338    }
10339
10340    #[test]
10341    fn test_fail_with_invalid_markup() {
10342        // Port of: 'should fail with invalid markup'
10343        let mut parser = HtmlParser::new();
10344        let result = parser.parse("index.html", "<div><span></div></span>");
10345
10346        assert!(matches!(result, Err(ParserError::Template(diag)) if
10347            diag.to_string().contains("unclosed <span> tag")
10348        ));
10349    }
10350
10351    #[test]
10352    fn test_complex_raw_text_page() {
10353        // Port of: 'should process a complex raw text page with DOCTYPE,
10354        // meta tags, styles, and scripts'
10355        let html = concat!(
10356            "<!DOCTYPE html>",
10357            "<html lang=\"en\">",
10358            "<head>",
10359            "<meta charset=\"utf-8\">",
10360            "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
10361            "<title>Complex Page</title>",
10362            "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
10363            "<link rel=\"stylesheet\" href=\"styles.css\">",
10364            "</head>",
10365            "<body>",
10366            "<h1>Hello World</h1>",
10367            "<script type=\"module\" src=\"./app.js\"></script>",
10368            "</body>",
10369            "</html>",
10370        );
10371        let (fragments, _) = parse_and_get_fragments(html);
10372
10373        // Should have: raw(DOCTYPE+<head>), head_start, raw(head content),
10374        // head_end, raw(</head><body>), body_start, raw(body content),
10375        // body_end, raw(</body></html>)
10376        assert!(
10377            fragments.len() >= 9,
10378            "Expected at least 9 fragments, got {}",
10379            fragments.len()
10380        );
10381
10382        // First fragment: DOCTYPE through the opening <head> tag
10383        assert!(
10384            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10385                raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
10386            "First fragment should contain DOCTYPE and end with <head>, got: {:?}",
10387            fragments[0]
10388        );
10389
10390        // head_start signal
10391        assert!(
10392            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
10393                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
10394            "Second fragment should be head_start signal"
10395        );
10396
10397        // Head content (meta, title, style, link)
10398        assert!(
10399            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10400                raw.value.contains("<meta charset=\"utf-8\">") &&
10401                raw.value.contains("<meta name=\"viewport\"") &&
10402                raw.value.contains("<title>Complex Page</title>") &&
10403                raw.value.contains("<style>") &&
10404                raw.value.contains("body { margin: 0; padding: 0; }")),
10405            "Third fragment should contain all head content, got: {:?}",
10406            fragments[2]
10407        );
10408
10409        // head_end signal
10410        assert!(
10411            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
10412                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
10413            "Fourth fragment should be head_end signal"
10414        );
10415
10416        // </head><body>
10417        assert!(
10418            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10419                raw.value.contains("</head>") && raw.value.ends_with("<body>")),
10420            "Fifth fragment should contain </head><body>"
10421        );
10422
10423        // body_start signal
10424        assert!(
10425            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
10426                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
10427            "Sixth fragment should be body_start signal"
10428        );
10429
10430        // Body content (h1 and script)
10431        assert!(
10432            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10433                raw.value.contains("<h1>Hello World</h1>") &&
10434                raw.value.contains("<script")),
10435            "Seventh fragment should contain body content"
10436        );
10437
10438        // body_end signal
10439        assert!(
10440            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
10441                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
10442            "Eighth fragment should be body_end signal"
10443        );
10444
10445        // Closing tags
10446        assert!(
10447            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10448                raw.value.contains("</body>") && raw.value.contains("</html>")),
10449            "Ninth fragment should contain closing tags"
10450        );
10451    }
10452
10453    // --- Binding count tests (with mock plugin) ---
10454
10455    /// Mock plugin that records the binding attribute count for each element.
10456    struct BindingCountPlugin {
10457        counts: Vec<u32>,
10458    }
10459
10460    impl BindingCountPlugin {
10461        fn new() -> Self {
10462            Self { counts: Vec::new() }
10463        }
10464    }
10465
10466    impl crate::plugin::ParserPlugin for BindingCountPlugin {
10467        fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
10468            let attr_name = context.name;
10469            if attr_name.starts_with('@') || attr_name == "f-ref" {
10470                AttributeAction::SkipAndCountBinding
10471            } else {
10472                AttributeAction::Keep
10473            }
10474        }
10475
10476        fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
10477            let binding_attribute_count = context.binding_count;
10478            self.counts.push(binding_attribute_count);
10479            if binding_attribute_count > 0 {
10480                Some(binding_attribute_count.to_le_bytes().to_vec())
10481            } else {
10482                None
10483            }
10484        }
10485    }
10486
10487    struct TemplateCapturePlugin {
10488        template: Option<String>,
10489        uses_shadow_dom: bool,
10490    }
10491
10492    impl TemplateCapturePlugin {
10493        fn new() -> Self {
10494            Self {
10495                template: None,
10496                uses_shadow_dom: false,
10497            }
10498        }
10499    }
10500
10501    impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
10502        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
10503            self.template = Some(context.template.to_string());
10504            self.uses_shadow_dom = context.uses_shadow_dom;
10505            Ok(())
10506        }
10507
10508        fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
10509            let attr_name = context.name;
10510            if attr_name.starts_with('@') || attr_name == "f-ref" {
10511                AttributeAction::SkipAndCountBinding
10512            } else {
10513                AttributeAction::Keep
10514            }
10515        }
10516
10517        fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
10518            match self.template {
10519                Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
10520                    crate::plugin::ComponentTemplateArtifact::template(
10521                        "todo-app".to_string(),
10522                        template,
10523                        self.uses_shadow_dom,
10524                    ),
10525                ])),
10526                None => Ok(ParserPluginArtifacts::None),
10527            }
10528        }
10529    }
10530
10531    #[test]
10532    fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
10533        let mut parser = HtmlParser::with_plugin_options(
10534            Box::new(TemplateCapturePlugin::new()),
10535            CssStrategy::Link,
10536        );
10537        parser
10538            .component_registry_mut()
10539            .register_component(ComponentRegistration::new(
10540                "todo-app",
10541                r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
10542                Some(":host { display: block; }"),
10543             true,))
10544            .expect("register todo-app");
10545
10546        parser
10547            .parse("index.html", "<todo-app></todo-app>")
10548            .expect("parse failed");
10549
10550        let artifacts = parser.take_plugin_artifacts().expect("artifacts");
10551        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10552            panic!("expected captured component template");
10553        };
10554        let template = &templates[0].template;
10555
10556        assert!(
10557            template.contains(r#"shadowrootmode="open""#),
10558            "parser must pass dev-authored root template attrs to plugins, got: {template}"
10559        );
10560        assert!(
10561            template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
10562            "parser must pass dev-authored root event attrs to plugins, got: {template}"
10563        );
10564        assert!(
10565            template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
10566            "parser must pass dev-authored root event attrs to plugins, got: {template}"
10567        );
10568        assert!(
10569            template.contains(r#"f-ref="{root}""#),
10570            "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
10571        );
10572        assert!(
10573            !template.contains("shadowrootadoptedstylesheets"),
10574            "link CSS must not add CSS module adoption attrs, got: {template}"
10575        );
10576    }
10577
10578    #[test]
10579    fn test_component_static_attrs_not_counted_as_bindings() {
10580        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10581        parser
10582            .component_registry
10583            .register_component(ComponentRegistration::new(
10584                "my-btn",
10585                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10586                None,
10587                true,
10588            ))
10589            .expect("register");
10590
10591        // All attributes are static — binding count should be 0
10592        let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
10593        let result = parser.parse("index.html", html);
10594        assert!(result.is_ok());
10595
10596        let records = parser.into_fragment_records();
10597
10598        // No Plugin fragment should appear (binding count = 0)
10599        let frags = &records["index.html"].fragments;
10600        let plugin_count = frags
10601            .iter()
10602            .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
10603            .count();
10604        assert_eq!(
10605            plugin_count, 0,
10606            "Static-only component attributes should not emit a Plugin fragment"
10607        );
10608    }
10609
10610    #[test]
10611    fn test_component_dynamic_attr_counted_as_binding() {
10612        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10613        parser
10614            .component_registry
10615            .register_component(ComponentRegistration::new(
10616                "my-btn",
10617                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10618                None,
10619                true,
10620            ))
10621            .expect("register");
10622
10623        // One dynamic attribute ({{...}}) — binding count should be 1
10624        let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
10625        let result = parser.parse("index.html", html);
10626        assert!(result.is_ok());
10627
10628        let records = parser.into_fragment_records();
10629        let frags = &records["index.html"].fragments;
10630
10631        // Exactly one Plugin fragment with count = 1
10632        let plugin_frags: Vec<_> = frags
10633            .iter()
10634            .filter_map(|f| match f.fragment.as_ref() {
10635                Some(Fragment::Plugin(p)) => Some(&p.data),
10636                _ => None,
10637            })
10638            .collect();
10639        assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
10640        let count = u32::from_le_bytes([
10641            plugin_frags[0][0],
10642            plugin_frags[0][1],
10643            plugin_frags[0][2],
10644            plugin_frags[0][3],
10645        ]);
10646        assert_eq!(count, 1, "Binding attribute count should be 1");
10647    }
10648
10649    #[test]
10650    fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
10651        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10652        parser
10653            .component_registry
10654            .register_component(ComponentRegistration::new(
10655                "my-btn",
10656                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10657                None,
10658                true,
10659            ))
10660            .expect("register");
10661
10662        // 2 static, 1 dynamic, 1 skipped-with-plugin (@click) — only dynamic + skipped counted
10663        let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
10664        let result = parser.parse("index.html", html);
10665        assert!(result.is_ok());
10666
10667        let records = parser.into_fragment_records();
10668        let frags = &records["index.html"].fragments;
10669
10670        let plugin_frags: Vec<_> = frags
10671            .iter()
10672            .filter_map(|f| match f.fragment.as_ref() {
10673                Some(Fragment::Plugin(p)) => Some(&p.data),
10674                _ => None,
10675            })
10676            .collect();
10677        assert_eq!(plugin_frags.len(), 1);
10678        let count = u32::from_le_bytes([
10679            plugin_frags[0][0],
10680            plugin_frags[0][1],
10681            plugin_frags[0][2],
10682            plugin_frags[0][3],
10683        ]);
10684        // 1 dynamic (appearance) + 1 skipped-but-counted (@click) = 2
10685        assert_eq!(
10686            count, 2,
10687            "Binding count should include dynamic + plugin-skipped attrs, not static"
10688        );
10689    }
10690
10691    #[test]
10692    fn test_component_only_skipped_plugin_attrs_counted() {
10693        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10694        parser
10695            .component_registry
10696            .register_component(ComponentRegistration::new(
10697                "my-btn",
10698                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10699                None,
10700                true,
10701            ))
10702            .expect("register");
10703
10704        // Only plugin-skipped attrs (@click, f-ref) plus static — only skipped counted
10705        let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
10706        let result = parser.parse("index.html", html);
10707        assert!(result.is_ok());
10708
10709        let records = parser.into_fragment_records();
10710        let frags = &records["index.html"].fragments;
10711
10712        let plugin_frags: Vec<_> = frags
10713            .iter()
10714            .filter_map(|f| match f.fragment.as_ref() {
10715                Some(Fragment::Plugin(p)) => Some(&p.data),
10716                _ => None,
10717            })
10718            .collect();
10719        assert_eq!(plugin_frags.len(), 1);
10720        let count = u32::from_le_bytes([
10721            plugin_frags[0][0],
10722            plugin_frags[0][1],
10723            plugin_frags[0][2],
10724            plugin_frags[0][3],
10725        ]);
10726        // @click + f-ref = 2, title is static = not counted
10727        assert_eq!(
10728            count, 2,
10729            "Only plugin-skipped attrs should be counted, not static title"
10730        );
10731    }
10732
10733    // ── Comment stripping tests ──────────────────────────────────────
10734
10735    #[test]
10736    fn test_comment_handlebars_signal_is_stripped() {
10737        let mut parser = HtmlParser::new();
10738        let html = "<!--{{tokens}}-->";
10739        parser.parse("test.html", html).expect("parse failed");
10740        let records = parser.into_fragment_records();
10741
10742        assert_stream!(records, "test.html", []);
10743    }
10744
10745    #[test]
10746    fn test_comment_triple_brace_raw_signal_is_stripped() {
10747        let mut parser = HtmlParser::new();
10748        let html = "<!--{{{tokens}}}-->";
10749        parser.parse("test.html", html).expect("parse failed");
10750        let records = parser.into_fragment_records();
10751
10752        assert_stream!(records, "test.html", []);
10753    }
10754
10755    #[test]
10756    fn test_comment_regular_stripped() {
10757        let mut parser = HtmlParser::new();
10758        let html = "<!-- regular comment -->";
10759        parser.parse("test.html", html).expect("parse failed");
10760        let records = parser.into_fragment_records();
10761
10762        assert_stream!(records, "test.html", []);
10763    }
10764
10765    #[test]
10766    fn test_comment_dotted_signal_is_stripped() {
10767        let mut parser = HtmlParser::new();
10768        let html = "<!--{{tokens.light}}-->";
10769        parser.parse("test.html", html).expect("parse failed");
10770        let records = parser.into_fragment_records();
10771
10772        assert_stream!(records, "test.html", []);
10773    }
10774
10775    #[test]
10776    fn test_comment_arbitrary_identifier_is_stripped() {
10777        let mut parser = HtmlParser::new();
10778        let html = "<!--{{someOtherBinding}}-->";
10779        parser.parse("test.html", html).expect("parse failed");
10780        let records = parser.into_fragment_records();
10781
10782        assert_stream!(records, "test.html", []);
10783    }
10784
10785    #[test]
10786    fn test_comment_with_surrounding_content_is_stripped() {
10787        let mut parser = HtmlParser::new();
10788        let html = "<div><!--{{tokens}}--></div>";
10789        parser.parse("test.html", html).expect("parse failed");
10790        let records = parser.into_fragment_records();
10791
10792        assert_stream!(records, "test.html", [raw("<div></div>")]);
10793    }
10794
10795    #[test]
10796    fn test_component_template_unterminated_comment_returns_error() {
10797        let mut parser = HtmlParser::new();
10798        parser
10799            .component_registry_mut()
10800            .register_component(ComponentRegistration::new(
10801                "x-bad",
10802                "<span>ok</span><!-- missing close",
10803                None,
10804                true,
10805            ))
10806            .expect("register failed");
10807
10808        let result = parser.parse("index.html", "<x-bad></x-bad>");
10809
10810        assert!(matches!(result, Err(ParserError::Template(diag)) if
10811            diag.to_string().contains("unterminated HTML comment")
10812        ));
10813    }
10814
10815    #[test]
10816    fn test_webui_plugin_strips_component_comments_before_metadata() {
10817        let mut parser =
10818            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10819        parser
10820            .component_registry_mut()
10821            .register_component(ComponentRegistration::new(
10822                "x-bleed",
10823                r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
10824                None,
10825                true,
10826            ))
10827            .expect("register failed");
10828
10829        parser
10830            .parse("index.html", "<x-bleed></x-bleed>")
10831            .expect("parse failed");
10832
10833        let artifacts = parser.take_plugin_artifacts().unwrap();
10834        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10835            panic!("expected component templates");
10836        };
10837        let template = &templates[0].template_json;
10838        assert!(template.contains("<div>hello</div>"));
10839        assert!(!template.contains("{{path}}"));
10840        assert!(!template.contains("@click"));
10841        assert!(!template.contains("?hidden"));
10842        assert!(!template.contains("-->"));
10843    }
10844
10845    #[test]
10846    fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
10847        let mut parser =
10848            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10849        parser
10850            .component_registry_mut()
10851            .register_component(ComponentRegistration::new(
10852                "x-style",
10853                "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
10854                None,
10855                true,
10856            ))
10857            .expect("register failed");
10858
10859        parser
10860            .parse("index.html", "<x-style></x-style>")
10861            .expect("parse failed");
10862
10863        let artifacts = parser.take_plugin_artifacts().unwrap();
10864        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10865            panic!("expected component templates");
10866        };
10867        let template = &templates[0].template_json;
10868        assert!(template.contains(".x { color: red; }"));
10869        assert!(!template.contains("ignored"));
10870        assert!(!template.contains("//"));
10871    }
10872
10873    // ── Token collection tests ───────────────────────────────────────
10874
10875    #[test]
10876    fn test_tokens_from_style_tag() {
10877        let mut parser = HtmlParser::new();
10878        let html = r#"<style>
10879            .btn { color: var(--colorPrimary); background: var(--bgColor); }
10880        </style>"#;
10881        parser.parse("test.html", html).expect("parse failed");
10882        let tokens = parser.take_tokens();
10883
10884        assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
10885    }
10886
10887    #[test]
10888    fn test_tokens_from_malformed_style_error_on_unclosed_var() {
10889        let mut parser = HtmlParser::new();
10890        let html = r#"<style>
10891            .bad { color: var(--dangling; }
10892            .ok { color: var(--valid); }
10893        </style>"#;
10894        let result = parser.parse("test.html", html);
10895
10896        assert!(matches!(result, Err(ParserError::Template(diag)) if
10897            diag.error_code() == Some(codes::INVALID_CSS)
10898                && diag.to_string().contains("Unterminated CSS var() call")
10899        ));
10900    }
10901
10902    #[test]
10903    fn test_tokens_from_component_css() {
10904        let mut parser = HtmlParser::new();
10905        parser
10906            .component_registry_mut()
10907            .register_component(ComponentRegistration::new(
10908                "my-button",
10909                "<button>Click</button>",
10910                Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
10911                true,
10912            ))
10913            .expect("register failed");
10914
10915        let html = "<my-button></my-button>";
10916        parser.parse("test.html", html).expect("parse failed");
10917        let tokens = parser.take_tokens();
10918
10919        assert_eq!(tokens, vec!["borderWidth", "textColor"]);
10920    }
10921
10922    #[test]
10923    fn test_token_requirements_preserve_fallback_chains() {
10924        let mut parser = HtmlParser::new();
10925        parser
10926            .component_registry_mut()
10927            .register_component(ComponentRegistration::new(
10928                "my-card",
10929                "<div>Card</div>",
10930                Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
10931                true,
10932            ))
10933            .expect("register failed");
10934
10935        parser
10936            .parse("test.html", "<my-card></my-card>")
10937            .expect("parse failed");
10938        let analysis = parser.token_analysis();
10939
10940        assert_eq!(analysis.fallback_chains.len(), 1);
10941        assert_eq!(
10942            analysis.fallback_chains[0].tokens,
10943            vec!["token-a", "token-b", "token-c"]
10944        );
10945        assert!(!analysis.fallback_chains[0].has_literal_fallback);
10946    }
10947
10948    #[test]
10949    fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
10950        let mut parser = HtmlParser::new();
10951        parser
10952            .component_registry_mut()
10953            .register_component(ComponentRegistration::new(
10954                "my-card",
10955                "<div>Card</div>",
10956                Some(
10957                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10958                ),
10959             true,))
10960            .expect("register failed");
10961
10962        parser
10963            .parse("test.html", "<my-card></my-card>")
10964            .expect("parse failed");
10965        let analysis = parser.token_analysis();
10966
10967        assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
10968        assert_eq!(analysis.fallback_chains.len(), 1);
10969        assert_eq!(
10970            analysis.fallback_chains[0].tokens,
10971            vec!["token-b", "token-c"]
10972        );
10973    }
10974
10975    #[test]
10976    fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
10977        let mut parser = HtmlParser::new();
10978        parser
10979            .component_registry_mut()
10980            .register_component(ComponentRegistration::new(
10981                "my-card",
10982                "<div>Card</div>",
10983                Some(
10984                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10985                ),
10986             true,))
10987            .expect("register failed");
10988        parser
10989            .parse("test.html", "<my-card></my-card>")
10990            .expect("parse failed");
10991
10992        let analysis = parser.token_analysis();
10993        let theme = webui_tokens::TokenFile {
10994            themes: HashMap::from([(
10995                "light".to_string(),
10996                HashMap::from([("token-b".to_string(), "green".to_string())]),
10997            )]),
10998        };
10999
11000        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
11001            panic!("missing --token-c in the theme must fail parser validation");
11002        };
11003        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
11004        // The help is concise: a typo suggestion plus the local-definition
11005        // escape hatch — it does not restate "add --token-c to theme".
11006        let help = diagnostic.help_text().expect("help text");
11007        assert!(help.contains("did you mean --token-b?"), "help: {help}");
11008        assert!(help.contains("define it locally"), "help: {help}");
11009        assert!(!help.contains("add --token"), "help: {help}");
11010    }
11011
11012    #[test]
11013    fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
11014        let mut parser = HtmlParser::new();
11015        parser
11016            .component_registry_mut()
11017            .register_component(ComponentRegistration::new(
11018                "my-card",
11019                "<div>Card</div>",
11020                Some(":host { color: var(--brand, #000); }"),
11021                true,
11022            ))
11023            .expect("register failed");
11024        parser
11025            .parse("test.html", "<my-card></my-card>")
11026            .expect("parse failed");
11027
11028        let analysis = parser.token_analysis();
11029        // The token is still hoisted so the runtime resolves it when a theme
11030        // does provide it.
11031        assert_eq!(analysis.protocol_tokens, vec!["brand"]);
11032
11033        // A theme without `--brand` must NOT fail the build: the CSS literal
11034        // fallback (`#000`) already provides a value.
11035        let theme = webui_tokens::TokenFile {
11036            themes: HashMap::from([("light".to_string(), HashMap::new())]),
11037        };
11038        analysis
11039            .validate_theme_tokens(&theme)
11040            .expect("literal fallback must exempt --brand from theme validation");
11041    }
11042
11043    #[test]
11044    fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
11045        let mut parser = HtmlParser::new();
11046        parser
11047            .component_registry_mut()
11048            .register_component(ComponentRegistration::new(
11049                "my-card",
11050                "<div>Card</div>",
11051                // One usage has a literal fallback, the other does not. The
11052                // bare `var(--brand)` makes the token genuinely required.
11053                Some(":host { color: var(--brand, #000); background: var(--brand); }"),
11054                true,
11055            ))
11056            .expect("register failed");
11057        parser
11058            .parse("test.html", "<my-card></my-card>")
11059            .expect("parse failed");
11060
11061        let analysis = parser.token_analysis();
11062        let theme = webui_tokens::TokenFile {
11063            themes: HashMap::from([("light".to_string(), HashMap::new())]),
11064        };
11065
11066        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
11067            panic!("a bare var(--brand) usage must require --brand in the theme");
11068        };
11069        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
11070        assert!(diagnostic.to_string().contains("--brand"));
11071    }
11072
11073    #[test]
11074    fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
11075        let mut parser = HtmlParser::new();
11076        parser
11077            .component_registry_mut()
11078            .register_component(ComponentRegistration::new(
11079                "my-card",
11080                "<div>Card</div>",
11081                Some(
11082                    ":host { \
11083                       color: var(--colr-brand, #000); \
11084                       border: var(--present, 1px); \
11085                       margin: var(--required); \
11086                     }",
11087                ),
11088                true,
11089            ))
11090            .expect("register failed");
11091        parser
11092            .parse("test.html", "<my-card></my-card>")
11093            .expect("parse failed");
11094
11095        let analysis = parser.token_analysis();
11096        let theme = webui_tokens::TokenFile {
11097            themes: HashMap::from([(
11098                "light".to_string(),
11099                HashMap::from([
11100                    ("present".to_string(), "2px".to_string()),
11101                    ("required".to_string(), "8px".to_string()),
11102                ]),
11103            )]),
11104        };
11105
11106        // `colr-brand`: literal-only and absent from every theme → warned.
11107        // `present`: literal-only but defined in the theme → not warned.
11108        // `required`: has no literal fallback → a validation concern, not a warning.
11109        assert_eq!(
11110            analysis.unthemed_literal_fallback_tokens(&theme),
11111            vec!["colr-brand".to_string()]
11112        );
11113    }
11114
11115    #[test]
11116    fn test_theme_token_error_reports_location_and_suggestion() {
11117        let mut parser = HtmlParser::new();
11118        parser
11119            .component_registry_mut()
11120            .register_component(ComponentRegistration::new(
11121                "my-card",
11122                "<div>Card</div>",
11123                ":host {\n  color: var(--color-neutral-2000);\n}".into(),
11124                true,
11125            ))
11126            .expect("register failed");
11127        parser
11128            .parse("test.html", "<my-card></my-card>")
11129            .expect("parse failed");
11130
11131        let analysis = parser.token_analysis();
11132        let theme = webui_tokens::TokenFile {
11133            themes: HashMap::from([(
11134                "dark".to_string(),
11135                HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
11136            )]),
11137        };
11138
11139        let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
11140            panic!("missing --color-neutral-2000 must fail validation");
11141        };
11142        assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
11143        // File + line:column, like other authoring diagnostics.
11144        let location = diag.location().expect("a source location");
11145        assert!(
11146            location.contains("my-card.css:2:14"),
11147            "location: {location}"
11148        );
11149        // Snippet shows the offending CSS line.
11150        assert!(
11151            diag.snippet_text()
11152                .is_some_and(|s| s.contains("--color-neutral-2000")),
11153            "snippet: {:?}",
11154            diag.snippet_text()
11155        );
11156        // Did-you-mean from the theme's own tokens.
11157        assert!(
11158            diag.help_text()
11159                .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
11160            "help: {:?}",
11161            diag.help_text()
11162        );
11163    }
11164
11165    #[test]
11166    fn test_theme_token_warning_reports_location_and_suggestion() {
11167        let mut parser = HtmlParser::new();
11168        parser
11169            .component_registry_mut()
11170            .register_component(ComponentRegistration::new(
11171                "my-card",
11172                "<div>Card</div>",
11173                ":host {\n  color: var(--colr-brand, #000);\n}".into(),
11174                true,
11175            ))
11176            .expect("register failed");
11177        parser
11178            .parse("test.html", "<my-card></my-card>")
11179            .expect("parse failed");
11180
11181        let analysis = parser.token_analysis();
11182        let theme = webui_tokens::TokenFile {
11183            themes: HashMap::from([(
11184                "light".to_string(),
11185                HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
11186            )]),
11187        };
11188
11189        let warnings = analysis.theme_token_warnings(&theme);
11190        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
11191        let body = warnings[0].body();
11192        assert!(body.contains("my-card.css:2:"), "warning: {body}");
11193        assert!(body.contains("--colr-brand"), "warning: {body}");
11194        assert!(
11195            body.contains("did you mean --color-brand?"),
11196            "warning: {body}"
11197        );
11198    }
11199
11200    #[test]
11201    fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
11202        let mut parser = HtmlParser::new();
11203        let result =
11204            parser
11205                .component_registry_mut()
11206                .register_component(ComponentRegistration::new(
11207                    "my-card",
11208                    "<div>Card</div>",
11209                    Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
11210                    true,
11211                ));
11212
11213        assert!(matches!(result, Err(ParserError::Css(message)) if
11214            message.contains("Unterminated CSS var() call")
11215        ));
11216    }
11217
11218    #[test]
11219    fn test_tokens_merged_from_style_and_components() {
11220        let mut parser = HtmlParser::new();
11221        parser
11222            .component_registry_mut()
11223            .register_component(ComponentRegistration::new(
11224                "my-widget",
11225                "<div>Widget</div>",
11226                Some(".w { padding: var(--spacingM); }"),
11227                true,
11228            ))
11229            .expect("register failed");
11230
11231        let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
11232        parser.parse("test.html", html).expect("parse failed");
11233        let tokens = parser.take_tokens();
11234
11235        assert_eq!(tokens, vec!["spacingM", "textColor"]);
11236    }
11237
11238    #[test]
11239    fn test_tokens_deduplicated() {
11240        let mut parser = HtmlParser::new();
11241        parser
11242            .component_registry_mut()
11243            .register_component(ComponentRegistration::new(
11244                "my-btn",
11245                "<button>B</button>",
11246                Some(".b { color: var(--shared); }"),
11247                true,
11248            ))
11249            .expect("register failed");
11250
11251        let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
11252        parser.parse("test.html", html).expect("parse failed");
11253        let tokens = parser.take_tokens();
11254
11255        assert_eq!(tokens, vec!["shared"]);
11256    }
11257
11258    #[test]
11259    fn test_tokens_empty_when_no_vars() {
11260        let mut parser = HtmlParser::new();
11261        let html = "<div>Hello</div>";
11262        parser.parse("test.html", html).expect("parse failed");
11263        let tokens = parser.take_tokens();
11264
11265        assert!(tokens.is_empty());
11266    }
11267
11268    #[test]
11269    fn test_tokens_exclude_locally_defined_in_component() {
11270        let mut parser = HtmlParser::new();
11271        parser
11272            .component_registry_mut()
11273            .register_component(ComponentRegistration::new(
11274                "my-card",
11275                "<div>Card</div>",
11276                Some(":host { --local: 5px; width: var(--external); }"),
11277                true,
11278            ))
11279            .expect("register failed");
11280
11281        let html = "<my-card></my-card>";
11282        parser.parse("test.html", html).expect("parse failed");
11283        let tokens = parser.take_tokens();
11284
11285        assert_eq!(tokens, vec!["external"]);
11286    }
11287
11288    #[test]
11289    fn test_tokens_exclude_entry_root_definitions() {
11290        let mut parser = HtmlParser::new();
11291        parser
11292            .component_registry_mut()
11293            .register_component(ComponentRegistration::new(
11294                "my-btn",
11295                "<button>B</button>",
11296                Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
11297                true,
11298            ))
11299            .expect("register failed");
11300
11301        // Entry HTML defines --color-primary and --radius-m in :root
11302        // Components use them — they should NOT appear in hoisted tokens
11303        let html = r#"<style>
11304            :root {
11305                --color-primary: #0078d4;
11306                --radius-m: 6px;
11307            }
11308            body { color: var(--color-primary); }
11309        </style>
11310        <my-btn></my-btn>"#;
11311        parser.parse("test.html", html).expect("parse failed");
11312        let tokens = parser.take_tokens();
11313
11314        // Both tokens are defined in entry :root, so neither should be hoisted
11315        assert!(
11316            tokens.is_empty(),
11317            "Tokens defined in entry :root should be excluded: {tokens:?}"
11318        );
11319    }
11320
11321    #[test]
11322    fn test_tokens_entry_defs_exclude_but_external_kept() {
11323        let mut parser = HtmlParser::new();
11324        parser
11325            .component_registry_mut()
11326            .register_component(ComponentRegistration::new(
11327                "my-card",
11328                "<div>Card</div>",
11329                Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
11330                true,
11331            ))
11332            .expect("register failed");
11333
11334        // Entry defines --color-primary but NOT --external-spacing
11335        let html = r#"<style>
11336            :root { --color-primary: #0078d4; }
11337        </style>
11338        <my-card></my-card>"#;
11339        parser.parse("test.html", html).expect("parse failed");
11340        let tokens = parser.take_tokens();
11341
11342        // Only --external-spacing should be hoisted (not defined in entry)
11343        assert_eq!(tokens, vec!["external-spacing"]);
11344    }
11345
11346    // ── Route parsing tests ─────────────────────────────────────────────
11347
11348    #[test]
11349    fn test_parse_simple_route() {
11350        let mut parser = HtmlParser::new();
11351        let html = r#"<route path="/profile" component="profile-page" exact />"#;
11352        parser.parse("test.html", html).expect("parse failed");
11353
11354        // Routes are emitted as Fragment::Route
11355        let records = parser.into_fragment_records();
11356        let frags = &records["test.html"].fragments;
11357        assert_eq!(frags.len(), 1);
11358        match frags[0].fragment.as_ref() {
11359            Some(web_ui_fragment::Fragment::Route(r)) => {
11360                assert_eq!(r.path, "/profile");
11361                assert_eq!(r.fragment_id, "profile-page");
11362                assert!(r.exact);
11363            }
11364            other => panic!("expected Fragment::Route, got {:?}", other),
11365        }
11366    }
11367
11368    #[test]
11369    fn test_parse_route_with_params() {
11370        let mut parser = HtmlParser::new();
11371        let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
11372        parser.parse("test.html", html).expect("parse failed");
11373
11374        // Route is emitted as Fragment::Route with correct path
11375        let records = parser.into_fragment_records();
11376        let frags = &records["test.html"].fragments;
11377        match frags[0].fragment.as_ref() {
11378            Some(web_ui_fragment::Fragment::Route(r)) => {
11379                assert_eq!(r.path, "/profile/:id/view/:section");
11380            }
11381            other => panic!("expected Fragment::Route, got {:?}", other),
11382        }
11383    }
11384
11385    #[test]
11386    fn test_parse_route_requires_component() {
11387        let mut parser = HtmlParser::new();
11388        let html = r#"<route path="/old" />"#;
11389        let result = parser.parse("test.html", html);
11390        assert!(result.is_err());
11391    }
11392
11393    #[test]
11394    fn test_parse_multiple_routes() {
11395        let mut parser = HtmlParser::new();
11396        let html = r#"<route path="/" component="app-layout" />
11397            <route path="/dashboard" component="dash-page" exact />
11398            <route path="/contacts" component="contacts-page" exact />"#;
11399        parser.parse("test.html", html).expect("parse failed");
11400
11401        let records = parser.into_fragment_records();
11402        let frags = &records["test.html"].fragments;
11403        assert_eq!(frags.len(), 3);
11404        // All should be Fragment::Route
11405        for frag in frags {
11406            assert!(matches!(
11407                frag.fragment.as_ref(),
11408                Some(web_ui_fragment::Fragment::Route(_))
11409            ));
11410        }
11411    }
11412
11413    #[test]
11414    fn test_parse_route_requires_component_with_body() {
11415        let mut parser = HtmlParser::new();
11416        let html = r#"<route path="/error">
11417            <div class="error"><h1>Not Found</h1></div>
11418        </route>"#;
11419        let result = parser.parse("test.html", html);
11420        assert!(
11421            result.is_err(),
11422            "Route without component attribute should fail"
11423        );
11424    }
11425
11426    #[test]
11427    fn test_parse_multiple_routes_with_fragments() {
11428        let mut parser = HtmlParser::new();
11429        let html = r#"<route path="/" component="home-page" exact />
11430            <route path="/about" component="about-page" exact />
11431            <route path="/contact/:id" component="contact-page" />"#;
11432        parser.parse("test.html", html).expect("parse failed");
11433
11434        let records = parser.into_fragment_records();
11435        let frags = &records["test.html"].fragments;
11436        assert_eq!(frags.len(), 3);
11437
11438        // Verify individual routes
11439        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
11440            assert_eq!(r.path, "/");
11441        }
11442        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
11443            assert_eq!(r.path, "/about");
11444        }
11445        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
11446            assert_eq!(r.path, "/contact/:id");
11447        }
11448    }
11449
11450    #[test]
11451    fn test_parse_route_with_query_allowlist() {
11452        let mut parser = HtmlParser::new();
11453        let html =
11454            r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
11455        parser.parse("test.html", html).expect("parse failed");
11456
11457        let records = parser.into_fragment_records();
11458        let frags = &records["test.html"].fragments;
11459        assert_eq!(frags.len(), 1);
11460        match frags[0].fragment.as_ref() {
11461            Some(web_ui_fragment::Fragment::Route(r)) => {
11462                assert_eq!(r.path, "/compose");
11463                assert_eq!(r.fragment_id, "compose-page");
11464                assert!(r.exact);
11465                assert_eq!(r.allowed_query, "action,to,subject");
11466            }
11467            other => panic!("expected Fragment::Route, got {:?}", other),
11468        }
11469    }
11470
11471    #[test]
11472    fn test_parse_route_without_query_has_empty_allowed_query() {
11473        let mut parser = HtmlParser::new();
11474        let html = r#"<route path="/profile" component="profile-page" exact />"#;
11475        parser.parse("test.html", html).expect("parse failed");
11476
11477        let records = parser.into_fragment_records();
11478        let frags = &records["test.html"].fragments;
11479        match frags[0].fragment.as_ref() {
11480            Some(web_ui_fragment::Fragment::Route(r)) => {
11481                assert!(r.allowed_query.is_empty());
11482            }
11483            other => panic!("expected Fragment::Route, got {:?}", other),
11484        }
11485    }
11486
11487    #[test]
11488    fn test_route_body_rejects_malformed_non_route_html() {
11489        let mut parser = HtmlParser::new();
11490        let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
11491        let result = parser.parse("test.html", html);
11492
11493        assert!(matches!(result, Err(ParserError::Template(diag)) if
11494            diag.to_string().contains("unclosed <span> tag")
11495        ));
11496    }
11497
11498    #[test]
11499    fn test_route_body_rejects_malformed_style_css() {
11500        let mut parser = HtmlParser::new();
11501        let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
11502        let result = parser.parse("test.html", html);
11503
11504        assert!(matches!(result, Err(ParserError::Template(diag)) if
11505            diag.error_code() == Some(codes::INVALID_CSS)
11506                && diag.to_string().contains("Unterminated CSS var() call")
11507        ));
11508    }
11509
11510    #[test]
11511    fn test_outlet_not_captured_by_for_loop() {
11512        let mut parser = HtmlParser::new();
11513        let html = r#"<template shadowrootmode="open">
11514  <ul>
11515    <for each="item in items">
11516      <li>{{item.name}}</li>
11517    </for>
11518  </ul>
11519  <main>
11520    <outlet />
11521  </main>
11522</template>"#;
11523        parser.parse("comp.html", html).expect("parse failed");
11524
11525        let records = parser.into_fragment_records();
11526        let frags = &records["comp.html"].fragments;
11527
11528        // The outlet should be a top-level fragment, NOT inside the for-loop's body
11529        let outlet_count = frags
11530            .iter()
11531            .filter(|f| {
11532                matches!(
11533                    f.fragment.as_ref(),
11534                    Some(web_ui_fragment::Fragment::Outlet(_))
11535                )
11536            })
11537            .count();
11538        assert_eq!(
11539            outlet_count, 1,
11540            "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
11541        );
11542
11543        // Verify outlet comes AFTER the raw "</ul>" text
11544        let outlet_idx = frags
11545            .iter()
11546            .position(|f| {
11547                matches!(
11548                    f.fragment.as_ref(),
11549                    Some(web_ui_fragment::Fragment::Outlet(_))
11550                )
11551            })
11552            .expect("no outlet found");
11553        let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
11554            Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
11555            _ => false,
11556        });
11557        if let Some(ul_idx) = close_ul_idx {
11558            assert!(
11559                outlet_idx > ul_idx,
11560                "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
11561            );
11562        }
11563    }
11564
11565    #[test]
11566    fn test_outlet_position_after_for_not_inside() {
11567        let mut parser = HtmlParser::new();
11568        let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
11569        parser.parse("test.html", html).expect("parse failed");
11570
11571        let records = parser.into_fragment_records();
11572        let frags = &records["test.html"].fragments;
11573
11574        // Outlet should be its own fragment at top level
11575        let has_outlet = frags.iter().any(|f| {
11576            matches!(
11577                f.fragment.as_ref(),
11578                Some(web_ui_fragment::Fragment::Outlet(_))
11579            )
11580        });
11581        assert!(
11582            has_outlet,
11583            "outlet should be in top-level fragments: {frags:?}"
11584        );
11585
11586        // The for-loop body should NOT contain the outlet
11587        let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
11588            Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
11589            _ => None,
11590        });
11591        if let Some(id) = for_id {
11592            let for_frags = &records[&id].fragments;
11593            let outlet_in_for = for_frags.iter().any(|f| {
11594                matches!(
11595                    f.fragment.as_ref(),
11596                    Some(web_ui_fragment::Fragment::Outlet(_))
11597                )
11598            });
11599            assert!(
11600                !outlet_in_for,
11601                "outlet should NOT be inside for-loop body: {for_frags:?}"
11602            );
11603        }
11604    }
11605
11606    #[test]
11607    fn multiple_outlets_in_one_fragment_warn() {
11608        let mut parser = HtmlParser::new();
11609        let html = r#"<main><outlet /></main><aside><outlet /></aside>"#;
11610        parser.parse("test.html", html).expect("parse failed");
11611
11612        let warnings = parser.take_warnings();
11613        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
11614        assert_eq!(warnings[0].severity(), Severity::Warning);
11615        assert_eq!(warnings[0].error_code(), Some(codes::MULTIPLE_OUTLETS));
11616        assert_eq!(warnings[0].component_name(), Some("test.html"));
11617        assert!(warnings[0].help_text().is_some());
11618    }
11619
11620    #[test]
11621    fn multiple_outlets_across_nested_directives_warn() {
11622        for html in [
11623            r#"<if condition="show"><outlet /></if><outlet />"#,
11624            r#"<for each="item in items"><outlet /></for><outlet />"#,
11625        ] {
11626            let mut parser = HtmlParser::new();
11627            parser.parse("test.html", html).expect("parse failed");
11628
11629            let warnings = parser.take_warnings();
11630            assert_eq!(warnings.len(), 1, "warnings for {html}: {warnings:?}");
11631            assert_eq!(warnings[0].error_code(), Some(codes::MULTIPLE_OUTLETS));
11632        }
11633    }
11634
11635    #[test]
11636    fn test_style_element_with_handlebars_signal_comment_emits_signal() {
11637        let mut parser = HtmlParser::new();
11638        let html = r#"<html><head><style>
11639:root {
11640    /*{{{tokens.light}}}*/
11641}
11642</style></head><body></body></html>"#;
11643
11644        let result = parser.parse("test.html", html);
11645        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11646        let fragment_records = parser.into_fragment_records();
11647
11648        assert_stream!(
11649            fragment_records,
11650            "test.html",
11651            [
11652                raw("<html><head>"),
11653                structural_matcher("head_start"),
11654                raw("<style>\n:root {\n    "),
11655                raw_text_signal("tokens.light", true),
11656                raw("\n}\n</style>"),
11657                structural_matcher("head_end"),
11658                raw("</head><body>"),
11659                structural_matcher("body_start"),
11660                structural_matcher("body_end"),
11661                raw("</body></html>"),
11662            ]
11663        );
11664    }
11665
11666    #[test]
11667    fn test_style_comment_signal_with_spaces_emits_signal() {
11668        let mut parser = HtmlParser::new();
11669        let html = r#"<html><head><style>
11670:root {
11671    /* {{{tokens.light}}} */
11672}
11673</style></head><body></body></html>"#;
11674
11675        let result = parser.parse("test.html", html);
11676        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11677        let fragment_records = parser.into_fragment_records();
11678
11679        assert_stream!(
11680            fragment_records,
11681            "test.html",
11682            [
11683                raw("<html><head>"),
11684                structural_matcher("head_start"),
11685                raw("<style>\n:root {\n    "),
11686                raw_text_signal("tokens.light", true),
11687                raw("\n}\n</style>"),
11688                structural_matcher("head_end"),
11689                raw("</head><body>"),
11690                structural_matcher("body_start"),
11691                structural_matcher("body_end"),
11692                raw("</body></html>"),
11693            ]
11694        );
11695    }
11696
11697    #[test]
11698    fn test_style_comment_signal_double_brace_emits_signal() {
11699        let mut parser = HtmlParser::new();
11700        let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
11701
11702        let result = parser.parse("test.html", html);
11703        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11704        let fragment_records = parser.into_fragment_records();
11705
11706        assert_stream!(
11707            fragment_records,
11708            "test.html",
11709            [
11710                raw("<html><head>"),
11711                structural_matcher("head_start"),
11712                raw("<style>"),
11713                raw_text_signal("themeCss", false),
11714                raw("</style>"),
11715                structural_matcher("head_end"),
11716                raw("</head><body>"),
11717                structural_matcher("body_start"),
11718                structural_matcher("body_end"),
11719                raw("</body></html>"),
11720            ]
11721        );
11722    }
11723
11724    #[test]
11725    fn test_style_comment_with_extra_text_is_stripped() {
11726        let mut parser = HtmlParser::new();
11727        let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
11728
11729        let result = parser.parse("test.html", html);
11730        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11731        let fragment_records = parser.into_fragment_records();
11732
11733        assert_stream!(
11734            fragment_records,
11735            "test.html",
11736            [
11737                raw("<html><head>"),
11738                structural_matcher("head_start"),
11739                raw("<style></style>"),
11740                structural_matcher("head_end"),
11741                raw("</head><body>"),
11742                structural_matcher("body_start"),
11743                structural_matcher("body_end"),
11744                raw("</body></html>"),
11745            ]
11746        );
11747    }
11748
11749    #[test]
11750    fn test_style_comment_with_multiple_signals_is_stripped() {
11751        let mut parser = HtmlParser::new();
11752        let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
11753
11754        let result = parser.parse("test.html", html);
11755        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11756        let fragment_records = parser.into_fragment_records();
11757
11758        assert_stream!(
11759            fragment_records,
11760            "test.html",
11761            [
11762                raw("<html><head>"),
11763                structural_matcher("head_start"),
11764                raw("<style></style>"),
11765                structural_matcher("head_end"),
11766                raw("</head><body>"),
11767                structural_matcher("body_start"),
11768                structural_matcher("body_end"),
11769                raw("</body></html>"),
11770            ]
11771        );
11772    }
11773
11774    #[test]
11775    fn test_style_bare_handlebars_stays_raw() {
11776        let mut parser = HtmlParser::new();
11777        // Handlebars outside a CSS comment must NOT be parsed as signals.
11778        let html =
11779            "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
11780
11781        let result = parser.parse("test.html", html);
11782        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11783        let fragment_records = parser.into_fragment_records();
11784
11785        assert_stream!(
11786            fragment_records,
11787            "test.html",
11788            [
11789                raw("<html><head>"),
11790                structural_matcher("head_start"),
11791                raw("<style>body { color: {{textColor}}; }</style>"),
11792                structural_matcher("head_end"),
11793                raw("</head><body>"),
11794                structural_matcher("body_start"),
11795                structural_matcher("body_end"),
11796                raw("</body></html>"),
11797            ]
11798        );
11799    }
11800
11801    #[test]
11802    fn test_style_mixed_css_and_comment_signal_emits_signal() {
11803        let mut parser = HtmlParser::new();
11804        let html = r#"<html><head><style>
11805  .a { color: red; }
11806  /*{{themeCss}}*/
11807  .b { color: blue; }
11808</style></head><body></body></html>"#;
11809
11810        let result = parser.parse("test.html", html);
11811        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11812        let fragment_records = parser.into_fragment_records();
11813
11814        assert_stream!(
11815            fragment_records,
11816            "test.html",
11817            [
11818                raw("<html><head>"),
11819                structural_matcher("head_start"),
11820                raw("<style>\n  .a { color: red; }\n  "),
11821                raw_text_signal("themeCss", false),
11822                raw("\n  .b { color: blue; }\n</style>"),
11823                structural_matcher("head_end"),
11824                raw("</head><body>"),
11825                structural_matcher("body_start"),
11826                structural_matcher("body_end"),
11827                raw("</body></html>"),
11828            ]
11829        );
11830    }
11831
11832    #[test]
11833    fn test_style_legal_comment_preserved_inline_by_default() {
11834        let mut parser = HtmlParser::new();
11835        let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
11836
11837        parser.parse("test.html", html).expect("parse failed");
11838        let fragment_records = parser.into_fragment_records();
11839
11840        assert_stream!(
11841            fragment_records,
11842            "test.html",
11843            [
11844                raw("<html><head>"),
11845                structural_matcher("head_start"),
11846                raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
11847                structural_matcher("head_end"),
11848                raw("</head><body>"),
11849                structural_matcher("body_start"),
11850                structural_matcher("body_end"),
11851                raw("</body></html>"),
11852            ]
11853        );
11854    }
11855
11856    #[test]
11857    fn test_style_legal_comments_none_strips_legal_comment() {
11858        let mut parser = HtmlParser::with_options(ParserOptions {
11859            legal_comments: LegalComments::None,
11860            ..ParserOptions::default()
11861        });
11862        let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
11863
11864        parser.parse("test.html", html).expect("parse failed");
11865        let fragment_records = parser.into_fragment_records();
11866
11867        assert_stream!(
11868            fragment_records,
11869            "test.html",
11870            [
11871                raw("<html><head>"),
11872                structural_matcher("head_start"),
11873                raw("<style> .x { color: red; }</style>"),
11874                structural_matcher("head_end"),
11875                raw("</head><body>"),
11876                structural_matcher("body_start"),
11877                structural_matcher("body_end"),
11878                raw("</body></html>"),
11879            ]
11880        );
11881    }
11882
11883    #[test]
11884    fn test_style_element_plain_css_unchanged() {
11885        let mut parser = HtmlParser::new();
11886        let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
11887
11888        let result = parser.parse("test.html", html);
11889        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11890        let fragment_records = parser.into_fragment_records();
11891
11892        // Plain CSS with no handlebars should remain as a single raw fragment.
11893        assert_stream!(
11894            fragment_records,
11895            "test.html",
11896            [
11897                raw("<html><head>"),
11898                structural_matcher("head_start"),
11899                raw("<style>body { margin: 0; }</style>"),
11900                structural_matcher("head_end"),
11901                raw("</head><body>"),
11902                structural_matcher("body_start"),
11903                structural_matcher("body_end"),
11904                raw("</body></html>"),
11905            ]
11906        );
11907    }
11908
11909    #[test]
11910    fn test_unclosed_style_element_returns_error() {
11911        let mut parser = HtmlParser::new();
11912        let result = parser.parse("index.html", "<style>.x { color: red; ");
11913        assert!(matches!(result, Err(ParserError::Template(diag)) if
11914            diag.to_string().contains("unclosed <style> tag")
11915        ));
11916    }
11917
11918    #[test]
11919    fn test_text_with_binding_after_void_element() {
11920        // Text nodes containing {{binding}} after void elements like <br>
11921        // must be preserved. Regression test for content being dropped.
11922        let (fragments, _) = parse_and_get_fragments(
11923            "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
11924        );
11925        assert_fragments!(
11926            fragments,
11927            [
11928                raw("Part A: "),
11929                signal("value1"),
11930                raw("<button>Click</button><br>Part B: "),
11931                signal("value2"),
11932                raw("<button>Click2</button>"),
11933            ]
11934        );
11935    }
11936}