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/// Returns true for whitespace-only text that contains a line break.
586///
587/// SSR drops these formatting runs, while inline spaces and tabs remain
588/// content. Compiled block and component edges use the same rule, so
589/// client-created blocks keep exactly the edge text that SSR renders.
590fn is_formatting_whitespace(content: &str) -> bool {
591    let mut line_break = false;
592    for ch in content.chars() {
593        if !ch.is_whitespace() {
594            return false;
595        }
596        line_break |= matches!(ch, '\n' | '\r');
597    }
598    line_break
599}
600
601#[cold]
602#[inline(never)]
603fn locate_for_error(error: ParserError, element: &Element<'_>) -> ParserError {
604    match error {
605        ParserError::Template(diagnostic) => (*diagnostic)
606            .at_offset(element.source(), element.start)
607            .into(),
608        other => other,
609    }
610}
611
612#[cold]
613#[inline(never)]
614fn invalid_for_each(owner: &str, each: &str, code: &'static str) -> ParserError {
615    Diagnostic::error("invalid <for> each expression")
616        .code(code)
617        .component(owner)
618        .element("for")
619        .snippet(format!("each=\"{each}\""))
620        .help("use each=\"item in collection\" without braces; item and collection names may use only letters, digits, '_', '-', and '.'")
621        .into()
622}
623
624#[cold]
625#[inline(never)]
626fn unsupported_for_template(owner: &str) -> ParserError {
627    Diagnostic::error("unsupported <for> template attribute")
628        .code(codes::INVALID_FOR_ID)
629        .component(owner)
630        .element("for")
631        .snippet("template")
632        .help("replace template with id on the loop definition and every reference; named loops are local to their file")
633        .into()
634}
635
636#[cold]
637#[inline(never)]
638fn named_for_error(
639    owner: &str,
640    code: &'static str,
641    title: &str,
642    id: &str,
643    help: &str,
644) -> Diagnostic {
645    Diagnostic::error(title)
646        .code(code)
647        .component(owner)
648        .element("for")
649        .snippet(format!("id=\"{id}\""))
650        .help(help)
651}
652
653struct ParseContext {
654    fragments: Vec<WebUIFragment>,
655    raw_buffer: String,
656}
657
658struct PendingBoundary {
659    declaration_id: u32,
660}
661
662struct BoundaryGraphEdge {
663    target: String,
664}
665
666/// A `<for>` callsite, used to name the offending repeat in a diagnostic.
667struct RepeatSite<'a> {
668    owner: &'a str,
669    item: &'a str,
670    collection: &'a str,
671}
672
673/// A boundary declaration a `<for>` repeat body can reach.
674struct RepeatBoundaryViolation {
675    repeat_owner: String,
676    each: String,
677    boundary_owner: String,
678    boundary_name: String,
679}
680
681/// Push every record a route subtree renders into `targets`.
682fn push_route_records<'a>(targets: &mut Vec<&'a str>, root: &'a WebUiFragmentRoute) {
683    let mut pending = vec![root];
684    while let Some(route) = pending.pop() {
685        targets.push(route.fragment_id.as_str());
686        targets.push(route.content_fragment_id.as_str());
687        targets.push(route.pending_component.as_str());
688        targets.push(route.error_component.as_str());
689        pending.extend(route.children.iter());
690    }
691}
692
693/// Push every record `list` renders through a lexical child edge.
694fn push_child_records<'a>(list: &'a FragmentList, targets: &mut Vec<&'a str>) {
695    for fragment in &list.fragments {
696        match fragment.fragment.as_ref() {
697            Some(Fragment::Component(component)) => targets.push(&component.fragment_id),
698            Some(Fragment::IfCond(if_cond)) => targets.push(&if_cond.fragment_id),
699            Some(Fragment::ForLoop(for_loop)) => targets.push(&for_loop.fragment_id),
700            Some(Fragment::Route(route)) => push_route_records(targets, route),
701            _ => {}
702        }
703    }
704}
705
706#[derive(Default)]
707struct FragmentCssTokens {
708    definitions: Vec<String>,
709    fallback_chains: Vec<CssFallbackChain>,
710}
711
712/// CSS token analysis produced from the parsed template/component graph.
713#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct CssTokenAnalysis {
715    /// Sorted, deduplicated token candidates stored in the protocol.
716    pub protocol_tokens: Vec<String>,
717    /// Fallback chains that still need theme/literal coverage after local and
718    /// ancestor custom-property definitions are considered.
719    pub fallback_chains: Vec<CssFallbackChain>,
720    /// Source location of each unresolved token's first `var()` usage, keyed by
721    /// token name. Used to point theme-validation diagnostics at the offending
722    /// CSS. Build-time only; never serialized.
723    pub(crate) token_sites: HashMap<String, TokenSite>,
724}
725
726/// Where an unresolved CSS token is first referenced, for diagnostics.
727///
728/// `owner` is a file-like label (`my-card.css`, or the entry file for inline
729/// `<style>`). `position`/`snippet` are populated for component CSS where the
730/// source is a standalone file; inline styles record the owner only.
731#[derive(Debug, Clone, PartialEq, Eq)]
732pub(crate) struct TokenSite {
733    owner: String,
734    position: Option<(usize, usize)>,
735    snippet: Option<String>,
736}
737
738enum ParseOp<'a> {
739    Parse {
740        range: Range<usize>,
741        depth: usize,
742    },
743    EmitClose(&'a str),
744    EndComponent(&'a str),
745    EndHead(&'a str),
746    EndBody(&'a str),
747    CompleteBoundary {
748        boundary: PendingBoundary,
749    },
750    /// Restore [`HtmlParser::foster_context_depth`] to the saved value when
751    /// leaving an element that changed it.
752    ///
753    /// Save/restore of the single overwritten value keeps ancestor tracking
754    /// allocation-free: no ancestor stack is retained.
755    RestoreFosterDepth(u32),
756    /// Restore the enclosing component/raw/inert context after its child range
757    /// has been parsed.
758    RestoreBoundaryParentScope(Option<&'static str>),
759    CompleteFor {
760        parent: ParseContext,
761        item: String,
762        collection: String,
763        fragment_id: String,
764        named_definition: bool,
765        previous_for_depth: usize,
766    },
767    CompleteIf {
768        parent: ParseContext,
769        condition: ConditionExpr,
770        fragment_id: String,
771    },
772}
773
774enum TokenGraphOp<'a> {
775    EnterFragment(&'a str),
776    ExitFragment(&'a str),
777    EnterComponent(&'a str),
778    EnterRoute(&'a WebUiFragmentRoute),
779    ExitDefinitions(&'a [String]),
780}
781
782impl Default for HtmlParser {
783    fn default() -> Self {
784        Self::new()
785    }
786}
787
788/// Parser for WebUI directives.
789pub struct HtmlParser {
790    /// CSS parser.
791    css_parser: CssParser,
792
793    /// Fragment ID counter.
794    id_counter: FragmentIdCounter,
795
796    /// Condition parser for parsing conditions in directives.
797    condition_parser: ConditionParser,
798
799    /// Handlebars parser for parsing handlebars expressions.
800    handlebars_parser: HandlebarsParser,
801
802    /// Component registry for WebUI components.
803    component_registry: ComponentRegistry,
804
805    /// Map of fragment IDs to their fragments
806    fragment_records: WebUIFragmentRecords,
807
808    /// Non-fatal parser advisories collected while parsing templates.
809    warnings: Vec<Diagnostic>,
810
811    /// Buffer for accumulating raw content
812    raw_buffer: String,
813
814    /// Parser output options for generated component templates.
815    options: ParserOptions,
816
817    /// Optional parser plugin for framework-specific behavior.
818    plugin: Option<Box<dyn ParserPlugin>>,
819
820    /// Static component behavior copied from the plugin at construction.
821    component_processing: ComponentProcessing,
822
823    /// Declarative Shadow DOM analysis resolved once per component tag.
824    component_dom_analyses: HashMap<String, ComponentDomAnalysis>,
825
826    /// Top-level fragments parsed by callers. Token graph traversal starts
827    /// from these roots after parsing completes.
828    token_roots: Vec<String>,
829
830    /// CSS custom property definitions and fallback chains from inline
831    /// `<style>` tags, keyed by owning fragment id.
832    fragment_css_tokens: HashMap<String, FragmentCssTokens>,
833
834    /// Fragment IDs currently being parsed, used to reject recursive component
835    /// references before they can recurse through template parsing.
836    in_progress_fragments: HashSet<String>,
837
838    /// The fragment ID (entry file or component tag) currently being parsed.
839    /// Used to name the owning template in authoring [`Diagnostic`]s.
840    current_fragment_id: String,
841
842    loop_owner_indices: HashMap<String, usize>,
843    named_for_loops: NamedForLoops,
844    named_for_fragment_ids: HashSet<String>,
845
846    /// Next protocol-wide compile-time boundary declaration identity.
847    next_boundary_declaration_id: u32,
848
849    /// Authored boundary names keyed by their owning entry/component template.
850    boundary_names_by_owner: HashMap<String, HashSet<String>>,
851
852    /// `true` while parsing is inside an open `<boundary>`, used to
853    /// reject a nested boundary.
854    in_boundary: bool,
855
856    /// Number of boundary scopes surrounding the current reusable-template
857    /// callsite. Kept separate from lexical `in_boundary` so component syntax
858    /// is analyzed independently while island-owned module entries stay
859    /// excluded from critical preloads.
860    boundary_ancestor_depth: usize,
861
862    /// Number of currently open native `<body>` elements. A depth rather than
863    /// a boolean preserves the parent state while recursively parsing a
864    /// component template.
865    body_depth: usize,
866
867    /// Number of lexical `<for>` scopes in the current template. Boundaries in
868    /// such scopes can occur repeatedly and therefore require an authored key.
869    for_depth: usize,
870
871    /// Number of runtime route-content scopes currently being parsed.
872    route_depth: usize,
873
874    /// Number of enclosing elements whose HTML insertion mode would
875    /// foster-parent an unknown element such as the generated
876    /// `<webui-hydrate>` sentinel.
877    ///
878    /// Non-zero means a `<boundary>` here would be split from its
879    /// payload script by the browser's parser, so it must be rejected at build
880    /// time. `<td>`/`<th>`/`<caption>` reset it to `0` because those switch
881    /// back to "in body" insertion rules.
882    foster_context_depth: u32,
883
884    /// Enclosing component host content or native raw/inert HTML context.
885    ///
886    /// A generated sentinel in any of these contexts is either text or lives
887    /// in an inert fragment, so it cannot commit a streaming boundary.
888    boundary_parent_scope: Option<&'static str>,
889
890    /// `src` of every authored `<script type="module">` outside any
891    /// `<boundary>`, in document order, from the current top-level parse.
892    ///
893    /// These are the page's critical module entries. The build resolves each
894    /// against the projection manifest to emit `<link rel="modulepreload">`
895    /// for the shared chunks they statically import, which the browser's
896    /// preload scanner cannot discover on its own.
897    ///
898    /// Scripts *inside* a boundary are deliberately excluded: an island loader
899    /// is meant to be requested only when the parser reaches its chunk, so
900    /// preloading it would undo the deferral the author asked for.
901    /// Explicit `fetchpriority="low"` modules are excluded for the same reason.
902    module_entry_srcs: Vec<String>,
903}
904
905struct BuiltComponentTemplate {
906    ssr: String,
907    artifact: Option<String>,
908    uses_shadow_dom: bool,
909    style: Option<OwnedComponentStyle>,
910}
911
912/// Owned form of [`plugin::ComponentStyleDelivery`], held for the lifetime of a
913/// built component template so plugin context can borrow from it.
914enum OwnedComponentStyle {
915    Link(String),
916    Inline(String),
917    Adopted(String),
918}
919
920#[derive(Clone, Copy)]
921struct ComponentStyleInjection<'a> {
922    css_snippet: Option<&'a str>,
923    adopted_specifier: Option<&'a str>,
924}
925
926#[derive(Clone, Copy)]
927struct ComponentTemplateMode {
928    preserve_runtime_attrs: bool,
929    policy_wrapper: bool,
930    // Emit inline CSS after bindings for clients that scan braces.
931    styles_at_end: bool,
932}
933
934impl BuiltComponentTemplate {
935    fn artifact(&self) -> &str {
936        self.artifact.as_deref().unwrap_or(&self.ssr)
937    }
938
939    fn plugin_context<'a>(&'a self, component: &'a Component) -> ComponentBuildContext<'a> {
940        ComponentBuildContext {
941            component,
942            template: self.artifact(),
943            uses_shadow_dom: self.uses_shadow_dom,
944            style: self.style.as_ref().map(|style| match style {
945                OwnedComponentStyle::Link(href) => plugin::ComponentStyleDelivery::Link { href },
946                OwnedComponentStyle::Inline(css) => plugin::ComponentStyleDelivery::Inline { css },
947                OwnedComponentStyle::Adopted(specifier) => {
948                    plugin::ComponentStyleDelivery::Adopted { specifier }
949                }
950            }),
951        }
952    }
953}
954
955#[derive(Debug, Clone, Copy)]
956struct ComponentDomAnalysis {
957    uses_shadow_dom: bool,
958    /// Byte range of the authored declarative Shadow DOM root, when present.
959    authored_shadow_root: Option<(usize, usize)>,
960    /// Content range of a sole bare `<template>` that explicitly selects Light
961    /// DOM.
962    authored_light_root: Option<(usize, usize)>,
963}
964
965struct ShadowRootModeOccurrence<'a> {
966    element_start: usize,
967    element_end: usize,
968    element_name: &'a str,
969    has_closing_tag: bool,
970    dynamically_bound: bool,
971    attr_offset: usize,
972    value: Option<&'a str>,
973}
974
975#[cfg(test)]
976fn analyze_component_dom(
977    tag_name: &str,
978    source: &str,
979    fallback: DomStrategy,
980) -> Result<ComponentDomAnalysis> {
981    analyze_component_dom_with_diagnostic_source(tag_name, source, fallback, None)
982}
983
984fn analyze_component_dom_with_diagnostic_source(
985    tag_name: &str,
986    source: &str,
987    fallback: DomStrategy,
988    diagnostic_source: Option<&str>,
989) -> Result<ComponentDomAnalysis> {
990    let mut ranges = Vec::with_capacity(8);
991    ranges.push((0..source.len(), true));
992    let mut top_level_element_count = 0usize;
993    let mut top_level_root_start = 0usize;
994    let mut has_top_level_content = false;
995    let mut bare_template_root = None;
996    let mut shadow_mode = None;
997    let mut slot_offset = None;
998
999    while let Some((range, top_level)) = ranges.pop() {
1000        for event in Walker::new_range(source, range.start, range.end) {
1001            match event {
1002                Event::Text(text) if top_level && !text.trim().is_empty() => {
1003                    has_top_level_content = true;
1004                }
1005                Event::Declaration(_) if top_level => {
1006                    has_top_level_content = true;
1007                }
1008                Event::Element(element) => {
1009                    if top_level {
1010                        top_level_element_count += 1;
1011                        top_level_root_start = element.start;
1012                        bare_template_root = (element.name().eq_ignore_ascii_case("template")
1013                            && !element.self_closing()
1014                            && element.attrs().next().is_none())
1015                        .then_some((element.content_start, element.content_end));
1016                    }
1017
1018                    for attr in element.attrs() {
1019                        let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1020                        if attr_name.eq_ignore_ascii_case("shadowrootmode") {
1021                            if shadow_mode.is_some() {
1022                                return Err(invalid_shadow_root_mode_error(
1023                                    tag_name,
1024                                    DiagnosticSite::new(
1025                                        source,
1026                                        diagnostic_source,
1027                                        element.start + attr.raw_range.start,
1028                                    ),
1029                                    "a component may declare only one `shadowrootmode`",
1030                                    "keep one `shadowrootmode=\"open\"` attribute on the sole top-level `<template>`",
1031                                ));
1032                            }
1033                            shadow_mode = Some(ShadowRootModeOccurrence {
1034                                element_start: element.start,
1035                                element_end: element.close_end(),
1036                                element_name: element.name(),
1037                                has_closing_tag: !element.self_closing()
1038                                    && element.close_end() > element.content_end(),
1039                                dynamically_bound: attr.name != attr_name,
1040                                attr_offset: element.start + attr.raw_range.start,
1041                                value: attr.value,
1042                            });
1043                        }
1044                    }
1045
1046                    if slot_offset.is_none() && element.name().eq_ignore_ascii_case("slot") {
1047                        slot_offset = Some(element.start);
1048                    }
1049
1050                    let name = element.name();
1051                    let raw_text = name.eq_ignore_ascii_case("script")
1052                        || name.eq_ignore_ascii_case("style")
1053                        || name.eq_ignore_ascii_case("textarea")
1054                        || name.eq_ignore_ascii_case("title");
1055                    if element.content_end() > element.inner().start && !raw_text {
1056                        ranges.push((element.inner(), false));
1057                    }
1058                }
1059                Event::Text(_)
1060                | Event::Comment(_)
1061                | Event::Declaration(_)
1062                | Event::ClosingTag(_) => {}
1063            }
1064        }
1065    }
1066
1067    let authored_shadow_root = match shadow_mode {
1068        Some(mode) => {
1069            let value = mode.value;
1070            if mode.dynamically_bound {
1071                return Err(invalid_shadow_root_mode_error(
1072                    tag_name,
1073                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1074                    "`shadowrootmode` must be a static attribute",
1075                    "replace the binding with `shadowrootmode=\"open\"` on the sole top-level `<template>`",
1076                ));
1077            }
1078            if value.is_some_and(|value| value.eq_ignore_ascii_case("closed")) {
1079                return Err(invalid_shadow_root_mode_error(
1080                    tag_name,
1081                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1082                    "`shadowrootmode=\"closed\"` is not supported",
1083                    "use `shadowrootmode=\"open\"`; WebUI requires an open root for hydration and style delivery",
1084                ));
1085            }
1086            if !value.is_some_and(|value| value.eq_ignore_ascii_case("open")) {
1087                return Err(invalid_shadow_root_mode_error(
1088                    tag_name,
1089                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1090                    "`shadowrootmode` must be `open`",
1091                    "set the attribute to `shadowrootmode=\"open\"`",
1092                ));
1093            }
1094            if top_level_element_count != 1
1095                || has_top_level_content
1096                || mode.element_start != top_level_root_start
1097                || !mode.element_name.eq_ignore_ascii_case("template")
1098                || !mode.has_closing_tag
1099            {
1100                return Err(invalid_shadow_root_mode_error(
1101                    tag_name,
1102                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1103                    "the declarative Shadow DOM wrapper must be the sole top-level element",
1104                    "wrap the complete component in one top-level `<template shadowrootmode=\"open\">`",
1105                ));
1106            }
1107            Some((mode.element_start, mode.element_end))
1108        }
1109        None => None,
1110    };
1111
1112    let authored_light_root =
1113        if authored_shadow_root.is_none() && top_level_element_count == 1 && !has_top_level_content
1114        {
1115            bare_template_root
1116        } else {
1117            None
1118        };
1119    let uses_shadow_dom = authored_shadow_root.is_some()
1120        || (authored_light_root.is_none() && fallback == DomStrategy::Shadow);
1121    if !uses_shadow_dom {
1122        if let Some(offset) = slot_offset {
1123            return Err(light_dom_slot_error(
1124                tag_name,
1125                DiagnosticSite::new(source, diagnostic_source, offset),
1126            ));
1127        }
1128    }
1129
1130    Ok(ComponentDomAnalysis {
1131        uses_shadow_dom,
1132        authored_shadow_root,
1133        authored_light_root,
1134    })
1135}
1136
1137#[cold]
1138#[inline(never)]
1139fn invalid_shadow_root_mode_error(
1140    tag_name: &str,
1141    site: DiagnosticSite<'_>,
1142    title: &str,
1143    help: &str,
1144) -> ParserError {
1145    let (source, offset) = site.resolve(DiagnosticTarget::Attribute("shadowrootmode"));
1146    Diagnostic::error(title)
1147        .code(codes::INVALID_SHADOW_ROOT_MODE)
1148        .component(tag_name)
1149        .at_offset(source, offset)
1150        .snippet(source_line_snippet(source, offset))
1151        .help(help)
1152        .into()
1153}
1154
1155#[cold]
1156#[inline(never)]
1157fn light_dom_slot_error(tag_name: &str, site: DiagnosticSite<'_>) -> ParserError {
1158    let (source, offset) = site.resolve(DiagnosticTarget::Element("slot"));
1159    Diagnostic::error("native `<slot>` projection requires Shadow DOM")
1160        .code(codes::LIGHT_DOM_SLOT)
1161        .component(tag_name)
1162        .element("slot")
1163        .at_offset(source, offset)
1164        .snippet(source_line_snippet(source, offset))
1165        .help(
1166            "wrap the complete component template in `<template shadowrootmode=\"open\">` to use `<slot>`",
1167        )
1168        .into()
1169}
1170
1171#[derive(Clone, Copy)]
1172struct DiagnosticSite<'a> {
1173    source: &'a str,
1174    authored_source: Option<&'a str>,
1175    offset: usize,
1176}
1177
1178impl<'a> DiagnosticSite<'a> {
1179    fn new(source: &'a str, authored_source: Option<&'a str>, offset: usize) -> Self {
1180        Self {
1181            source,
1182            authored_source,
1183            offset,
1184        }
1185    }
1186
1187    fn resolve(self, target: DiagnosticTarget) -> (&'a str, usize) {
1188        let Some(authored_source) = self.authored_source else {
1189            return (self.source, self.offset);
1190        };
1191        let transformed_offsets = diagnostic_target_offsets(self.source, target);
1192        let occurrence = transformed_offsets
1193            .iter()
1194            .position(|offset| *offset == self.offset)
1195            .unwrap_or(0);
1196        let authored_offsets = diagnostic_target_offsets(authored_source, target);
1197        authored_offsets
1198            .get(occurrence)
1199            .copied()
1200            .map_or((self.source, self.offset), |offset| {
1201                (authored_source, offset)
1202            })
1203    }
1204}
1205
1206#[derive(Clone, Copy)]
1207enum DiagnosticTarget {
1208    Element(&'static str),
1209    Attribute(&'static str),
1210}
1211
1212#[cold]
1213#[inline(never)]
1214fn diagnostic_target_offsets(source: &str, target: DiagnosticTarget) -> Vec<usize> {
1215    let mut offsets = Vec::new();
1216    let mut ranges = Vec::with_capacity(4);
1217    ranges.push(0..source.len());
1218
1219    while let Some(range) = ranges.pop() {
1220        for event in Walker::new_range(source, range.start, range.end) {
1221            let Event::Element(element) = event else {
1222                continue;
1223            };
1224            match target {
1225                DiagnosticTarget::Element(name) if element.name().eq_ignore_ascii_case(name) => {
1226                    offsets.push(element.start);
1227                }
1228                DiagnosticTarget::Attribute(name) => {
1229                    for attr in element.attrs() {
1230                        let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1231                        if attr_name.eq_ignore_ascii_case(name) {
1232                            offsets.push(element.start + attr.raw_range.start);
1233                        }
1234                    }
1235                }
1236                DiagnosticTarget::Element(_) => {}
1237            }
1238
1239            let name = element.name();
1240            let raw_text = name.eq_ignore_ascii_case("script")
1241                || name.eq_ignore_ascii_case("style")
1242                || name.eq_ignore_ascii_case("textarea")
1243                || name.eq_ignore_ascii_case("title");
1244            if element.content_end() > element.inner().start && !raw_text {
1245                ranges.push(element.inner());
1246            }
1247        }
1248    }
1249    offsets.sort_unstable();
1250    offsets
1251}
1252
1253struct ComponentTemplateInput<'a> {
1254    tag_name: &'a str,
1255    html: &'a str,
1256    artifact_html: Option<&'a str>,
1257    authored_html: Option<&'a str>,
1258    css_content: Option<&'a str>,
1259    artifact_needed: bool,
1260}
1261
1262fn add_token_definitions<'a>(
1263    definitions: &'a [String],
1264    available_counts: &mut HashMap<&'a str, usize>,
1265) {
1266    for definition in definitions {
1267        let count = available_counts.entry(definition.as_str()).or_insert(0);
1268        *count += 1;
1269    }
1270}
1271
1272fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<&str, usize>) {
1273    for definition in definitions {
1274        if let Some(count) = available_counts.get_mut(definition.as_str()) {
1275            if *count == 1 {
1276                available_counts.remove(definition.as_str());
1277            } else {
1278                *count -= 1;
1279            }
1280        }
1281    }
1282}
1283
1284/// Accumulators for the token-graph walk: the unresolved fallback chains and
1285/// the per-token source locations, grouped so walk helpers stay within the
1286/// argument-count budget.
1287#[derive(Default)]
1288struct UnresolvedTokens {
1289    chains: Vec<CssFallbackChain>,
1290    sites: HashMap<String, TokenSite>,
1291}
1292
1293fn record_unresolved_requirements(
1294    source: &[CssFallbackChain],
1295    available_counts: &HashMap<&str, usize>,
1296    owner: &str,
1297    css_source: Option<&str>,
1298    out: &mut UnresolvedTokens,
1299) {
1300    for requirement in source {
1301        let tokens: Vec<String> = requirement
1302            .tokens
1303            .iter()
1304            .filter(|token| !available_counts.contains_key(token.as_str()))
1305            .cloned()
1306            .collect();
1307        if tokens.is_empty() {
1308            continue;
1309        }
1310        for token in &tokens {
1311            if !out.sites.contains_key(token) {
1312                out.sites
1313                    .insert(token.clone(), token_site(owner, css_source, token));
1314            }
1315        }
1316        out.chains.push(CssFallbackChain {
1317            tokens,
1318            has_literal_fallback: requirement.has_literal_fallback,
1319        });
1320    }
1321}
1322
1323/// Build the [`TokenSite`] for `token`. When `css_source` is available (a
1324/// component's standalone CSS), the token's `var()` usage is located for a
1325/// precise `line:column` and snippet; otherwise only the `owner` is recorded.
1326fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
1327    let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
1328        Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
1329        None => (None, None),
1330    };
1331    TokenSite {
1332        owner: owner.to_string(),
1333        position,
1334        snippet,
1335    }
1336}
1337
1338/// Locate the first `var(--token)` **usage** (not a `--token:` definition) in
1339/// `css`, returning its 1-based `(line, column)` and a one-line snippet.
1340///
1341/// Iterative byte scan — no regex, no recursion. Cold-ish (build time, only for
1342/// unresolved tokens).
1343fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
1344    let bytes = css.as_bytes();
1345    let name = token.as_bytes();
1346    let mut index = 0;
1347    while index + 2 + name.len() <= bytes.len() {
1348        let is_prefixed = bytes[index] == b'-'
1349            && bytes[index + 1] == b'-'
1350            && bytes.get(index + 2..index + 2 + name.len()) == Some(name);
1351        if is_prefixed {
1352            let after = index + 2 + name.len();
1353            let is_exact_name = bytes
1354                .get(after)
1355                .is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
1356            if is_exact_name {
1357                let mut cursor = after;
1358                while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
1359                    cursor += 1;
1360                }
1361                // `--token:` is a definition, not the usage we want to point at.
1362                if bytes.get(cursor) != Some(&b':') {
1363                    let (line, column) = diagnostic::line_column(css, index);
1364                    return Some((line, column, source_line_snippet(css, index)));
1365                }
1366            }
1367        }
1368        index += 1;
1369    }
1370    None
1371}
1372
1373/// Extract the trimmed line containing byte `offset`, capped so minified input
1374/// does not produce a wall-of-text snippet.
1375fn source_line_snippet(source: &str, offset: usize) -> String {
1376    const MAX_SNIPPET: usize = 80;
1377    let start = source[..offset].rfind('\n').map_or(0, |n| n + 1);
1378    let end = source[offset..]
1379        .find('\n')
1380        .map_or(source.len(), |n| offset + n);
1381    let line = source[start..end].trim();
1382    if line.chars().count() > MAX_SNIPPET {
1383        let truncated: String = line.chars().take(MAX_SNIPPET).collect();
1384        format!("{truncated}…")
1385    } else {
1386        line.to_string()
1387    }
1388}
1389
1390/// File-like owner label for a component's standalone CSS
1391/// (`my-card` → `my-card.css`), used as the `--> owner:line:column` prefix.
1392fn css_owner_label(tag_name: &str) -> String {
1393    let mut label = String::with_capacity(tag_name.len() + 4);
1394    label.push_str(tag_name);
1395    label.push_str(".css");
1396    label
1397}
1398
1399impl CssTokenAnalysis {
1400    /// Validate the required CSS tokens against a loaded design-token theme.
1401    ///
1402    /// A token is *required* in every theme only when it appears in at least one
1403    /// unresolved `var()` chain with **no** literal CSS fallback. A chain such as
1404    /// `var(--x, 16px)` provides its own value, so `--x` stays in
1405    /// [`protocol_tokens`](Self::protocol_tokens) for runtime resolution (the
1406    /// theme value still wins when present) but does not fail the build when the
1407    /// theme omits it. The chain/theme policy lives in
1408    /// [`webui_tokens::validate_chain_tokens`]; this only adapts its
1409    /// [`webui_tokens::TokenError`] into a structured [`Diagnostic`].
1410    ///
1411    /// # Errors
1412    ///
1413    /// Returns [`ParserError::Template`] with a stable diagnostic code when a
1414    /// required token is missing from a theme. Theme token values are trusted and
1415    /// their transitive references are left to browser CSS semantics.
1416    pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
1417        webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
1418            .map_err(|source| self.theme_token_error(source, theme))
1419    }
1420
1421    /// CSS tokens used **only** with a literal `var()` fallback and defined in no
1422    /// theme — likely typos that callers may surface as non-fatal advisories.
1423    ///
1424    /// Thin wrapper over [`webui_tokens::unthemed_literal_fallback_tokens`].
1425    #[must_use]
1426    pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
1427        webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
1428    }
1429
1430    /// Structured, color-free advisories for CSS tokens used only with a
1431    /// literal `var()` fallback and defined in no theme — almost always typos.
1432    ///
1433    /// Returned as warning-severity [`Diagnostic`]s so hosts render them with
1434    /// the same location/snippet/`help:` layout as errors (the CLI colorizes;
1435    /// other hosts use the plain [`Diagnostic::body`]). Each carries the token's
1436    /// source location and a `did you mean --…?` suggestion.
1437    #[must_use]
1438    pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
1439        let mut warnings = Vec::new();
1440        for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
1441            let help = match closest_theme_token(&token, theme.themes.values()) {
1442                Some(suggestion) => {
1443                    format!("did you mean --{suggestion}? otherwise the literal fallback is used")
1444                }
1445                None => format!(
1446                    "define --{token} in the theme, or keep its literal fallback if intentional"
1447                ),
1448            };
1449            let diag = self.locate_diagnostic(
1450                Diagnostic::warning(format!("unthemed CSS token --{token}"))
1451                    .code(codes::UNTHEMED_TOKEN)
1452                    .help(help),
1453                &token,
1454            );
1455            warnings.push(diag);
1456        }
1457        warnings
1458    }
1459
1460    /// Adapt a [`webui_tokens::TokenError`] into a structured [`Diagnostic`],
1461    /// enriching it with the token's source location and a `did you mean …?`
1462    /// suggestion drawn from the theme's own tokens.
1463    #[cold]
1464    #[inline(never)]
1465    fn theme_token_error(
1466        &self,
1467        source: webui_tokens::TokenError,
1468        theme: &webui_tokens::TokenFile,
1469    ) -> ParserError {
1470        match source {
1471            webui_tokens::TokenError::MissingToken {
1472                theme: theme_name,
1473                token,
1474            } => {
1475                // The error title + snippet already say the token is missing
1476                // from the theme, so the help only adds what isn't obvious: the
1477                // likely-typo suggestion and the local-definition escape hatch.
1478                let help =
1479                    match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
1480                        Some(suggestion) => {
1481                            format!("did you mean --{suggestion}? otherwise define it locally")
1482                        }
1483                        None => {
1484                            "define it locally if it should not come from the theme".to_string()
1485                        }
1486                    };
1487                self.locate_diagnostic(
1488                    Diagnostic::error("missing theme token")
1489                        .code(codes::MISSING_THEME_TOKEN)
1490                        .help(help),
1491                    &token,
1492                )
1493                .into()
1494            }
1495            other => ParserError::Generic(format!("Theme token validation failed: {other}")),
1496        }
1497    }
1498
1499    /// Attach `token`'s recorded source location (owner, `line:column`, snippet)
1500    /// to `diag`. Falls back to a `--token` snippet when no location is known.
1501    fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
1502        let Some(site) = self.token_sites.get(token) else {
1503            return diag.snippet(format!("--{token}"));
1504        };
1505        let mut diag = diag.component(site.owner.clone());
1506        if let Some((line, column)) = site.position {
1507            diag = diag.position(line, column);
1508        }
1509        match &site.snippet {
1510            Some(snippet) => diag.snippet(snippet.clone()),
1511            None => diag.snippet(format!("--{token}")),
1512        }
1513    }
1514}
1515
1516/// Closest theme token to `target` by Levenshtein distance across the given
1517/// theme token maps, considering each theme's keys. Sorted for determinism.
1518#[cold]
1519#[inline(never)]
1520fn closest_theme_token<'a>(
1521    target: &str,
1522    themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
1523) -> Option<String> {
1524    let mut names: Vec<&str> = themes
1525        .flat_map(|theme| theme.keys().map(String::as_str))
1526        .collect();
1527    names.sort_unstable();
1528    names.dedup();
1529    crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
1530}
1531
1532impl HtmlParser {
1533    /// Create a new directive parser with default parser options.
1534    #[must_use]
1535    pub fn new() -> Self {
1536        Self::with_options(ParserOptions::default())
1537    }
1538
1539    /// Create a new directive parser with explicit parser options.
1540    #[must_use]
1541    pub fn with_options(options: impl Into<ParserOptions>) -> Self {
1542        let options = options.into();
1543
1544        Self {
1545            component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
1546            css_parser: CssParser::new(),
1547            id_counter: FragmentIdCounter::new(),
1548            condition_parser: ConditionParser::new(),
1549            handlebars_parser: HandlebarsParser::new(),
1550            raw_buffer: String::new(),
1551            fragment_records: WebUIFragmentRecords::new(),
1552            warnings: Vec::new(),
1553            options,
1554            plugin: None,
1555            component_processing: ComponentProcessing::default(),
1556            component_dom_analyses: HashMap::new(),
1557            token_roots: Vec::new(),
1558            fragment_css_tokens: HashMap::new(),
1559            in_progress_fragments: HashSet::new(),
1560            current_fragment_id: String::new(),
1561            loop_owner_indices: HashMap::new(),
1562            named_for_loops: NamedForLoops::default(),
1563            named_for_fragment_ids: HashSet::new(),
1564            next_boundary_declaration_id: 0,
1565            boundary_names_by_owner: HashMap::new(),
1566            in_boundary: false,
1567            boundary_ancestor_depth: 0,
1568            body_depth: 0,
1569            for_depth: 0,
1570            route_depth: 0,
1571            foster_context_depth: 0,
1572            boundary_parent_scope: None,
1573            module_entry_srcs: Vec::new(),
1574        }
1575    }
1576
1577    /// Create a new parser with a plugin and default parser options.
1578    #[must_use]
1579    pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
1580        Self::with_plugin_options(plugin, ParserOptions::default())
1581    }
1582
1583    /// Create a new parser with a plugin and explicit parser options.
1584    #[must_use]
1585    pub fn with_plugin_options(
1586        mut plugin: Box<dyn ParserPlugin>,
1587        options: impl Into<ParserOptions>,
1588    ) -> Self {
1589        let mut p = Self::with_options(options);
1590        plugin.configure_parser(&p.options);
1591        let component_processing = plugin.component_processing();
1592        p.component_registry
1593            .set_component_source_transform(component_processing.source_transform);
1594        p.component_processing = component_processing;
1595        p.plugin = Some(plugin);
1596        p
1597    }
1598
1599    /// Get a mutable reference to the component registry.
1600    pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
1601        &mut self.component_registry
1602    }
1603
1604    /// Get a shared reference to the component registry.
1605    #[must_use]
1606    pub fn component_registry(&self) -> &ComponentRegistry {
1607        &self.component_registry
1608    }
1609
1610    pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
1611        std::mem::take(&mut self.fragment_records)
1612    }
1613
1614    /// Take non-fatal parser warnings collected so far.
1615    #[must_use]
1616    pub fn take_warnings(&mut self) -> Vec<Diagnostic> {
1617        std::mem::take(&mut self.warnings)
1618    }
1619
1620    /// Check if a fragment ID has been parsed (exists in the fragment records).
1621    pub fn has_fragment(&self, fragment_id: &str) -> bool {
1622        self.fragment_records.contains_key(fragment_id)
1623    }
1624
1625    /// Compute transitive boundary presence and conservative declaration
1626    /// repeatability for the complete parsed fragment graph, and reject every
1627    /// boundary a `<for>` repeat body can reach.
1628    ///
1629    /// This runs only after a top-level parse succeeds. Every analysis is
1630    /// iterative: presence propagates through reverse edges, repeat
1631    /// reachability is a multi-source forward walk, and occurrence counts use a
1632    /// two-value (once/many) fixed point. Cycles therefore converge without
1633    /// recursion and are conservatively classified as repeatable.
1634    #[cold]
1635    #[inline(never)]
1636    fn finalize_boundary_metadata(&mut self) -> Result<()> {
1637        if self.next_boundary_declaration_id != 0 {
1638            let (outgoing, direct_boundary_records) = self.boundary_graph();
1639            self.mark_boundary_records(&outgoing, direct_boundary_records);
1640            if let Some(violation) = self.find_repeat_reachable_boundary() {
1641                return Err(Self::repeat_reachable_boundary_error(&violation));
1642            }
1643            let occurrence_counts = self.boundary_occurrence_counts(&outgoing);
1644
1645            let mut unkeyed_repeat: Option<(u32, String, String)> = None;
1646            for (fragment_id, list) in &mut self.fragment_records {
1647                let repeated = occurrence_counts
1648                    .get(fragment_id)
1649                    .is_some_and(|count| *count > 1);
1650                if !repeated {
1651                    continue;
1652                }
1653                for fragment in &mut list.fragments {
1654                    if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_mut() {
1655                        if boundary.phase() == BoundaryPhase::Start {
1656                            boundary.may_repeat = true;
1657                            if boundary.key.is_none()
1658                                && unkeyed_repeat
1659                                    .as_ref()
1660                                    .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1661                            {
1662                                unkeyed_repeat = Some((
1663                                    boundary.declaration_id,
1664                                    boundary.owner_fragment_id.clone(),
1665                                    boundary.name.clone(),
1666                                ));
1667                            }
1668                        }
1669                    }
1670                }
1671            }
1672            if let Some((_, owner, name)) = unkeyed_repeat {
1673                return Err(Self::missing_boundary_key_error(&owner, &name));
1674            }
1675
1676            if let Some((owner, name)) = self.find_transitively_nested_boundary() {
1677                return Err(Self::transitively_nested_boundary_error(&owner, &name));
1678            }
1679        }
1680        self.finalize_component_span_signals();
1681        Ok(())
1682    }
1683
1684    fn finalize_component_span_signals(&mut self) {
1685        const START: &str = "streaming_span_start:";
1686        const END: &str = "streaming_span_end:";
1687
1688        let spanning: HashSet<String> = self
1689            .fragment_records
1690            .iter()
1691            .filter_map(|(id, list)| list.contains_boundary.then_some(id.clone()))
1692            .collect();
1693        for list in self.fragment_records.values_mut() {
1694            let mut normalized = Vec::with_capacity(list.fragments.len());
1695            for fragment in list.fragments.drain(..) {
1696                let remove = match fragment.fragment.as_ref() {
1697                    Some(Fragment::Signal(signal)) => signal
1698                        .value
1699                        .strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1700                        .and_then(|value| {
1701                            value
1702                                .strip_prefix(START)
1703                                .or_else(|| value.strip_prefix(END))
1704                        })
1705                        .is_some_and(|tag| !spanning.contains(tag)),
1706                    _ => false,
1707                };
1708                if remove {
1709                    continue;
1710                }
1711                if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
1712                    if let Some(WebUIFragment {
1713                        fragment: Some(Fragment::Raw(previous)),
1714                    }) = normalized.last_mut()
1715                    {
1716                        previous.value.push_str(&raw.value);
1717                        continue;
1718                    }
1719                }
1720                normalized.push(fragment);
1721            }
1722            list.fragments = normalized;
1723        }
1724    }
1725
1726    fn boundary_graph(&self) -> (HashMap<String, Vec<BoundaryGraphEdge>>, Vec<String>) {
1727        let mut outgoing: HashMap<String, Vec<BoundaryGraphEdge>> =
1728            HashMap::with_capacity(self.fragment_records.len());
1729        let mut direct_boundary_records = Vec::new();
1730
1731        for (fragment_id, list) in &self.fragment_records {
1732            let edges = outgoing.entry(fragment_id.clone()).or_default();
1733            for fragment in &list.fragments {
1734                match fragment.fragment.as_ref() {
1735                    Some(Fragment::Component(component)) => {
1736                        Self::push_boundary_edge(edges, &component.fragment_id);
1737                    }
1738                    Some(Fragment::ForLoop(for_loop)) => {
1739                        Self::push_boundary_edge(edges, &for_loop.fragment_id);
1740                    }
1741                    Some(Fragment::IfCond(if_cond)) => {
1742                        Self::push_boundary_edge(edges, &if_cond.fragment_id);
1743                    }
1744                    Some(Fragment::Boundary(boundary)) => {
1745                        if boundary.phase() == BoundaryPhase::Start {
1746                            direct_boundary_records.push(fragment_id.clone());
1747                        }
1748                    }
1749                    Some(Fragment::Route(route)) => {
1750                        Self::push_route_boundary_edges(edges, route);
1751                    }
1752                    _ => {}
1753                }
1754            }
1755        }
1756        (outgoing, direct_boundary_records)
1757    }
1758
1759    fn push_boundary_edge(edges: &mut Vec<BoundaryGraphEdge>, target: &str) {
1760        if !target.is_empty() {
1761            edges.push(BoundaryGraphEdge {
1762                target: target.to_string(),
1763            });
1764        }
1765    }
1766
1767    fn push_route_boundary_edges(edges: &mut Vec<BoundaryGraphEdge>, root: &WebUiFragmentRoute) {
1768        let mut pending = vec![root];
1769        while let Some(route) = pending.pop() {
1770            Self::push_boundary_edge(edges, &route.fragment_id);
1771            Self::push_boundary_edge(edges, &route.content_fragment_id);
1772            Self::push_boundary_edge(edges, &route.pending_component);
1773            Self::push_boundary_edge(edges, &route.error_component);
1774            pending.extend(route.children.iter());
1775        }
1776    }
1777
1778    fn mark_boundary_records(
1779        &mut self,
1780        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1781        direct_boundary_records: Vec<String>,
1782    ) {
1783        for list in self.fragment_records.values_mut() {
1784            list.contains_boundary = false;
1785        }
1786
1787        let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
1788        for (parent, edges) in outgoing {
1789            for edge in edges {
1790                reverse
1791                    .entry(edge.target.as_str())
1792                    .or_default()
1793                    .push(parent.as_str());
1794            }
1795        }
1796
1797        let mut marked = HashSet::with_capacity(self.fragment_records.len());
1798        let mut pending = direct_boundary_records;
1799        while let Some(fragment_id) = pending.pop() {
1800            if !marked.insert(fragment_id.clone()) {
1801                continue;
1802            }
1803            if let Some(list) = self.fragment_records.get_mut(&fragment_id) {
1804                list.contains_boundary = true;
1805            }
1806            if let Some(parents) = reverse.get(fragment_id.as_str()) {
1807                pending.extend(parents.iter().map(|parent| (*parent).to_string()));
1808            }
1809        }
1810    }
1811
1812    fn boundary_occurrence_counts(
1813        &self,
1814        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1815    ) -> HashMap<String, u8> {
1816        let mut incoming: HashMap<&str, Vec<&str>> = HashMap::new();
1817        for (parent, edges) in outgoing {
1818            for edge in edges {
1819                incoming
1820                    .entry(edge.target.as_str())
1821                    .or_default()
1822                    .push(parent.as_str());
1823            }
1824        }
1825
1826        let mut aggregate = HashMap::with_capacity(self.fragment_records.len());
1827        for root in &self.token_roots {
1828            if !self.fragment_records.contains_key(root) {
1829                continue;
1830            }
1831            let counts = self.boundary_occurrences_from_root(root.as_str(), outgoing, &incoming);
1832            for (fragment_id, count) in counts {
1833                let current = aggregate.entry(fragment_id).or_insert(0);
1834                *current = (*current).max(count);
1835            }
1836        }
1837        aggregate
1838    }
1839
1840    fn boundary_occurrences_from_root(
1841        &self,
1842        root: &str,
1843        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1844        incoming: &HashMap<&str, Vec<&str>>,
1845    ) -> HashMap<String, u8> {
1846        let mut counts: HashMap<String, u8> = HashMap::with_capacity(self.fragment_records.len());
1847        counts.insert(root.to_string(), 1);
1848        let mut pending = vec![root.to_string()];
1849        while let Some(parent) = pending.pop() {
1850            let Some(edges) = outgoing.get(&parent) else {
1851                continue;
1852            };
1853            for edge in edges {
1854                let next = Self::recompute_boundary_occurrences(
1855                    edge.target.as_str(),
1856                    root,
1857                    incoming,
1858                    &counts,
1859                );
1860                let current = counts.get(&edge.target).copied().unwrap_or(0);
1861                if next > current {
1862                    counts.insert(edge.target.clone(), next);
1863                    pending.push(edge.target.clone());
1864                }
1865            }
1866        }
1867        counts
1868    }
1869
1870    fn recompute_boundary_occurrences(
1871        fragment_id: &str,
1872        root: &str,
1873        incoming: &HashMap<&str, Vec<&str>>,
1874        counts: &HashMap<String, u8>,
1875    ) -> u8 {
1876        let mut count = u8::from(fragment_id == root);
1877        let Some(parents) = incoming.get(fragment_id) else {
1878            return count;
1879        };
1880        for parent in parents {
1881            let parent_count = counts.get(*parent).copied().unwrap_or(0);
1882            count = count.saturating_add(parent_count).min(2);
1883            if count == 2 {
1884                break;
1885            }
1886        }
1887        count
1888    }
1889
1890    /// Locate the lowest-numbered declaration a `<for>` repeat body reaches.
1891    ///
1892    /// Reachability is a single multi-source forward walk seeded with every
1893    /// repeat body record, so a graph with many loops still costs one pass. It
1894    /// follows the same component/condition/loop/route edges the presence
1895    /// analysis uses, plus the route-child records an enclosing route mounts at
1896    /// its component's `<outlet />` — the one runtime edge that has no
1897    /// lexical counterpart.
1898    fn find_repeat_reachable_boundary(&self) -> Option<RepeatBoundaryViolation> {
1899        let mut origin: HashMap<&str, &str> = HashMap::new();
1900        let mut pending: Vec<&str> = Vec::new();
1901        let mut repeats: HashMap<&str, RepeatSite<'_>> = HashMap::new();
1902        let mut seeds: Vec<(&str, RepeatSite<'_>)> = Vec::new();
1903
1904        for (owner, list) in &self.fragment_records {
1905            for fragment in &list.fragments {
1906                let Some(Fragment::ForLoop(for_loop)) = fragment.fragment.as_ref() else {
1907                    continue;
1908                };
1909                if for_loop.fragment_id.is_empty() {
1910                    continue;
1911                }
1912                let body = for_loop.fragment_id.as_str();
1913                seeds.push((
1914                    body,
1915                    RepeatSite {
1916                        owner: owner.as_str(),
1917                        item: for_loop.item.as_str(),
1918                        collection: for_loop.collection.as_str(),
1919                    },
1920                ));
1921            }
1922        }
1923        seeds.sort_unstable_by(|(left_body, left), (right_body, right)| {
1924            (left.owner, left.item, left.collection, *left_body).cmp(&(
1925                right.owner,
1926                right.item,
1927                right.collection,
1928                *right_body,
1929            ))
1930        });
1931        for (body, site) in seeds {
1932            if !origin.contains_key(body) {
1933                repeats.insert(body, site);
1934                origin.insert(body, body);
1935                pending.push(body);
1936            }
1937        }
1938        // `pending` is a LIFO stack. Reverse the sorted seeds so the lowest
1939        // repeat site owns every shared reachable record deterministically.
1940        pending.reverse();
1941        if pending.is_empty() {
1942            return None;
1943        }
1944
1945        let mounts = self.route_mount_targets();
1946        let mut found: Option<(u32, &str, &WebUiFragmentBoundary)> = None;
1947        let mut targets: Vec<&str> = Vec::new();
1948        while let Some(id) = pending.pop() {
1949            let Some(seed) = origin.get(id).copied() else {
1950                continue;
1951            };
1952            let Some(list) = self.fragment_records.get(id) else {
1953                continue;
1954            };
1955            for fragment in &list.fragments {
1956                if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
1957                    if boundary.phase() == BoundaryPhase::Start
1958                        && found
1959                            .as_ref()
1960                            .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1961                    {
1962                        found = Some((boundary.declaration_id, seed, boundary));
1963                    }
1964                }
1965            }
1966            targets.clear();
1967            push_child_records(list, &mut targets);
1968            if let Some(mounted) = mounts.get(id) {
1969                targets.extend(mounted.iter().copied());
1970            }
1971            for target in targets.drain(..) {
1972                if target.is_empty() {
1973                    continue;
1974                }
1975                if !origin.contains_key(target) {
1976                    origin.insert(target, seed);
1977                    pending.push(target);
1978                }
1979            }
1980        }
1981
1982        found.map(|(_, seed, boundary)| {
1983            let site = repeats.get(seed);
1984            RepeatBoundaryViolation {
1985                repeat_owner: site.map_or_else(String::new, |site| site.owner.to_string()),
1986                each: site.map_or_else(String::new, |site| {
1987                    format!("{} in {}", site.item, site.collection)
1988                }),
1989                boundary_owner: boundary.owner_fragment_id.clone(),
1990                boundary_name: boundary.name.clone(),
1991            }
1992        })
1993    }
1994
1995    /// Map every `<outlet />`-hosting record to the child-route records a
1996    /// route can mount there.
1997    ///
1998    /// `<outlet />` is the only render edge with no lexical counterpart: the
1999    /// mounted record is chosen from the enclosing route's children at request
2000    /// time. Resolving it needs the parent route's render closure, so the map
2001    /// is built once and reused by the repeat walk instead of being recomputed
2002    /// per outlet.
2003    fn route_mount_targets(&self) -> HashMap<&str, Vec<&str>> {
2004        let outlet_hosts = self.outlet_host_records();
2005        if outlet_hosts.is_empty() {
2006            return HashMap::new();
2007        }
2008
2009        let mut mounts: HashMap<&str, Vec<&str>> = HashMap::new();
2010        let mut children: Vec<&str> = Vec::new();
2011        let mut closure: HashSet<&str> = HashSet::new();
2012        let mut pending: Vec<&str> = Vec::new();
2013        let mut targets: Vec<&str> = Vec::new();
2014        for list in self.fragment_records.values() {
2015            for fragment in &list.fragments {
2016                let Some(Fragment::Route(root)) = fragment.fragment.as_ref() else {
2017                    continue;
2018                };
2019                let mut routes = vec![root];
2020                while let Some(route) = routes.pop() {
2021                    routes.extend(route.children.iter());
2022                    if route.children.is_empty() {
2023                        continue;
2024                    }
2025                    children.clear();
2026                    for child in &route.children {
2027                        push_route_records(&mut children, child);
2028                    }
2029                    closure.clear();
2030                    pending.clear();
2031                    for host in [
2032                        route.fragment_id.as_str(),
2033                        route.content_fragment_id.as_str(),
2034                    ] {
2035                        if !host.is_empty() && closure.insert(host) {
2036                            pending.push(host);
2037                        }
2038                    }
2039                    while let Some(id) = pending.pop() {
2040                        if outlet_hosts.contains(id) {
2041                            mounts
2042                                .entry(id)
2043                                .or_default()
2044                                .extend(children.iter().copied());
2045                        }
2046                        let Some(list) = self.fragment_records.get(id) else {
2047                            continue;
2048                        };
2049                        targets.clear();
2050                        push_child_records(list, &mut targets);
2051                        for target in targets.drain(..) {
2052                            if !target.is_empty() && closure.insert(target) {
2053                                pending.push(target);
2054                            }
2055                        }
2056                    }
2057                }
2058            }
2059        }
2060        mounts
2061    }
2062
2063    /// Records that render an `<outlet />`.
2064    fn outlet_host_records(&self) -> HashSet<&str> {
2065        self.fragment_records
2066            .iter()
2067            .filter(|(_, list)| {
2068                list.fragments
2069                    .iter()
2070                    .any(|fragment| matches!(fragment.fragment.as_ref(), Some(Fragment::Outlet(_))))
2071            })
2072            .map(|(id, _)| id.as_str())
2073            .collect()
2074    }
2075
2076    /// Locate the lowest-numbered declaration whose inline body reaches another
2077    /// boundary through a component, condition, loop, or route record.
2078    ///
2079    /// Lexical nesting is already rejected while parsing, so this only has to
2080    /// look at the child records referenced between a start and end marker.
2081    fn find_transitively_nested_boundary(&self) -> Option<(String, String)> {
2082        let mut nested: Option<(u32, &str, &str)> = None;
2083        for list in self.fragment_records.values() {
2084            let mut active: Option<&WebUiFragmentBoundary> = None;
2085            for fragment in &list.fragments {
2086                let Some(boundary) = active else {
2087                    if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
2088                        if boundary.phase() == BoundaryPhase::Start {
2089                            active = Some(boundary);
2090                        }
2091                    }
2092                    continue;
2093                };
2094                if let Some(Fragment::Boundary(marker)) = fragment.fragment.as_ref() {
2095                    if marker.phase() == BoundaryPhase::End {
2096                        active = None;
2097                    }
2098                    continue;
2099                }
2100                if !self.fragment_reaches_boundary(fragment) {
2101                    continue;
2102                }
2103                if nested
2104                    .as_ref()
2105                    .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
2106                {
2107                    nested = Some((
2108                        boundary.declaration_id,
2109                        boundary.owner_fragment_id.as_str(),
2110                        boundary.name.as_str(),
2111                    ));
2112                }
2113            }
2114        }
2115        nested.map(|(_, owner, name)| (owner.to_string(), name.to_string()))
2116    }
2117
2118    /// Whether a fragment inside a boundary body reaches a nested declaration
2119    /// through one of its child records.
2120    fn fragment_reaches_boundary(&self, fragment: &WebUIFragment) -> bool {
2121        let reaches = |id: &str| {
2122            !id.is_empty()
2123                && self
2124                    .fragment_records
2125                    .get(id)
2126                    .is_some_and(|list| list.contains_boundary)
2127        };
2128        match fragment.fragment.as_ref() {
2129            Some(Fragment::Component(component)) => reaches(&component.fragment_id),
2130            Some(Fragment::ForLoop(for_loop)) => reaches(&for_loop.fragment_id),
2131            Some(Fragment::IfCond(if_cond)) => reaches(&if_cond.fragment_id),
2132            Some(Fragment::Route(route)) => {
2133                let mut pending = vec![route];
2134                while let Some(current) = pending.pop() {
2135                    if reaches(&current.fragment_id)
2136                        || reaches(&current.content_fragment_id)
2137                        || reaches(&current.pending_component)
2138                        || reaches(&current.error_component)
2139                    {
2140                        return true;
2141                    }
2142                    pending.extend(current.children.iter());
2143                }
2144                false
2145            }
2146            _ => false,
2147        }
2148    }
2149
2150    #[cold]
2151    #[inline(never)]
2152    fn transitively_nested_boundary_error(owner: &str, name: &str) -> ParserError {
2153        Diagnostic::error(format!(
2154            "boundary \"{name}\" transitively contains another boundary"
2155        ))
2156        .code(codes::NESTED_BOUNDARY)
2157        .component(owner)
2158        .element("boundary")
2159        .snippet(format!("name=\"{name}\""))
2160        .help("remove the inner declaration or move it outside the enclosing boundary, including declarations reached through components and runtime branches")
2161        .into()
2162    }
2163
2164    /// Build the error for a boundary a `<for>` repeat body reaches through a
2165    /// component, condition, route, or outlet.
2166    #[cold]
2167    #[inline(never)]
2168    fn repeat_reachable_boundary_error(violation: &RepeatBoundaryViolation) -> ParserError {
2169        let RepeatBoundaryViolation {
2170            repeat_owner,
2171            each,
2172            boundary_owner,
2173            boundary_name,
2174        } = violation;
2175        Diagnostic::error(format!(
2176            "boundary \"{boundary_name}\" in {boundary_owner} is reachable from a <for> repeat body"
2177        ))
2178        .code(codes::BOUNDARY_IN_REPEAT)
2179        .component(repeat_owner)
2180        .element("for")
2181        .snippet(format!("each=\"{each}\""))
2182        .help(
2183            "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
2184             whole <for> in one <boundary> to render the list as a single independently paced \
2185             region — this includes declarations reached through components, runtime branches, \
2186             routes, and outlets",
2187        )
2188        .into()
2189    }
2190
2191    /// `src` of every authored `<script type="module">` outside any
2192    /// `<boundary>`, in document order, from the most recent top-level
2193    /// [`HtmlParser::parse`] call.
2194    ///
2195    /// These are the page's critical module entries — the ones whose shared
2196    /// chunks belong in `<link rel="modulepreload">`. Island loaders placed
2197    /// inside a boundary are excluded, because deferring them is the point.
2198    ///
2199    /// A `src` containing `{{` is skipped: it is a binding resolved per
2200    /// request, so no build-time artifact can be matched to it.
2201    #[must_use]
2202    pub fn module_entry_srcs(&self) -> &[String] {
2203        &self.module_entry_srcs
2204    }
2205
2206    /// Iterate component tags and whether each effectively uses Shadow DOM.
2207    ///
2208    /// This remains available independently of parser plugins so protocol
2209    /// builders can persist effective per-component ownership in plugin-free builds.
2210    pub fn component_shadow_dom_usage(&self) -> impl Iterator<Item = (&str, bool)> {
2211        self.component_dom_analyses
2212            .iter()
2213            .map(|(tag_name, analysis)| (tag_name.as_str(), analysis.uses_shadow_dom))
2214    }
2215
2216    /// Iterate non-eager component work-policy codes.
2217    pub fn component_work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
2218        self.component_registry.work_policies()
2219    }
2220
2221    /// Take any post-parse artifacts captured by the parser plugin.
2222    ///
2223    /// # Errors
2224    ///
2225    /// Returns [`ParserError::Template`] if a tracked component contains an
2226    /// invalid `@event` handler or a non-braced `w-ref` binding.
2227    pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
2228        self.plugin
2229            .take()
2230            .map_or(Ok(ParserPluginArtifacts::None), |plugin| plugin.finish())
2231    }
2232
2233    /// Take the accumulated CSS tokens as a sorted, deduplicated `Vec`.
2234    ///
2235    /// Convenience wrapper around [`HtmlParser::token_analysis`].
2236    #[must_use]
2237    pub fn take_tokens(&mut self) -> Vec<String> {
2238        self.token_analysis().protocol_tokens
2239    }
2240
2241    /// Analyze CSS token requirements from the parsed fragment/component graph.
2242    ///
2243    /// Each token candidate in a `var()` fallback chain is removed when covered
2244    /// by a same-block definition or an unconditional root/host default in the
2245    /// current fragment or an ancestor component/root. The returned list is
2246    /// sorted and deduplicated from the remaining unresolved candidates.
2247    #[must_use]
2248    pub fn token_analysis(&self) -> CssTokenAnalysis {
2249        let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
2250        let mut protocol_token_set = HashSet::new();
2251        for chain in &fallback_chains {
2252            for token in &chain.tokens {
2253                protocol_token_set.insert(token.as_str());
2254            }
2255        }
2256        let mut protocol_tokens: Vec<String> =
2257            protocol_token_set.into_iter().map(str::to_owned).collect();
2258        protocol_tokens.sort();
2259        CssTokenAnalysis {
2260            protocol_tokens,
2261            fallback_chains,
2262            token_sites,
2263        }
2264    }
2265
2266    fn collect_unresolved_fallback_chains(
2267        &self,
2268    ) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
2269        let mut out = UnresolvedTokens::default();
2270        let mut available_counts: HashMap<&str, usize> = HashMap::new();
2271        let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
2272        let mut active_fragments = HashSet::new();
2273        let mut visits =
2274            (!self.named_for_fragment_ids.is_empty()).then(scoped_visits::ScopedVisits::default);
2275        for root in self.token_roots.iter().rev() {
2276            ops.push(TokenGraphOp::EnterFragment(root.as_str()));
2277        }
2278
2279        while let Some(op) = ops.pop() {
2280            match op {
2281                TokenGraphOp::EnterFragment(fragment_id) => {
2282                    if let Some(visits) = &mut visits {
2283                        if active_fragments.contains(fragment_id)
2284                            || !visits.insert(fragment_id, available_counts.keys().copied())
2285                        {
2286                            continue;
2287                        }
2288                        active_fragments.insert(fragment_id);
2289                        ops.push(TokenGraphOp::ExitFragment(fragment_id));
2290                    }
2291                    self.enter_token_fragment(
2292                        fragment_id,
2293                        &mut available_counts,
2294                        &mut out,
2295                        &mut ops,
2296                    );
2297                }
2298                TokenGraphOp::ExitFragment(fragment_id) => {
2299                    active_fragments.remove(fragment_id);
2300                }
2301                TokenGraphOp::EnterComponent(tag_name) => {
2302                    self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
2303                }
2304                TokenGraphOp::EnterRoute(route) => {
2305                    self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
2306                }
2307                TokenGraphOp::ExitDefinitions(definitions) => {
2308                    remove_token_definitions(definitions, &mut available_counts);
2309                }
2310            }
2311        }
2312
2313        (out.chains, out.sites)
2314    }
2315
2316    fn enter_token_fragment<'a>(
2317        &'a self,
2318        fragment_id: &'a str,
2319        available_counts: &mut HashMap<&'a str, usize>,
2320        out: &mut UnresolvedTokens,
2321        ops: &mut Vec<TokenGraphOp<'a>>,
2322    ) {
2323        if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
2324            add_token_definitions(&css.definitions, available_counts);
2325            // Inline `<style>` tokens record their owning fragment (the entry
2326            // file). A fragment may carry several `<style>` blocks, so an
2327            // offset into one body is ambiguous — record the owner only.
2328            record_unresolved_requirements(
2329                &css.fallback_chains,
2330                available_counts,
2331                fragment_id,
2332                None,
2333                out,
2334            );
2335            ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
2336        }
2337
2338        let Some(fragments) = self.fragment_records.get(fragment_id) else {
2339            return;
2340        };
2341        for fragment in fragments.fragments.iter().rev() {
2342            match fragment.fragment.as_ref() {
2343                Some(web_ui_fragment::Fragment::Component(component)) => {
2344                    ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
2345                }
2346                Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
2347                    ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
2348                }
2349                Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
2350                    ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
2351                }
2352                Some(web_ui_fragment::Fragment::Route(route)) => {
2353                    ops.push(TokenGraphOp::EnterRoute(route));
2354                }
2355                _ => {}
2356            }
2357        }
2358    }
2359
2360    fn enter_token_component<'a>(
2361        &'a self,
2362        tag_name: &'a str,
2363        available_counts: &mut HashMap<&'a str, usize>,
2364        out: &mut UnresolvedTokens,
2365        ops: &mut Vec<TokenGraphOp<'a>>,
2366    ) {
2367        let Some(component) = self.component_registry.get(tag_name) else {
2368            return;
2369        };
2370        add_token_definitions(&component.css_definitions, available_counts);
2371        record_unresolved_requirements(
2372            &component.css_fallback_chains,
2373            available_counts,
2374            &css_owner_label(tag_name),
2375            self.component_registry.diagnostic_css_content(tag_name),
2376            out,
2377        );
2378        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2379        ops.push(TokenGraphOp::EnterFragment(tag_name));
2380    }
2381
2382    fn enter_token_route<'a>(
2383        &'a self,
2384        route: &'a WebUiFragmentRoute,
2385        available_counts: &mut HashMap<&'a str, usize>,
2386        out: &mut UnresolvedTokens,
2387        ops: &mut Vec<TokenGraphOp<'a>>,
2388    ) {
2389        if !route.content_fragment_id.is_empty() {
2390            ops.push(TokenGraphOp::EnterFragment(
2391                route.content_fragment_id.as_str(),
2392            ));
2393        }
2394        let Some(component) = self.component_registry.get(&route.fragment_id) else {
2395            return;
2396        };
2397        add_token_definitions(&component.css_definitions, available_counts);
2398        record_unresolved_requirements(
2399            &component.css_fallback_chains,
2400            available_counts,
2401            &css_owner_label(&route.fragment_id),
2402            self.component_registry
2403                .diagnostic_css_content(&route.fragment_id),
2404            out,
2405        );
2406        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2407        if !route.error_component.is_empty() {
2408            ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
2409        }
2410        if !route.pending_component.is_empty() {
2411            ops.push(TokenGraphOp::EnterComponent(
2412                route.pending_component.as_str(),
2413            ));
2414        }
2415        for child in route.children.iter().rev() {
2416            ops.push(TokenGraphOp::EnterRoute(child));
2417        }
2418        ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
2419    }
2420
2421    fn record_fragment_css_tokens(
2422        &mut self,
2423        definitions: HashSet<String>,
2424        fallback_chains: Vec<CssFallbackChain>,
2425    ) {
2426        let css = self
2427            .fragment_css_tokens
2428            .entry(self.current_fragment_id.clone())
2429            .or_default();
2430        css.definitions.extend(definitions);
2431        css.definitions.sort();
2432        css.definitions.dedup();
2433        css.fallback_chains.extend(fallback_chains);
2434    }
2435
2436    /// Parse HTML content to generate WebUI fragments.
2437    pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2438        let fragment_key = fragment_id.to_string();
2439        let is_token_root = self.in_progress_fragments.is_empty();
2440        if is_token_root {
2441            self.module_entry_srcs.clear();
2442        }
2443        self.boundary_names_by_owner.remove(&fragment_key);
2444        // Save the caller's fragment id and restore it before returning. A
2445        // component parse recurses through `enter_component_directive`
2446        // (`self.parse(child, …)`); without restoring, the parent would keep
2447        // parsing with `current_fragment_id` pointing at the child, so a later
2448        // diagnostic in the parent would be attributed to the wrong owner.
2449        let previous_fragment_id =
2450            std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
2451        if !self.in_progress_fragments.insert(fragment_key.clone()) {
2452            let err = self
2453                .authoring_error(
2454                    codes::RECURSIVE_TEMPLATE,
2455                    format!("recursive template reference while parsing <{fragment_id}>"),
2456                )
2457                .help(
2458                    "move the recursive usage behind runtime data, or split the component graph \
2459                     so templates do not reference themselves at build time",
2460                )
2461                .into();
2462            self.current_fragment_id = previous_fragment_id;
2463            return Err(err);
2464        }
2465        if is_token_root && !self.token_roots.contains(&fragment_key) {
2466            self.token_roots.push(fragment_key.clone());
2467        }
2468
2469        // Lexical directive/HTML context belongs to one authored template.
2470        // Component parsing may recurse while its callsite is inside a loop or
2471        // boundary, but those callsite properties are computed later by the
2472        // fragment graph analysis rather than leaking into the component's
2473        // authored syntax.
2474        let previous_in_boundary = std::mem::replace(&mut self.in_boundary, false);
2475        let previous_boundary_ancestor_depth = self.boundary_ancestor_depth;
2476        if previous_in_boundary {
2477            self.boundary_ancestor_depth += 1;
2478        }
2479        let previous_body_depth = std::mem::replace(&mut self.body_depth, 0);
2480        let previous_for_depth = std::mem::replace(&mut self.for_depth, 0);
2481        let previous_route_depth = std::mem::replace(&mut self.route_depth, 0);
2482        let previous_foster_depth = std::mem::replace(&mut self.foster_context_depth, 0);
2483        let previous_parent_scope = self.boundary_parent_scope.take();
2484        let previous_named_for_loops = std::mem::take(&mut self.named_for_loops);
2485        let owner_index = self.loop_owner_indices.len() + 1;
2486        self.loop_owner_indices
2487            .entry(fragment_key.clone())
2488            .or_insert(owner_index);
2489
2490        let mut result = self.parse_inner(fragment_id, html_content);
2491        if is_token_root && result.is_ok() {
2492            result = self.finalize_boundary_metadata();
2493            if result.is_ok() {
2494                self.finalize_outlet_warnings();
2495            }
2496        }
2497
2498        self.in_boundary = previous_in_boundary;
2499        self.boundary_ancestor_depth = previous_boundary_ancestor_depth;
2500        self.body_depth = previous_body_depth;
2501        self.for_depth = previous_for_depth;
2502        self.route_depth = previous_route_depth;
2503        self.foster_context_depth = previous_foster_depth;
2504        self.boundary_parent_scope = previous_parent_scope;
2505        self.named_for_loops = previous_named_for_loops;
2506        self.in_progress_fragments.remove(&fragment_key);
2507        self.current_fragment_id = previous_fragment_id;
2508        result
2509    }
2510
2511    fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2512        if html_content.len() > MAX_TEMPLATE_BYTES {
2513            return Err(ParserError::Html(format!(
2514                "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.",
2515                html_content.len()
2516            )));
2517        }
2518
2519        // Reset sub-fragments for new parse
2520        self.raw_buffer.clear();
2521        if let Some(ref mut plugin) = self.plugin {
2522            plugin.begin_fragment(FragmentContext { id: fragment_id });
2523        }
2524
2525        let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
2526        self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
2527        self.named_for_loops.validate(fragment_id, html_content)?;
2528
2529        self.flush_raw_buffer(&mut entry_fragment);
2530
2531        self.fragment_records.insert(
2532            fragment_id.to_string(),
2533            FragmentList {
2534                fragments: entry_fragment,
2535                contains_boundary: false,
2536            },
2537        );
2538
2539        Ok(())
2540    }
2541
2542    /// Add raw content to the buffer
2543    fn add_raw_fragment(&mut self, content: &str) {
2544        if !content.is_empty() {
2545            self.raw_buffer.push_str(content);
2546        }
2547    }
2548
2549    /// Add a for fragment, flushing raw buffer first
2550    fn add_for_fragment(
2551        &mut self,
2552        item: String,
2553        collection: String,
2554        fragment_id: String,
2555        fragments: &mut Vec<WebUIFragment>,
2556    ) {
2557        self.flush_raw_buffer(fragments);
2558        fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
2559    }
2560
2561    /// Add an if fragment, flushing raw buffer first
2562    fn add_if_fragment(
2563        &mut self,
2564        condition: ConditionExpr,
2565        fragment_id: String,
2566        fragments: &mut Vec<WebUIFragment>,
2567    ) {
2568        self.flush_raw_buffer(fragments);
2569        fragments.push(WebUIFragment::if_cond(condition, fragment_id));
2570    }
2571
2572    /// Add a non-raw fragment, flushing the raw buffer first if needed
2573    fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
2574        self.flush_raw_buffer(fragments);
2575        fragments.push(fragment);
2576    }
2577
2578    /// Flush the raw buffer into fragments if not empty
2579    fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
2580        if !self.raw_buffer.is_empty() {
2581            fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
2582        }
2583    }
2584
2585    /// Returns true when a text node should be emitted into the fragment stream.
2586    ///
2587    /// Pure formatting runs that contain line breaks are still dropped, but
2588    /// inline whitespace-only separators such as the spaces around `&gt;` in
2589    /// `{{sectionName}} &gt; {{topicName}}` must be preserved.
2590    fn should_emit_text_content(content: &str) -> bool {
2591        !content.is_empty() && !is_formatting_whitespace(content)
2592    }
2593
2594    fn parse_range(
2595        &mut self,
2596        source: &str,
2597        range: Range<usize>,
2598        fragments: &mut Vec<WebUIFragment>,
2599        depth: usize,
2600    ) -> Result<()> {
2601        let mut ops = vec![ParseOp::Parse { range, depth }];
2602        let mut outlet_seen = false;
2603
2604        while let Some(op) = ops.pop() {
2605            match op {
2606                ParseOp::Parse { range, depth } => {
2607                    if depth > MAX_TEMPLATE_DEPTH {
2608                        return Err(self
2609                            .html_error(
2610                                codes::EXCESSIVE_NESTING,
2611                                format!(
2612                                    "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
2613                                ),
2614                                source,
2615                                range.start,
2616                            )
2617                            .help(
2618                                "split deeply nested markup into components, or reduce generated nesting",
2619                            )
2620                            .into());
2621                    }
2622
2623                    let end = range.end;
2624                    let mut index = range.start;
2625                    while index < end {
2626                        let remaining = &source[index..end];
2627                        if remaining.starts_with("<!--") {
2628                            let Some(close) = html::find_comment_close(remaining) else {
2629                                return Err(self
2630                                    .html_error(
2631                                        codes::UNTERMINATED_HTML_COMMENT,
2632                                        "unterminated HTML comment",
2633                                        source,
2634                                        index,
2635                                    )
2636                                    .help("close the comment with `-->`")
2637                                    .into());
2638                            };
2639                            index += close;
2640                            continue;
2641                        }
2642
2643                        if remaining.starts_with("<!") {
2644                            let Some(close) = html::find_declaration_close(remaining) else {
2645                                return Err(self
2646                                    .html_error(
2647                                        codes::UNTERMINATED_HTML_DECLARATION,
2648                                        "unterminated HTML declaration",
2649                                        source,
2650                                        index,
2651                                    )
2652                                    .help("close the declaration with `>`")
2653                                    .into());
2654                            };
2655                            self.add_raw_fragment(&remaining[..close]);
2656                            index += close;
2657                            continue;
2658                        }
2659
2660                        if remaining.starts_with('<') {
2661                            let Some(tag) = html::parse_tag(remaining) else {
2662                                return Err(self
2663                                    .html_error(
2664                                        codes::MALFORMED_HTML_TAG,
2665                                        "malformed HTML tag",
2666                                        source,
2667                                        index,
2668                                    )
2669                                    .help(
2670                                        "close the tag with `>`, or escape a literal `<` as `&lt;`",
2671                                    )
2672                                    .into());
2673                            };
2674                            if tag.closing {
2675                                return Err(self
2676                                    .html_error(
2677                                        codes::UNEXPECTED_CLOSING_TAG,
2678                                        format!("unexpected closing tag </{}>", tag.name),
2679                                        source,
2680                                        index,
2681                                    )
2682                                    .help("remove it, or add the matching opening tag before it")
2683                                    .into());
2684                            }
2685
2686                            let content_start = index + tag.close + 1;
2687                            let (content_end, close_end) = if tag.self_closing
2688                                || html::is_void_element(tag.name)
2689                            {
2690                                (content_start, content_start)
2691                            } else if let Some((close_start, close_end)) =
2692                                html::find_matching_end(remaining, tag.name, tag.close + 1)
2693                            {
2694                                (index + close_start, index + close_end)
2695                            } else {
2696                                return Err(self
2697                                    .html_error(
2698                                        codes::UNCLOSED_HTML_TAG,
2699                                        format!("unclosed <{}> tag", tag.name),
2700                                        source,
2701                                        index,
2702                                    )
2703                                    .help(format!(
2704                                        "add the matching </{}> closing tag, or make the element self-closing",
2705                                        tag.name
2706                                    ))
2707                                    .into());
2708                            };
2709
2710                            let element = Element {
2711                                source,
2712                                start: index,
2713                                tag,
2714                                content_start,
2715                                content_end,
2716                                close_end,
2717                            };
2718                            if close_end < end {
2719                                ops.push(ParseOp::Parse {
2720                                    range: close_end..end,
2721                                    depth,
2722                                });
2723                            }
2724
2725                            match element.name() {
2726                                "for" => {
2727                                    self.enter_for_directive(&element, fragments, depth, &mut ops)?;
2728                                }
2729                                "if" => {
2730                                    self.enter_if_directive(&element, fragments, depth, &mut ops)?;
2731                                }
2732                                "route" => {
2733                                    self.process_route_directive(&element, fragments, depth)?;
2734                                }
2735                                "outlet" => {
2736                                    self.flush_raw_buffer(fragments);
2737                                    if outlet_seen {
2738                                        self.warnings.push(self.multiple_outlets_warning(&element));
2739                                    }
2740                                    outlet_seen = true;
2741                                    fragments.push(WebUIFragment::outlet());
2742                                }
2743                                "boundary" => {
2744                                    self.enter_boundary_directive(
2745                                        &element, fragments, depth, &mut ops,
2746                                    )?;
2747                                }
2748                                name if name.eq_ignore_ascii_case("body") => {
2749                                    self.enter_body_element(&element, fragments, depth, &mut ops)?;
2750                                }
2751                                name if name.eq_ignore_ascii_case("head") => {
2752                                    self.enter_head_element(&element, fragments, depth, &mut ops)?;
2753                                }
2754                                name if name.eq_ignore_ascii_case("webui-hydrate") => {
2755                                    return Err(self.authored_webui_hydrate_error(&element));
2756                                }
2757                                name if name.eq_ignore_ascii_case("style") => {
2758                                    self.process_style_element(&element, fragments)?;
2759                                }
2760                                _ if self.component_registry.contains(element.name()) => {
2761                                    self.enter_component_directive(
2762                                        &element, fragments, depth, &mut ops,
2763                                    )?;
2764                                }
2765                                _ => {
2766                                    self.check_component_typo(&element)?;
2767                                    self.enter_regular_element(
2768                                        &element, fragments, depth, &mut ops,
2769                                    )?;
2770                                }
2771                            }
2772
2773                            break;
2774                        }
2775
2776                        let next = remaining.find('<').unwrap_or(remaining.len());
2777                        let text_end = if next == 0 {
2778                            remaining
2779                                .chars()
2780                                .next()
2781                                .map_or(remaining.len(), char::len_utf8)
2782                        } else {
2783                            next
2784                        };
2785                        self.process_text(&remaining[..text_end], fragments)?;
2786                        index += text_end;
2787                    }
2788                }
2789                ParseOp::EmitClose(name) => {
2790                    self.add_raw_fragment("</");
2791                    self.add_raw_fragment(name);
2792                    self.add_raw_fragment(">");
2793                }
2794                ParseOp::EndComponent(name) => {
2795                    self.add_raw_fragment("</");
2796                    self.add_raw_fragment(name);
2797                    self.add_raw_fragment(">");
2798                    self.flush_raw_buffer(fragments);
2799                    fragments.push(structural_signal(format!("streaming_span_end:{name}")));
2800                }
2801                ParseOp::EndHead(name) => {
2802                    self.flush_raw_buffer(fragments);
2803                    fragments.push(structural_signal("head_end"));
2804                    self.add_raw_fragment("</");
2805                    self.add_raw_fragment(name);
2806                    self.add_raw_fragment(">");
2807                }
2808                ParseOp::EndBody(name) => {
2809                    self.flush_raw_buffer(fragments);
2810                    fragments.push(structural_signal("body_end"));
2811                    self.add_raw_fragment("</");
2812                    self.add_raw_fragment(name);
2813                    self.add_raw_fragment(">");
2814                    self.body_depth = self.body_depth.saturating_sub(1);
2815                }
2816                ParseOp::CompleteBoundary { boundary } => {
2817                    self.flush_raw_buffer(fragments);
2818                    fragments.push(WebUIFragment::boundary_end(boundary.declaration_id));
2819                    self.in_boundary = false;
2820                }
2821                ParseOp::RestoreFosterDepth(previous) => {
2822                    self.foster_context_depth = previous;
2823                }
2824                ParseOp::RestoreBoundaryParentScope(previous) => {
2825                    self.boundary_parent_scope = previous;
2826                }
2827                ParseOp::CompleteFor {
2828                    parent,
2829                    item,
2830                    collection,
2831                    fragment_id,
2832                    named_definition,
2833                    previous_for_depth,
2834                } => {
2835                    self.for_depth = previous_for_depth;
2836                    self.flush_raw_buffer(fragments);
2837                    let for_fragment = std::mem::take(fragments);
2838                    *fragments = parent.fragments;
2839                    self.raw_buffer = parent.raw_buffer;
2840
2841                    if !for_fragment.is_empty() || named_definition {
2842                        self.fragment_records.insert(
2843                            fragment_id.clone(),
2844                            FragmentList {
2845                                fragments: for_fragment,
2846                                contains_boundary: false,
2847                            },
2848                        );
2849                    } else {
2850                        continue;
2851                    }
2852
2853                    self.add_for_fragment(item, collection, fragment_id, fragments);
2854                }
2855                ParseOp::CompleteIf {
2856                    parent,
2857                    condition,
2858                    fragment_id,
2859                } => {
2860                    self.flush_raw_buffer(fragments);
2861                    let if_fragment = std::mem::take(fragments);
2862                    *fragments = parent.fragments;
2863                    self.raw_buffer = parent.raw_buffer;
2864
2865                    self.fragment_records.insert(
2866                        fragment_id.clone(),
2867                        FragmentList {
2868                            fragments: if_fragment,
2869                            contains_boundary: false,
2870                        },
2871                    );
2872                    self.add_if_fragment(condition, fragment_id, fragments);
2873                }
2874            }
2875        }
2876
2877        Ok(())
2878    }
2879
2880    fn enter_regular_element<'a>(
2881        &mut self,
2882        element: &Element<'a>,
2883        fragments: &mut Vec<WebUIFragment>,
2884        depth: usize,
2885        ops: &mut Vec<ParseOp<'a>>,
2886    ) -> Result<()> {
2887        if !self.in_boundary
2888            && self.boundary_ancestor_depth == 0
2889            && element.name().eq_ignore_ascii_case("script")
2890        {
2891            self.record_module_entry(element);
2892        }
2893        self.add_raw_fragment("<");
2894        self.add_raw_fragment(element.name());
2895
2896        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2897        if let Some(ref mut p) = self.plugin {
2898            if let Some(data) = p.finish_opening_tag(ElementStartContext {
2899                tag_name: element.name(),
2900                binding_count,
2901            }) {
2902                self.add_fragment(WebUIFragment::plugin(data), fragments);
2903            }
2904        }
2905
2906        if element.self_closing() {
2907            self.add_raw_fragment("/>");
2908            return Ok(());
2909        }
2910
2911        self.add_raw_fragment(">");
2912        if element.name().eq_ignore_ascii_case("template")
2913            && element
2914                .attrs()
2915                .find(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
2916                .and_then(|attr| attr.value)
2917                .is_some_and(|mode| mode.eq_ignore_ascii_case("open"))
2918            && self
2919                .component_dom_analyses
2920                .get(&self.current_fragment_id)
2921                .is_some_and(|analysis| analysis.uses_shadow_dom)
2922        {
2923            // This compiler-owned no-DOM hook lets the server install the
2924            // complete tree-local style closure before hydratable children.
2925            // It is a protocol fragment rather than an HTML marker, so client
2926            // ordinal paths are unchanged.
2927            self.flush_raw_buffer(fragments);
2928            fragments.push(structural_signal(format!(
2929                "shadow_styles:{}",
2930                self.current_fragment_id
2931            )));
2932        }
2933        if !element.is_void() {
2934            if element.close_end() > element.content_end() {
2935                ops.push(ParseOp::EmitClose(element.name()));
2936            }
2937            self.enter_foster_context(element.name(), ops);
2938            self.enter_boundary_parent_scope(element, ops);
2939            ops.push(ParseOp::Parse {
2940                range: element.inner(),
2941                depth: depth + 1,
2942            });
2943        }
2944        Ok(())
2945    }
2946
2947    /// Record an authored `<script type="module" src="...">` as a critical
2948    /// module entry, so the build can preload the chunks it statically imports.
2949    ///
2950    /// Only bare, static `src` values qualify. A `{{binding}}` resolves per
2951    /// request and cannot be matched to a build artifact, and a script without
2952    /// `type="module"` has no ES module graph to preload. Both are skipped
2953    /// silently rather than diagnosed, because either is a perfectly valid
2954    /// thing to author — they just carry no preload information.
2955    ///
2956    /// The caller has already excluded scripts inside a `<boundary>`.
2957    fn record_module_entry(&mut self, element: &Element<'_>) {
2958        let Some(src) = element.attr("src") else {
2959            return;
2960        };
2961        if src.is_empty() || src.contains("{{") {
2962            return;
2963        }
2964        if element
2965            .attrs()
2966            .find(|attr| attr.name.eq_ignore_ascii_case("fetchpriority"))
2967            .and_then(|attr| attr.value)
2968            .is_some_and(|priority| priority.eq_ignore_ascii_case("low"))
2969        {
2970            return;
2971        }
2972        if !element
2973            .attr("type")
2974            .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module"))
2975        {
2976            return;
2977        }
2978        // Document order is meaningful and duplicates are near-impossible, so
2979        // this stays a `Vec` scan rather than paying for a set.
2980        if self.module_entry_srcs.iter().any(|seen| seen == src) {
2981            return;
2982        }
2983        self.module_entry_srcs.push(src.to_string());
2984    }
2985
2986    fn enter_head_element<'a>(
2987        &mut self,
2988        element: &Element<'a>,
2989        fragments: &mut Vec<WebUIFragment>,
2990        depth: usize,
2991        ops: &mut Vec<ParseOp<'a>>,
2992    ) -> Result<()> {
2993        if element.name() == "head" {
2994            // Preserve the lowercase legacy protocol shape exactly: authored
2995            // attributes on `<head>` were ignored and the opening was emitted
2996            // as the literal `<head>`.
2997            self.add_raw_fragment("<head>");
2998        } else {
2999            // origin/main treated mixed-case native tags as ordinary elements.
3000            // Keep their authored name and attributes while adding only the
3001            // separate case-insensitive streaming structure.
3002            self.add_raw_fragment("<");
3003            self.add_raw_fragment(element.name());
3004            let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
3005            if let Some(ref mut p) = self.plugin {
3006                if let Some(data) = p.finish_opening_tag(ElementStartContext {
3007                    tag_name: element.name(),
3008                    binding_count,
3009                }) {
3010                    self.add_fragment(WebUIFragment::plugin(data), fragments);
3011                }
3012            }
3013            self.add_raw_fragment(">");
3014        }
3015        self.flush_raw_buffer(fragments);
3016        // Mirrors `body_start`: emitted immediately after the raw opening
3017        // `<head>` fragment and before any child content (including
3018        // authored async scripts), so `render_streaming` can preflight on it.
3019        // Raw signals unknown to the legacy (non-streaming) handler are
3020        // ignored there, so this is a no-op for existing output.
3021        fragments.push(structural_signal("head_start"));
3022        ops.push(ParseOp::EndHead(element.name()));
3023        ops.push(ParseOp::Parse {
3024            range: element.inner(),
3025            depth: depth + 1,
3026        });
3027        Ok(())
3028    }
3029
3030    fn enter_body_element<'a>(
3031        &mut self,
3032        element: &Element<'a>,
3033        fragments: &mut Vec<WebUIFragment>,
3034        depth: usize,
3035        ops: &mut Vec<ParseOp<'a>>,
3036    ) -> Result<()> {
3037        self.add_raw_fragment("<");
3038        self.add_raw_fragment(element.name());
3039        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
3040        if let Some(ref mut p) = self.plugin {
3041            if let Some(data) = p.finish_opening_tag(ElementStartContext {
3042                tag_name: element.name(),
3043                binding_count,
3044            }) {
3045                self.add_fragment(WebUIFragment::plugin(data), fragments);
3046            }
3047        }
3048        self.add_raw_fragment(">");
3049        self.flush_raw_buffer(fragments);
3050        fragments.push(structural_signal("body_start"));
3051        self.body_depth += 1;
3052        ops.push(ParseOp::EndBody(element.name()));
3053        ops.push(ParseOp::Parse {
3054            range: element.inner(),
3055            depth: depth + 1,
3056        });
3057        Ok(())
3058    }
3059
3060    /// Start an authoring [`Diagnostic`] for the template currently being
3061    /// parsed, naming the owning fragment (entry file or component tag) when
3062    /// known. `code` is the stable machine-readable [`code`](crate::diagnostic::codes).
3063    ///
3064    /// Marked `#[cold]`/`#[inline(never)]`: this only runs while *building* a
3065    /// build error, so keeping it out-of-line preserves hot parse-path layout.
3066    #[cold]
3067    #[inline(never)]
3068    fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
3069        let diag = Diagnostic::error(title).code(code);
3070        if self.current_fragment_id.is_empty() {
3071            diag
3072        } else {
3073            diag.component(self.current_fragment_id.clone())
3074        }
3075    }
3076
3077    /// Like [`HtmlParser::authoring_error`], but also records the source
3078    /// position (line/column) of `element` so the diagnostic can point at the
3079    /// exact spot in the template.
3080    #[cold]
3081    #[inline(never)]
3082    fn authoring_error_at(
3083        &self,
3084        code: &'static str,
3085        title: impl Into<String>,
3086        element: &Element<'_>,
3087    ) -> Diagnostic {
3088        self.authoring_error(code, title)
3089            .at_offset(element.source(), element.start)
3090    }
3091
3092    /// Build a positioned [`Diagnostic`] for a structural HTML well-formedness
3093    /// error (unclosed/malformed tag, unterminated comment, …), naming the
3094    /// owning fragment and the source position from a byte `offset`.
3095    #[cold]
3096    #[inline(never)]
3097    fn html_error(
3098        &self,
3099        code: &'static str,
3100        title: impl Into<String>,
3101        source: &str,
3102        offset: usize,
3103    ) -> Diagnostic {
3104        self.authoring_error(code, title).at_offset(source, offset)
3105    }
3106
3107    /// Build the warning for a second `<outlet>` at one parsed route level.
3108    #[cold]
3109    #[inline(never)]
3110    fn multiple_outlets_warning(&self, element: &Element<'_>) -> Diagnostic {
3111        outlet_warnings::multiple_outlets_warning(&self.current_fragment_id)
3112            .at_offset(element.source(), element.start)
3113    }
3114
3115    /// Build the `help:` line for an unknown component `<name>`.
3116    ///
3117    /// Offers the closest registered component as a "did you mean …?" fix when
3118    /// one is a near typo; otherwise falls back to the generic registration
3119    /// hint.
3120    #[cold]
3121    #[inline(never)]
3122    fn unknown_component_help(&self, name: &str) -> String {
3123        match suggest::closest_match(name, self.component_registry.names()) {
3124            Some(suggestion) => format!(
3125                "did you mean <{suggestion}>? otherwise register the component by adding a \
3126                 matching .html file"
3127            ),
3128            None => "register the component (add a matching .html file) or check the tag name \
3129                     for a typo"
3130                .to_string(),
3131        }
3132    }
3133
3134    /// Suggest a registered component that an unregistered custom-element `tag`
3135    /// likely mistypes.
3136    ///
3137    /// Only same-namespace candidates are considered — the text before the
3138    /// first `-` must match exactly — so a genuine third-party custom element
3139    /// (`<md-button>` when `<mp-button>` is registered) is never flagged, while
3140    /// an in-namespace slip (`<mp-buton>` → `<mp-button>`) is. Returns `None`
3141    /// for non-hyphenated (native) tags and when no near match exists.
3142    fn suggest_component(&self, tag: &str) -> Option<&str> {
3143        let (prefix, _) = tag.split_once('-')?;
3144        let same_namespace = self
3145            .component_registry
3146            .names()
3147            .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
3148        suggest::closest_match(tag, same_namespace)
3149    }
3150
3151    /// Error with a "did you mean …?" hint when `element` is an unregistered
3152    /// custom-element tag that closely matches a registered component in the
3153    /// same namespace. Genuine external custom elements pass through as raw
3154    /// HTML (returns `Ok`).
3155    fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
3156        if let Some(suggestion) = self.suggest_component(element.name()) {
3157            return Err(self
3158                .authoring_error_at(
3159                    codes::UNKNOWN_COMPONENT,
3160                    format!("unknown component <{}>", element.name()),
3161                    element,
3162                )
3163                .help(format!(
3164                    "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
3165                     .html file",
3166                    element.name()
3167                ))
3168                .into());
3169        }
3170        Ok(())
3171    }
3172
3173    /// Name of an attribute on `element` that looks like a typo of `expected`
3174    /// (close edit distance, not an exact match), if any. Used to suggest the
3175    /// intended directive attribute (e.g. `eahc` → `each`).
3176    #[cold]
3177    #[inline(never)]
3178    fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
3179        suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
3180            .filter(|&name| name != expected)
3181    }
3182
3183    /// Promote a standalone [`ParserError::Css`] into a structured authoring
3184    /// [`Diagnostic`] (code [`codes::INVALID_CSS`], owning fragment), so CSS
3185    /// mistakes render and serialize like every other authoring error. The
3186    /// message already carries the in-`<style>` line/column. Non-CSS errors
3187    /// pass through unchanged.
3188    #[cold]
3189    #[inline(never)]
3190    fn css_diagnostic(&self, err: ParserError) -> ParserError {
3191        match err {
3192            ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
3193            other => other,
3194        }
3195    }
3196
3197    /// Build the error for a `<for>` missing its `each` attribute (cold path).
3198    #[cold]
3199    #[inline(never)]
3200    fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
3201        let diag = self
3202            .authoring_error_at(
3203                codes::MISSING_FOR_EACH,
3204                "missing each attribute on <for>",
3205                element,
3206            )
3207            .element("for");
3208        match Self::attr_typo_suggestion(element, "each") {
3209            Some(typo) => diag.help(format!(
3210                "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
3211            )),
3212            None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
3213        }
3214        .into()
3215    }
3216
3217    fn enter_for_directive<'a>(
3218        &mut self,
3219        element: &Element<'a>,
3220        fragments: &mut Vec<WebUIFragment>,
3221        depth: usize,
3222        ops: &mut Vec<ParseOp<'a>>,
3223    ) -> Result<()> {
3224        let each = element
3225            .attr("each")
3226            .ok_or_else(|| self.for_each_missing_error(element))?;
3227        let (item, collection) = parse_for_each(&self.current_fragment_id, each)
3228            .map_err(|error| locate_for_error(error, element))?;
3229
3230        let named_id = validate_for_id(&self.current_fragment_id, &element.tag)
3231            .map_err(|error| locate_for_error(error, element))?;
3232        let fragment_id = if let Some(id) = named_id {
3233            self.named_for_loops
3234                .register(
3235                    &self.current_fragment_id,
3236                    id,
3237                    item,
3238                    (!element.self_closing(), element.start),
3239                )
3240                .map_err(|error| locate_for_error(error, element))?;
3241            let scoped_id = format!(
3242                "{id}-{}",
3243                self.loop_owner_indices[&self.current_fragment_id]
3244            );
3245            if !self.named_for_fragment_ids.contains(&scoped_id)
3246                && (self.id_counter.has_generated(&scoped_id)
3247                    || self.fragment_records.contains_key(&scoped_id)
3248                    || self.component_registry.contains(&scoped_id)
3249                    || self.in_progress_fragments.contains(&scoped_id))
3250            {
3251                return Err(self.for_id_collision_error(element, &scoped_id));
3252            }
3253            self.named_for_fragment_ids.insert(scoped_id.clone());
3254            scoped_id
3255        } else {
3256            self.next_fragment_id("for")
3257        };
3258        if named_id.is_some() && element.self_closing() {
3259            self.add_for_fragment(
3260                item.to_string(),
3261                collection.to_string(),
3262                fragment_id,
3263                fragments,
3264            );
3265            return Ok(());
3266        }
3267        let parent = ParseContext {
3268            fragments: std::mem::take(fragments),
3269            raw_buffer: std::mem::take(&mut self.raw_buffer),
3270        };
3271
3272        let previous_for_depth = self.for_depth;
3273        self.for_depth += 1;
3274        ops.push(ParseOp::CompleteFor {
3275            parent,
3276            item: item.to_string(),
3277            collection: collection.to_string(),
3278            fragment_id,
3279            named_definition: named_id.is_some(),
3280            previous_for_depth,
3281        });
3282        ops.push(ParseOp::Parse {
3283            range: element.inner(),
3284            depth: depth + 1,
3285        });
3286        Ok(())
3287    }
3288
3289    fn next_fragment_id(&mut self, prefix: &str) -> String {
3290        loop {
3291            let id = self.id_counter.next_id(prefix);
3292            if !self.named_for_fragment_ids.contains(&id) {
3293                return id;
3294            }
3295        }
3296    }
3297
3298    #[cold]
3299    #[inline(never)]
3300    fn for_id_collision_error(&self, element: &Element<'_>, id: &str) -> ParserError {
3301        self.authoring_error_at(
3302            codes::INVALID_FOR_ID,
3303            "named <for> id conflicts with an existing fragment",
3304            element,
3305        )
3306        .element("for")
3307        .help(format!(
3308            "choose a different id; the fragment name '{id}' is already in use"
3309        ))
3310        .into()
3311    }
3312
3313    /// Build the error for an `<if>` missing its `condition` attribute (cold
3314    /// path).
3315    #[cold]
3316    #[inline(never)]
3317    fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
3318        let diag = self
3319            .authoring_error_at(
3320                codes::MISSING_IF_CONDITION,
3321                "missing condition attribute on <if>",
3322                element,
3323            )
3324            .element("if");
3325        match Self::attr_typo_suggestion(element, "condition") {
3326            Some(typo) => diag.help(format!(
3327                "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
3328            )),
3329            None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
3330        }
3331        .into()
3332    }
3333
3334    /// Build the error for a malformed `<if condition>` expression (cold path).
3335    #[cold]
3336    #[inline(never)]
3337    fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
3338        self.authoring_error_at(
3339            codes::INVALID_IF_CONDITION,
3340            "invalid <if> condition expression",
3341            element,
3342        )
3343        .element("if")
3344        .snippet(format!("condition=\"{condition}\""))
3345        .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
3346        .into()
3347    }
3348
3349    fn enter_if_directive<'a>(
3350        &mut self,
3351        element: &Element<'a>,
3352        fragments: &mut Vec<WebUIFragment>,
3353        depth: usize,
3354        ops: &mut Vec<ParseOp<'a>>,
3355    ) -> Result<()> {
3356        let condition_str = element
3357            .attr("condition")
3358            .ok_or_else(|| self.if_condition_missing_error(element))?;
3359
3360        let condition = self
3361            .condition_parser
3362            .parse(strip_condition_braces(condition_str))
3363            .map_err(|_| self.if_condition_invalid_error(element, condition_str))?;
3364
3365        self.flush_raw_buffer(fragments);
3366        let parent = ParseContext {
3367            fragments: std::mem::take(fragments),
3368            raw_buffer: std::mem::take(&mut self.raw_buffer),
3369        };
3370        let fragment_id = self.next_fragment_id("if");
3371
3372        ops.push(ParseOp::CompleteIf {
3373            parent,
3374            condition,
3375            fragment_id,
3376        });
3377        ops.push(ParseOp::Parse {
3378            range: element.inner(),
3379            depth: depth + 1,
3380        });
3381        Ok(())
3382    }
3383
3384    /// Track whether `name` opens (or shields from) an HTML insertion mode
3385    /// that foster-parents unknown elements, pushing a restore op when the
3386    /// depth changes.
3387    ///
3388    /// Called for every regular element, so the common `Transparent` case
3389    /// does no work beyond the classification compare — no op is pushed and
3390    /// no field is written.
3391    #[inline]
3392    fn enter_foster_context<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
3393        match foster_context_of(name) {
3394            FosterContext::Transparent => {}
3395            FosterContext::Hostile => {
3396                ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3397                self.foster_context_depth += 1;
3398            }
3399            FosterContext::Barrier => {
3400                if self.foster_context_depth != 0 {
3401                    ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3402                    self.foster_context_depth = 0;
3403                }
3404            }
3405        }
3406    }
3407
3408    #[inline]
3409    fn enter_boundary_parent_scope<'a>(
3410        &mut self,
3411        element: &Element<'a>,
3412        ops: &mut Vec<ParseOp<'a>>,
3413    ) {
3414        if element.name().eq_ignore_ascii_case("template") && element.has_attr("shadowrootmode") {
3415            return;
3416        }
3417        let Some(scope) = boundary_parent_scope(element.name()) else {
3418            return;
3419        };
3420        ops.push(ParseOp::RestoreBoundaryParentScope(
3421            self.boundary_parent_scope,
3422        ));
3423        self.boundary_parent_scope = Some(scope);
3424    }
3425
3426    #[inline]
3427    fn enter_component_content_scope<'a>(&mut self, ops: &mut Vec<ParseOp<'a>>) {
3428        ops.push(ParseOp::RestoreBoundaryParentScope(
3429            self.boundary_parent_scope,
3430        ));
3431        self.boundary_parent_scope = Some("component host content");
3432    }
3433
3434    /// Enter a `<boundary name="…">` directive.
3435    ///
3436    /// `<boundary>` is a reserved, compile-time-only directive (see
3437    /// "Progressive Streaming Hydration" in `DESIGN.md`): it emits no wrapper
3438    /// element. Its children stay inline in the owner's record, bracketed by a
3439    /// [`BoundaryPhase::Start`]/[`BoundaryPhase::End`] fragment pair.
3440    ///
3441    /// # Errors
3442    ///
3443    /// Returns [`ParserError::Template`] when:
3444    /// - the boundary is nested inside another open boundary
3445    ///   ([`codes::NESTED_BOUNDARY`]);
3446    /// - the boundary is outside an open `<body>`
3447    ///   ([`codes::BOUNDARY_OUTSIDE_BODY`]);
3448    /// - the boundary sits in an HTML foster-parenting context such as
3449    ///   `<table>`/`<tbody>`/`<tr>`/`<select>`
3450    ///   ([`codes::BOUNDARY_IN_FOSTER_CONTEXT`]);
3451    /// - the boundary would cut through component host content or native
3452    ///   raw/inert content ([`codes::BOUNDARY_CROSSES_SCOPE`]);
3453    /// - `name` is missing/empty ([`codes::MISSING_BOUNDARY_NAME`]), dynamic
3454    ///   ([`codes::INVALID_BOUNDARY_NAME`]), or a duplicate within its owner
3455    ///   ([`codes::DUPLICATE_BOUNDARY_NAME`]).
3456    /// - the boundary sits inside a `<for>` repeat body
3457    ///   ([`codes::BOUNDARY_IN_REPEAT`]), or an authored key is malformed
3458    ///   ([`codes::INVALID_BOUNDARY_KEY`]).
3459    fn enter_boundary_directive<'a>(
3460        &mut self,
3461        element: &Element<'a>,
3462        fragments: &mut Vec<WebUIFragment>,
3463        depth: usize,
3464        ops: &mut Vec<ParseOp<'a>>,
3465    ) -> Result<()> {
3466        if let Some(scope) = self.boundary_parent_scope {
3467            return Err(self.boundary_scope_error(element, scope));
3468        }
3469        if self.in_boundary {
3470            return Err(self.nested_boundary_error(element));
3471        }
3472        if self.for_depth != 0 {
3473            return Err(self.boundary_in_repeat_error(element));
3474        }
3475        let owner_fragment_id = self.current_fragment_id.clone();
3476        let owner_is_component = self.component_registry.contains(&owner_fragment_id);
3477        if !owner_is_component && self.body_depth == 0 && self.route_depth == 0 {
3478            return Err(self.boundary_outside_body_error(element));
3479        }
3480        if self.foster_context_depth != 0 {
3481            return Err(self.boundary_in_foster_context_error(element));
3482        }
3483
3484        let name = self.validate_boundary_name(element)?;
3485        let inserted = self
3486            .boundary_names_by_owner
3487            .entry(owner_fragment_id.clone())
3488            .or_default()
3489            .insert(name.clone());
3490        if !inserted {
3491            return Err(self.duplicate_boundary_name_error(element, &name));
3492        }
3493        let key = self.validate_boundary_key(element)?;
3494        let declaration_id = self.allocate_boundary_declaration_id(element)?;
3495        self.in_boundary = true;
3496        self.add_fragment(
3497            WebUIFragment {
3498                fragment: Some(Fragment::Boundary(WebUiFragmentBoundary {
3499                    declaration_id,
3500                    owner_fragment_id,
3501                    name,
3502                    key,
3503                    may_repeat: false,
3504                    phase: BoundaryPhase::Start as i32,
3505                })),
3506            },
3507            fragments,
3508        );
3509
3510        ops.push(ParseOp::CompleteBoundary {
3511            boundary: PendingBoundary { declaration_id },
3512        });
3513        ops.push(ParseOp::Parse {
3514            range: element.inner(),
3515            depth: depth + 1,
3516        });
3517        Ok(())
3518    }
3519
3520    /// Validate `<boundary name>`: required, non-empty, and static (no
3521    /// `{{binding}}`).
3522    fn validate_boundary_name(&self, element: &Element<'_>) -> Result<String> {
3523        let name = element.attr("name").unwrap_or_default();
3524        if name.trim().is_empty() {
3525            return Err(self.missing_boundary_name_error(element));
3526        }
3527        if name.contains("{{") {
3528            return Err(self.invalid_boundary_name_error(element, name));
3529        }
3530        Ok(name.to_string())
3531    }
3532
3533    fn validate_boundary_key(&self, element: &Element<'_>) -> Result<Option<String>> {
3534        if !element.has_attr("key") {
3535            return Ok(None);
3536        }
3537        let Some(raw) = element.attr("key") else {
3538            return Err(self.invalid_boundary_key_error(element, ""));
3539        };
3540        let trimmed = raw.trim();
3541        let braced = trimmed.starts_with("{{") || trimmed.ends_with("}}");
3542        let valid = if braced {
3543            trimmed
3544                .strip_prefix("{{")
3545                .and_then(|value| value.strip_suffix("}}"))
3546                .filter(|value| !value.starts_with('{') && !value.ends_with('}'))
3547                .is_some_and(|value| !value.trim().is_empty())
3548        } else {
3549            !trimmed.is_empty()
3550        };
3551        if !valid {
3552            return Err(self.invalid_boundary_key_error(element, raw));
3553        }
3554        Ok(Some(raw.to_string()))
3555    }
3556
3557    fn allocate_boundary_declaration_id(&mut self, element: &Element<'_>) -> Result<u32> {
3558        let declaration_id = self.next_boundary_declaration_id;
3559        let Some(next) = declaration_id.checked_add(1) else {
3560            return Err(self.too_many_boundaries_error(element));
3561        };
3562        self.next_boundary_declaration_id = next;
3563        Ok(declaration_id)
3564    }
3565
3566    /// Build the error for a `<boundary>` missing its `name` attribute
3567    /// (cold path).
3568    #[cold]
3569    #[inline(never)]
3570    fn missing_boundary_name_error(&self, element: &Element<'_>) -> ParserError {
3571        self.authoring_error_at(
3572            codes::MISSING_BOUNDARY_NAME,
3573            "missing name attribute on <boundary>",
3574            element,
3575        )
3576        .element("boundary")
3577        .help("add a unique static name, e.g. <boundary name=\"counter-ready\">")
3578        .into()
3579    }
3580
3581    /// Build the error for a `<boundary name>` that is not a static
3582    /// string literal (cold path).
3583    #[cold]
3584    #[inline(never)]
3585    fn invalid_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3586        self.authoring_error_at(
3587            codes::INVALID_BOUNDARY_NAME,
3588            "invalid name attribute on <boundary>",
3589            element,
3590        )
3591        .element("boundary")
3592        .snippet(format!("name=\"{name}\""))
3593        .help("boundary names must be a static string literal, e.g. name=\"counter-ready\"")
3594        .into()
3595    }
3596
3597    /// Build the error for a duplicate `<boundary name>` within one owner.
3598    #[cold]
3599    #[inline(never)]
3600    fn duplicate_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3601        self.authoring_error_at(
3602            codes::DUPLICATE_BOUNDARY_NAME,
3603            format!("duplicate <boundary> name \"{name}\""),
3604            element,
3605        )
3606        .element("boundary")
3607        .help("boundary names must be unique within their entry or component template; rename one of the declarations")
3608        .into()
3609    }
3610
3611    #[cold]
3612    #[inline(never)]
3613    fn missing_boundary_key_error(owner: &str, name: &str) -> ParserError {
3614        Diagnostic::error(format!(
3615            "boundary \"{name}\" renders more than once and has no key"
3616        ))
3617        .code(codes::MISSING_BOUNDARY_KEY)
3618        .component(owner)
3619        .element("boundary")
3620        .snippet(format!("name=\"{name}\""))
3621        .help(
3622            "the owning template is rendered from more than one callsite, so every occurrence \
3623             needs a distinct key, e.g. key=\"{{sectionId}}\" — or render the declaration from a \
3624             single callsite",
3625        )
3626        .into()
3627    }
3628
3629    /// Build the error for a `<boundary>` authored directly inside `<for>`.
3630    #[cold]
3631    #[inline(never)]
3632    fn boundary_in_repeat_error(&self, element: &Element<'_>) -> ParserError {
3633        let name = element.attr("name").unwrap_or_default();
3634        self.authoring_error_at(
3635            codes::BOUNDARY_IN_REPEAT,
3636            "<boundary> is not valid inside a <for> repeat",
3637            element,
3638        )
3639        .element("boundary")
3640        .snippet(format!("name=\"{name}\""))
3641        .help(
3642            "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
3643             whole <for> in one <boundary> to render the list as a single independently paced \
3644             region",
3645        )
3646        .into()
3647    }
3648
3649    #[cold]
3650    #[inline(never)]
3651    fn invalid_boundary_key_error(&self, element: &Element<'_>, key: &str) -> ParserError {
3652        self.authoring_error_at(
3653            codes::INVALID_BOUNDARY_KEY,
3654            "invalid key expression on <boundary>",
3655            element,
3656        )
3657        .element("boundary")
3658        .snippet(format!("key=\"{key}\""))
3659        .help("use a non-empty expression such as key=\"item.id\" or key=\"{{item.id}}\"")
3660        .into()
3661    }
3662
3663    #[cold]
3664    #[inline(never)]
3665    fn too_many_boundaries_error(&self, element: &Element<'_>) -> ParserError {
3666        self.authoring_error_at(
3667            codes::TOO_MANY_BOUNDARIES,
3668            "too many boundary declarations",
3669            element,
3670        )
3671        .element("boundary")
3672        .help("split this build into smaller protocols so each declaration has a unique u32 ID")
3673        .into()
3674    }
3675
3676    /// Build the error for a `<boundary>` nested inside another open
3677    /// boundary (cold path).
3678    #[cold]
3679    #[inline(never)]
3680    fn nested_boundary_error(&self, element: &Element<'_>) -> ParserError {
3681        self.authoring_error_at(
3682            codes::NESTED_BOUNDARY,
3683            "nested <boundary> is not allowed",
3684            element,
3685        )
3686        .element("boundary")
3687        .help("boundaries cannot be nested; close the enclosing <boundary> before opening another")
3688        .into()
3689    }
3690
3691    /// Build the error for a `<boundary>` that would cut through a native
3692    /// raw/inert scope or component host content.
3693    #[cold]
3694    #[inline(never)]
3695    fn boundary_scope_error(&self, element: &Element<'_>, scope: &str) -> ParserError {
3696        self.authoring_error_at(
3697            codes::BOUNDARY_CROSSES_SCOPE,
3698            format!("<boundary> cannot appear inside {scope}"),
3699            element,
3700        )
3701        .element("boundary")
3702        .help("move the <boundary> outside this raw, inert, or component-host content scope")
3703        .into()
3704    }
3705
3706    /// Build the error for a `<boundary>` outside an open body.
3707    #[cold]
3708    #[inline(never)]
3709    fn boundary_outside_body_error(&self, element: &Element<'_>) -> ParserError {
3710        self.authoring_error_at(
3711            codes::BOUNDARY_OUTSIDE_BODY,
3712            "<boundary> must appear inside <body>",
3713            element,
3714        )
3715        .element("boundary")
3716        .help("move the <boundary> between the opening <body> and its matching </body>")
3717        .into()
3718    }
3719
3720    /// Build the error for a `<boundary>` inside an HTML
3721    /// foster-parenting insertion mode (cold path).
3722    ///
3723    /// The browser would move the generated `<webui-hydrate>` sentinel out of
3724    /// the table while leaving its payload `<script>` behind, so the
3725    /// coordinator could not pair them and would halt hydration for the whole
3726    /// page. Rejecting at build time keeps that failure impossible.
3727    #[cold]
3728    #[inline(never)]
3729    fn boundary_in_foster_context_error(&self, element: &Element<'_>) -> ParserError {
3730        self.authoring_error_at(
3731            codes::BOUNDARY_IN_FOSTER_CONTEXT,
3732            "<boundary> cannot appear inside a table or select context",
3733            element,
3734        )
3735        .element("boundary")
3736        .help(
3737            "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>",
3738        )
3739        .into()
3740    }
3741
3742    #[cold]
3743    #[inline(never)]
3744    fn route_boundary_placement_error(&self, element: &Element<'_>) -> ParserError {
3745        self.authoring_error_at(
3746            codes::INVALID_ROUTE_BOUNDARY_PLACEMENT,
3747            "route boundary must be a direct <route> child",
3748            element,
3749        )
3750        .element("boundary")
3751        .help("move the <boundary> directly under <route>, or place it in the route component template")
3752        .into()
3753    }
3754
3755    /// Build the error for an authored `<webui-hydrate>` (cold path).
3756    /// `<webui-hydrate>` is reserved for the compiler/handler-generated
3757    /// runtime hydration sentinel and must never appear in authored markup.
3758    #[cold]
3759    #[inline(never)]
3760    fn authored_webui_hydrate_error(&self, element: &Element<'_>) -> ParserError {
3761        self.authoring_error_at(
3762            codes::AUTHORED_WEBUI_HYDRATE,
3763            "<webui-hydrate> is reserved for the generated runtime sentinel",
3764            element,
3765        )
3766        .element("webui-hydrate")
3767        .help("remove <webui-hydrate>; it is emitted only by the compiler/handler, never authored")
3768        .into()
3769    }
3770
3771    fn enter_component_directive<'a>(
3772        &mut self,
3773        element: &Element<'a>,
3774        fragments: &mut Vec<WebUIFragment>,
3775        depth: usize,
3776        ops: &mut Vec<ParseOp<'a>>,
3777    ) -> Result<()> {
3778        let needs_template_build = !self.fragment_records.contains_key(element.name());
3779        let cached_dom_analysis = self.component_dom_analyses.get(element.name()).copied();
3780        let has_plugin_artifact = self
3781            .component_registry
3782            .component_artifact_source(element.name())
3783            .is_some();
3784        let authored_html = if needs_template_build || cached_dom_analysis.is_none() {
3785            self.component_registry
3786                .component_authored_source(element.name())
3787        } else {
3788            None
3789        };
3790        let (dom_analysis, template_source) = {
3791            let component = self.component_registry.get(element.name()).ok_or_else(|| {
3792                self.authoring_error_at(
3793                    codes::UNKNOWN_COMPONENT,
3794                    format!("unknown component <{}>", element.name()),
3795                    element,
3796                )
3797                .help(self.unknown_component_help(element.name()))
3798            })?;
3799            (
3800                cached_dom_analysis.map_or_else(
3801                    || {
3802                        let fast_html = (has_plugin_artifact
3803                            && self.options.dom_strategy == DomStrategy::Shadow)
3804                            .then(|| {
3805                                Self::append_shadow_mode_if_missing(component.html_content.clone())
3806                            });
3807                        analyze_component_dom_with_diagnostic_source(
3808                            element.name(),
3809                            fast_html.as_deref().unwrap_or(&component.html_content),
3810                            self.options.dom_strategy,
3811                            authored_html.as_deref(),
3812                        )
3813                    },
3814                    Ok,
3815                )?,
3816                needs_template_build.then(|| {
3817                    (
3818                        component.html_content.clone(),
3819                        component.css_content.clone(),
3820                    )
3821                }),
3822            )
3823        };
3824        if cached_dom_analysis.is_none() {
3825            self.component_dom_analyses
3826                .insert(element.name().to_string(), dom_analysis);
3827        }
3828
3829        self.flush_raw_buffer(fragments);
3830        fragments.push(structural_signal(format!(
3831            "streaming_span_start:{}",
3832            element.name()
3833        )));
3834        self.add_raw_fragment("<");
3835        self.add_raw_fragment(element.name());
3836        if self
3837            .component_registry
3838            .render_policy(element.name())
3839            .is_some_and(ComponentRenderPolicy::is_interaction_hydration)
3840        {
3841            self.add_raw_fragment(" data-webui-interaction");
3842        }
3843
3844        let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
3845        if let Some(ref mut p) = self.plugin {
3846            if let Some(data) = p.finish_opening_tag(ElementStartContext {
3847                tag_name: element.name(),
3848                binding_count,
3849            }) {
3850                self.add_fragment(WebUIFragment::plugin(data), fragments);
3851            }
3852        }
3853
3854        // Emit a compiler-owned streamed SSR root signal immediately before the
3855        // component opening tag closes. Ordinary rendering ignores it byte-for-byte;
3856        // streaming rendering consumes it to inject ` data-ws` inside the opening tag
3857        // (so the marker exists before custom-element upgrade). Placing the signal
3858        // between the attribute run and the closing `>` is required because an
3859        // attribute cannot be injected once `>` has already been flushed.
3860        self.flush_raw_buffer(fragments);
3861        fragments.push(structural_signal(format!(
3862            "streaming_root:{}",
3863            element.name()
3864        )));
3865
3866        if element.self_closing() {
3867            self.add_raw_fragment("/>");
3868        } else {
3869            self.add_raw_fragment(">");
3870        }
3871
3872        self.flush_raw_buffer(fragments);
3873
3874        if let Some((html_content, css_content)) = template_source {
3875            let artifact_html = self
3876                .component_registry
3877                .component_artifact_source(element.name())
3878                .map(str::to_string);
3879            let built = self.build_component_templates(ComponentTemplateInput {
3880                tag_name: element.name(),
3881                html: &html_content,
3882                artifact_html: artifact_html.as_deref(),
3883                authored_html: authored_html.as_deref(),
3884                css_content: css_content.as_deref(),
3885                artifact_needed: self.plugin.is_some(),
3886            })?;
3887
3888            if let Some(ref mut p) = self.plugin {
3889                let component_data = self
3890                    .component_registry
3891                    .get(element.name())
3892                    .ok_or_else(|| {
3893                        ParserError::NotFound(format!(
3894                            "component <{}> disappeared during CSS compilation",
3895                            element.name()
3896                        ))
3897                    })?
3898                    .clone();
3899                p.component_built(built.plugin_context(&component_data))?;
3900            }
3901
3902            self.parse(element.name(), &built.ssr)?;
3903        }
3904
3905        fragments.push(WebUIFragment::component(element.name().to_string()));
3906
3907        if element.self_closing() {
3908            fragments.push(structural_signal(format!(
3909                "streaming_span_end:{}",
3910                element.name()
3911            )));
3912        } else {
3913            ops.push(ParseOp::EndComponent(element.name()));
3914            self.enter_component_content_scope(ops);
3915            ops.push(ParseOp::Parse {
3916                range: element.inner(),
3917                depth: depth + 1,
3918            });
3919        }
3920
3921        Ok(())
3922    }
3923
3924    fn analyze_component_dom(
3925        &mut self,
3926        tag_name: &str,
3927        source: &str,
3928        authored_source: Option<&str>,
3929    ) -> Result<ComponentDomAnalysis> {
3930        if let Some(analysis) = self.component_dom_analyses.get(tag_name) {
3931            return Ok(*analysis);
3932        }
3933        let analysis = analyze_component_dom_with_diagnostic_source(
3934            tag_name,
3935            source,
3936            self.options.dom_strategy,
3937            authored_source,
3938        )?;
3939        self.component_dom_analyses
3940            .insert(tag_name.to_string(), analysis);
3941        Ok(analysis)
3942    }
3943
3944    fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
3945        if !Self::should_emit_text_content(content) {
3946            return Ok(());
3947        }
3948
3949        let parsed_fragments = self.handlebars_parser.parse(content)?;
3950        for fragment in parsed_fragments {
3951            if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
3952                // Whitespace inside an emitted text run is part of the binding,
3953                // not inter-element formatting. Client text parts retain it too.
3954                self.add_raw_fragment(&raw.value);
3955            } else if let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() {
3956                if signal.raw && is_html_text_only_scope(self.boundary_parent_scope) {
3957                    self.add_fragment(
3958                        WebUIFragment::raw_text_signal(signal.value.as_str(), true),
3959                        fragments,
3960                    );
3961                } else {
3962                    self.add_fragment(fragment, fragments);
3963                }
3964            } else {
3965                self.add_fragment(fragment, fragments);
3966            }
3967        }
3968        Ok(())
3969    }
3970
3971    fn process_style_content(
3972        &mut self,
3973        css: &str,
3974        comments: &[crate::css_parser::CssComment],
3975        fragments: &mut Vec<WebUIFragment>,
3976    ) {
3977        let mut last_end = 0usize;
3978
3979        for comment in comments {
3980            if comment.start_byte < last_end {
3981                if comment.end_byte > last_end {
3982                    last_end = comment.end_byte;
3983                }
3984                continue;
3985            }
3986
3987            self.add_raw_fragment(&css[last_end..comment.start_byte]);
3988            let comment_text = &css[comment.start_byte..comment.end_byte];
3989            if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
3990                self.add_fragment(fragment, fragments);
3991            } else if comment.preserve {
3992                self.add_raw_fragment(comment_text);
3993            }
3994            last_end = comment.end_byte;
3995        }
3996
3997        self.add_raw_fragment(&css[last_end..]);
3998    }
3999
4000    fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
4001        let signal = comment_policy::parse_css_signal_comment(comment)?;
4002        Some(WebUIFragment::raw_text_signal(signal.path, signal.raw))
4003    }
4004
4005    /// Check if an attribute value is a pure handlebars expression (e.g., "{{name}}" or
4006    /// "{{name}}" with quotes). Returns the inner signal name (borrowed) if so.
4007    fn extract_single_handlebars(value: &str) -> Option<&str> {
4008        let trimmed = value.trim();
4009        if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
4010            let inner = trimmed[2..trimmed.len() - 2].trim();
4011            // Verify there's no other {{ in the middle (i.e., it's truly a single expression)
4012            if !inner.contains("{{") && !inner.is_empty() {
4013                return Some(inner);
4014            }
4015        }
4016        None
4017    }
4018
4019    /// Check if an attribute value contains any handlebars expressions.
4020    fn contains_handlebars(value: &str) -> bool {
4021        value.contains("{{")
4022    }
4023
4024    fn process_tag_attributes(
4025        &mut self,
4026        attrs: Attrs<'_>,
4027        fragments: &mut Vec<WebUIFragment>,
4028        is_component: bool,
4029    ) -> Result<u32> {
4030        let mut first_dynamic_emitted = false;
4031        let mut binding_count: u32 = 0;
4032        for attr in attrs {
4033            let attr_name = attr.name;
4034            let attr_value = attr.value;
4035            let is_property_binding = attr_name.starts_with(':')
4036                && attr_value
4037                    .and_then(Self::extract_single_handlebars)
4038                    .is_some();
4039
4040            // `:property="{{expr}}"` is WebUI-owned syntax. Plugins may classify
4041            // FAST's single-brace `:property="{expr}"`, but cannot discard the
4042            // server-side scope transfer before parser-core processes it.
4043            if !is_property_binding {
4044                if let Some(ref mut p) = self.plugin {
4045                    match p.process_attribute(AttributeContext { name: attr_name }) {
4046                        AttributeAction::Keep => {}
4047                        AttributeAction::Skip => continue,
4048                        AttributeAction::SkipAndCountBinding => {
4049                            binding_count += 1;
4050                            continue;
4051                        }
4052                    }
4053                }
4054            }
4055
4056            if let Some(bool_name) = attr_name.strip_prefix('?') {
4057                if is_component {
4058                    if let Some(condition) = self.parse_boolean_condition(attr_value) {
4059                        let frag = Self::maybe_mark_attr_start(
4060                            WebUIFragment::attribute_boolean(bool_name, condition),
4061                            &mut first_dynamic_emitted,
4062                        );
4063                        self.add_fragment(frag, fragments);
4064                        binding_count += 1;
4065                    }
4066                } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
4067                    binding_count += 1;
4068                }
4069            } else if let Some(prop_name) = attr_name.strip_prefix(':') {
4070                if !is_component {
4071                    return Err(ParserError::Parse(format!(
4072                        ":{prop_name} complex binding is only allowed on custom elements. \
4073                         Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
4074                    )));
4075                }
4076                if Self::is_blocked_complex_property(prop_name) {
4077                    return Err(ParserError::Parse(format!(
4078                        ":{prop_name} is not allowed as a complex attribute binding \
4079                         because it enables arbitrary HTML injection. \
4080                         Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
4081                    )));
4082                }
4083                if let Some(val) = attr_value {
4084                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
4085                        let frag = Self::maybe_mark_attr_start(
4086                            WebUIFragment::attribute_complex(attr_name, signal_name),
4087                            &mut first_dynamic_emitted,
4088                        );
4089                        self.add_fragment(frag, fragments);
4090                        binding_count += 1;
4091                    }
4092                }
4093            } else if is_component && Self::is_skipped_attribute(attr_name) {
4094                if let Some(val) = attr_value {
4095                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
4096                        let frag = WebUIFragment {
4097                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4098                                WebUIFragmentAttribute {
4099                                    name: attr_name.to_string(),
4100                                    value: signal_name.to_string(),
4101                                    attr_skip: true,
4102                                    ..Default::default()
4103                                },
4104                            )),
4105                        };
4106                        self.add_fragment(frag, fragments);
4107                        binding_count += 1;
4108                    } else if Self::contains_handlebars(val) {
4109                        let template_id = self.next_fragment_id("attr");
4110                        let parsed = self.parse_attribute_template(val)?;
4111                        self.fragment_records.insert(
4112                            template_id.clone(),
4113                            FragmentList {
4114                                fragments: parsed,
4115                                contains_boundary: false,
4116                            },
4117                        );
4118                        let frag = WebUIFragment {
4119                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4120                                WebUIFragmentAttribute {
4121                                    name: attr_name.to_string(),
4122                                    template: template_id,
4123                                    attr_skip: true,
4124                                    ..Default::default()
4125                                },
4126                            )),
4127                        };
4128                        self.add_fragment(frag, fragments);
4129                        binding_count += 1;
4130                    } else {
4131                        let frag = WebUIFragment {
4132                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4133                                WebUIFragmentAttribute {
4134                                    name: attr_name.to_string(),
4135                                    value: html_escape::decode_html_entities(val).into_owned(),
4136                                    raw_value: true,
4137                                    attr_skip: true,
4138                                    ..Default::default()
4139                                },
4140                            )),
4141                        };
4142                        self.add_fragment(frag, fragments);
4143                    }
4144                } else {
4145                    self.add_raw_fragment(" ");
4146                    self.add_raw_fragment(attr_name);
4147                }
4148            } else if let Some(val) = attr_value {
4149                if Self::contains_handlebars(val) {
4150                    if is_component {
4151                        if let Some(signal_name) = Self::extract_single_handlebars(val) {
4152                            let frag = Self::maybe_mark_attr_start(
4153                                WebUIFragment::attribute(attr_name, signal_name),
4154                                &mut first_dynamic_emitted,
4155                            );
4156                            self.add_fragment(frag, fragments);
4157                            binding_count += 1;
4158                        } else {
4159                            let template_id = self.next_fragment_id("attr");
4160                            let parsed = self.parse_attribute_template(val)?;
4161                            self.fragment_records.insert(
4162                                template_id.clone(),
4163                                FragmentList {
4164                                    fragments: parsed,
4165                                    contains_boundary: false,
4166                                },
4167                            );
4168                            let frag = Self::maybe_mark_attr_start(
4169                                WebUIFragment::attribute_template(attr_name, template_id),
4170                                &mut first_dynamic_emitted,
4171                            );
4172                            self.add_fragment(frag, fragments);
4173                            binding_count += 1;
4174                        }
4175                    } else {
4176                        self.process_dynamic_attribute(attr_name, val, fragments)?;
4177                        binding_count += 1;
4178                    }
4179                } else if is_component {
4180                    let frag = Self::maybe_mark_attr_start(
4181                        WebUIFragment {
4182                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4183                                WebUIFragmentAttribute {
4184                                    name: attr_name.to_string(),
4185                                    value: html_escape::decode_html_entities(val).into_owned(),
4186                                    raw_value: true,
4187                                    ..Default::default()
4188                                },
4189                            )),
4190                        },
4191                        &mut first_dynamic_emitted,
4192                    );
4193                    self.add_fragment(frag, fragments);
4194                } else {
4195                    self.add_raw_fragment(" ");
4196                    self.add_raw_fragment(attr.raw);
4197                }
4198            } else if is_component {
4199                let fragment = Self::maybe_mark_attr_start(
4200                    WebUIFragment::attribute_boolean(attr_name, ConditionExpr::identifier("true")),
4201                    &mut first_dynamic_emitted,
4202                );
4203                self.add_fragment(fragment, fragments);
4204            } else {
4205                self.add_raw_fragment(" ");
4206                self.add_raw_fragment(attr_name);
4207            }
4208        }
4209        Ok(binding_count)
4210    }
4211
4212    fn parse_attribute_template(&mut self, value: &str) -> Result<Vec<WebUIFragment>> {
4213        let mut fragments = self.handlebars_parser.parse(value)?;
4214        for fragment in &mut fragments {
4215            if let Some(web_ui_fragment::Fragment::Raw(raw)) = &mut fragment.fragment {
4216                if let std::borrow::Cow::Owned(decoded) =
4217                    html_escape::decode_html_entities(&raw.value)
4218                {
4219                    raw.value = decoded;
4220                }
4221            }
4222        }
4223        Ok(fragments)
4224    }
4225
4226    /// Set `attr_start = true` on the first non-skipped attribute fragment for
4227    /// a component element.
4228    fn maybe_mark_attr_start(
4229        mut frag: WebUIFragment,
4230        first_dynamic_emitted: &mut bool,
4231    ) -> WebUIFragment {
4232        if !*first_dynamic_emitted {
4233            if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
4234                a.attr_start = true;
4235            }
4236            *first_dynamic_emitted = true;
4237        }
4238        frag
4239    }
4240
4241    /// Process a boolean attribute (?prefix). Silently drops if value is not a
4242    /// pure handlebars expression.
4243    fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
4244        if let Some(val) = value {
4245            if let Some(expr_str) = Self::extract_single_handlebars(val) {
4246                return Some(
4247                    self.condition_parser
4248                        .parse(expr_str)
4249                        .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
4250                );
4251            }
4252        }
4253
4254        None
4255    }
4256
4257    fn process_boolean_attribute(
4258        &mut self,
4259        name: &str,
4260        value: Option<&str>,
4261        fragments: &mut Vec<WebUIFragment>,
4262    ) -> Result<bool> {
4263        if let Some(condition) = self.parse_boolean_condition(value) {
4264            self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
4265            return Ok(true);
4266        }
4267        // Invalid boolean attribute — silently drop (no output at all)
4268        Ok(false)
4269    }
4270
4271    /// Process a dynamic attribute (regular name with handlebars in value).
4272    fn process_dynamic_attribute(
4273        &mut self,
4274        name: &str,
4275        value: &str,
4276        fragments: &mut Vec<WebUIFragment>,
4277    ) -> Result<()> {
4278        if let Some(signal_name) = Self::extract_single_handlebars(value) {
4279            // Pure handlebars — simple attribute fragment
4280            self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
4281        } else {
4282            // Mixed static + dynamic — create a template sub-stream
4283            let template_id = self.next_fragment_id("attr");
4284            let parsed = self.handlebars_parser.parse(value)?;
4285
4286            self.fragment_records.insert(
4287                template_id.clone(),
4288                FragmentList {
4289                    fragments: parsed,
4290                    contains_boundary: false,
4291                },
4292            );
4293
4294            self.add_fragment(
4295                WebUIFragment::attribute_template(name, template_id),
4296                fragments,
4297            );
4298        }
4299        Ok(())
4300    }
4301
4302    fn process_style_element(
4303        &mut self,
4304        element: &Element<'_>,
4305        fragments: &mut Vec<WebUIFragment>,
4306    ) -> Result<()> {
4307        self.add_raw_fragment(element.opening());
4308        let inner = element.inner();
4309        let style_content = &element.source()[inner.start..inner.end];
4310        let (defs, requirements, comments) = self
4311            .css_parser
4312            .extract_definitions_requirements_and_comments(
4313                style_content,
4314                self.options.legal_comments,
4315            )
4316            .map_err(|e| self.css_diagnostic(e))?;
4317        self.record_fragment_css_tokens(defs, requirements);
4318        self.process_style_content(style_content, &comments, fragments);
4319        if element.close_end() > element.content_end() {
4320            // Reconstruct the closing tag from the parsed name so the emitted
4321            // case mirrors the source (e.g. `<STYLE>` closes with `</STYLE>`),
4322            // matching how regular elements are closed via `ParseOp::EmitClose`.
4323            self.add_raw_fragment("</");
4324            self.add_raw_fragment(element.name());
4325            self.add_raw_fragment(">");
4326        }
4327        Ok(())
4328    }
4329
4330    /// Process a `<route>` directive.
4331    ///
4332    /// Emits a `Fragment::Route` protocol fragment. The handler renders
4333    /// `<webui-route>` elements with server-side route matching — matched
4334    /// routes get `active` + component content, non-matched get `display:none`.
4335    fn process_route_directive(
4336        &mut self,
4337        element: &Element<'_>,
4338        fragments: &mut Vec<WebUIFragment>,
4339        depth: usize,
4340    ) -> Result<()> {
4341        let attrs = Self::route_attrs_from_element(element);
4342        let path = attrs.path.clone();
4343        let component = attrs.component.clone();
4344        route_parser::validate_attributes(&attrs)?;
4345
4346        let route_params: std::collections::HashSet<String> =
4347            route_parser::extract_params(&path)?.into_iter().collect();
4348        if !attrs.cache_tags.is_empty() {
4349            route_parser::validate_tag_placeholders(
4350                &attrs.cache_tags,
4351                &route_params,
4352                "cache-tags",
4353                &path,
4354            )?;
4355        }
4356        if !attrs.invalidates.is_empty() {
4357            route_parser::validate_tag_placeholders(
4358                &attrs.invalidates,
4359                &route_params,
4360                "invalidates",
4361                &path,
4362            )?;
4363        }
4364
4365        if !attrs.pending_component.is_empty() {
4366            self.ensure_route_component_parsed(&attrs.pending_component)?;
4367        }
4368        if !attrs.error_component.is_empty() {
4369            self.ensure_route_component_parsed(&attrs.error_component)?;
4370        }
4371        self.ensure_route_component_parsed(&component)?;
4372        self.flush_raw_buffer(fragments);
4373        let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4374
4375        let mut all_params = std::collections::HashSet::new();
4376        all_params.extend(route_params);
4377        self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
4378        let children =
4379            self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
4380
4381        let mut route_fragment =
4382            route_parser::build_route_fragment(&attrs, component.clone(), children);
4383        route_fragment.content_fragment_id = content_fragment_id;
4384        fragments.push(WebUIFragment::route_from(route_fragment));
4385
4386        Ok(())
4387    }
4388
4389    fn parse_route_boundary_content(
4390        &mut self,
4391        route: &Element<'_>,
4392        depth: usize,
4393    ) -> Result<String> {
4394        let inner = route.inner();
4395        if !route.source()[inner.clone()].contains("<boundary") {
4396            return Ok(String::new());
4397        }
4398        let saved_buffer = std::mem::take(&mut self.raw_buffer);
4399        let previous_route_depth = self.route_depth;
4400        self.route_depth += 1;
4401        let mut content = Vec::new();
4402        let parse_result: Result<()> = (|| {
4403            for event in Walker::new_range(route.source(), inner.start, inner.end) {
4404                if let Event::Element(element) = event {
4405                    if element.name() == "boundary" {
4406                        self.parse_range(
4407                            element.source(),
4408                            element.start..element.close_end(),
4409                            &mut content,
4410                            depth + 1,
4411                        )?;
4412                    }
4413                }
4414            }
4415            self.flush_raw_buffer(&mut content);
4416            Ok(())
4417        })();
4418        self.route_depth = previous_route_depth;
4419        self.raw_buffer = saved_buffer;
4420        parse_result?;
4421
4422        if content.is_empty() {
4423            return Ok(String::new());
4424        }
4425        let fragment_id = self.next_fragment_id("route-content");
4426        self.fragment_records.insert(
4427            fragment_id.clone(),
4428            FragmentList {
4429                fragments: content,
4430                contains_boundary: false,
4431            },
4432        );
4433        Ok(fragment_id)
4434    }
4435
4436    /// Parse nested `<route>` children into route fragments.
4437    ///
4438    /// `depth` is the nesting level of the routes produced here (top-level
4439    /// `<route>` children are depth 1). Nested routes are validated against
4440    /// [`MAX_TEMPLATE_DEPTH`] so pathological route trees cannot exhaust the
4441    /// call stack via `parse_route_as_fragment` recursion.
4442    fn parse_child_routes(
4443        &mut self,
4444        source: &str,
4445        range: Range<usize>,
4446        ancestor_params: &std::collections::HashSet<String>,
4447        depth: usize,
4448    ) -> Result<Vec<WebUiFragmentRoute>> {
4449        if depth > MAX_TEMPLATE_DEPTH {
4450            return Err(self
4451                .html_error(
4452                    codes::EXCESSIVE_NESTING,
4453                    format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4454                    source,
4455                    range.start,
4456                )
4457                .help("flatten deeply nested <route> trees before building")
4458                .into());
4459        }
4460
4461        let mut children = Vec::new();
4462        for event in Walker::new_range(source, range.start, range.end) {
4463            match event {
4464                Event::Element(element) => {
4465                    self.validate_closed_element(&element)?;
4466                    self.reject_boundary_or_hydrate_in_route(&element, true)?;
4467                    if element.name() == "route" {
4468                        children.push(self.parse_route_as_fragment(
4469                            &element,
4470                            ancestor_params,
4471                            depth,
4472                        )?);
4473                    } else if element.name().eq_ignore_ascii_case("style") {
4474                        self.validate_style_element(&element)?;
4475                    } else if !element.self_closing() && !element.is_void() {
4476                        self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
4477                    }
4478                }
4479                Event::Text(text) => {
4480                    if text.contains('<') {
4481                        return Err(self
4482                            .authoring_error(
4483                                codes::MALFORMED_HTML_TAG,
4484                                "malformed HTML tag in <route>",
4485                            )
4486                            .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
4487                            .into());
4488                    }
4489                }
4490                Event::Comment(comment_range) => {
4491                    self.validate_comment_range(source, comment_range)?;
4492                }
4493                Event::Declaration(declaration_range) => {
4494                    self.validate_declaration_range(source, declaration_range)?;
4495                }
4496                Event::ClosingTag(closing_range) => {
4497                    return Err(self.unexpected_closing_tag_error(source, closing_range));
4498                }
4499            }
4500        }
4501
4502        Ok(children)
4503    }
4504
4505    fn validate_route_nesting_depth(
4506        &self,
4507        source: &str,
4508        range: Range<usize>,
4509        depth: usize,
4510    ) -> Result<()> {
4511        let mut stack = vec![(range, depth)];
4512        while let Some((range, depth)) = stack.pop() {
4513            if depth > MAX_TEMPLATE_DEPTH {
4514                return Err(self
4515                    .html_error(
4516                        codes::EXCESSIVE_NESTING,
4517                        format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4518                        source,
4519                        range.start,
4520                    )
4521                    .help("flatten deeply nested <route> trees before building")
4522                    .into());
4523            }
4524
4525            for event in Walker::new_range(source, range.start, range.end) {
4526                if let Event::Element(element) = event {
4527                    self.validate_closed_element(&element)?;
4528                    if element.name() == "route" && !element.self_closing() {
4529                        stack.push((element.inner(), depth + 1));
4530                    }
4531                }
4532            }
4533        }
4534        Ok(())
4535    }
4536
4537    fn validate_ignored_route_html(
4538        &mut self,
4539        source: &str,
4540        range: Range<usize>,
4541        depth: usize,
4542    ) -> Result<()> {
4543        let mut stack = vec![(range, depth)];
4544        while let Some((range, depth)) = stack.pop() {
4545            if depth > MAX_TEMPLATE_DEPTH {
4546                return Err(self
4547                    .html_error(
4548                        codes::EXCESSIVE_NESTING,
4549                        format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4550                        source,
4551                        range.start,
4552                    )
4553                    .help("split deeply nested markup into components, or reduce generated nesting")
4554                    .into());
4555            }
4556
4557            for event in Walker::new_range(source, range.start, range.end) {
4558                match event {
4559                    Event::Element(element) => {
4560                        self.validate_closed_element(&element)?;
4561                        self.reject_boundary_or_hydrate_in_route(&element, false)?;
4562                        if element.name().eq_ignore_ascii_case("style") {
4563                            self.validate_style_element(&element)?;
4564                        } else if !element.self_closing() && !element.is_void() {
4565                            stack.push((element.inner(), depth + 1));
4566                        }
4567                    }
4568                    Event::Text(text) => {
4569                        if text.contains('<') {
4570                            return Err(self
4571                                .authoring_error(
4572                                    codes::MALFORMED_HTML_TAG,
4573                                    "malformed HTML tag in <route>",
4574                                )
4575                                .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
4576                                .into());
4577                        }
4578                    }
4579                    Event::Comment(comment_range) => {
4580                        self.validate_comment_range(source, comment_range)?;
4581                    }
4582                    Event::Declaration(declaration_range) => {
4583                        self.validate_declaration_range(source, declaration_range)?;
4584                    }
4585                    Event::ClosingTag(closing_range) => {
4586                        return Err(self.unexpected_closing_tag_error(source, closing_range));
4587                    }
4588                }
4589            }
4590        }
4591        Ok(())
4592    }
4593
4594    /// Reject `<webui-hydrate>` in route markup and boundaries that are not
4595    /// direct route children.
4596    ///
4597    /// `<route>` children are validated by a separate well-formedness walker
4598    /// ([`Self::parse_child_routes`]/[`Self::validate_ignored_route_html`]),
4599    /// not the main fragment dispatch in [`Self::parse_range`], so both
4600    /// reserved directives need an explicit check here to produce the same
4601    /// structured diagnostics as everywhere else instead of silently passing
4602    /// through as ignored HTML.
4603    fn reject_boundary_or_hydrate_in_route(
4604        &self,
4605        element: &Element<'_>,
4606        allow_boundary: bool,
4607    ) -> Result<()> {
4608        if element.name() == "boundary" && !allow_boundary {
4609            Err(self.route_boundary_placement_error(element))
4610        } else if element.name().eq_ignore_ascii_case("webui-hydrate") {
4611            Err(self.authored_webui_hydrate_error(element))
4612        } else {
4613            Ok(())
4614        }
4615    }
4616
4617    fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
4618        if !element.self_closing()
4619            && !element.is_void()
4620            && element.close_end() == element.content_end()
4621        {
4622            return Err(self
4623                .html_error(
4624                    codes::UNCLOSED_HTML_TAG,
4625                    format!("unclosed <{}> tag", element.name()),
4626                    element.source(),
4627                    element.start,
4628                )
4629                .help(format!(
4630                    "add the matching </{}> closing tag, or make the element self-closing",
4631                    element.name()
4632                ))
4633                .into());
4634        }
4635        Ok(())
4636    }
4637
4638    fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
4639        let inner = element.inner();
4640        let style_content = &element.source()[inner.start..inner.end];
4641        self.css_parser
4642            .extract_definitions_requirements_and_comments(
4643                style_content,
4644                self.options.legal_comments,
4645            )
4646            .map_err(|e| self.css_diagnostic(e))?;
4647        Ok(())
4648    }
4649
4650    fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4651        let start = range.start;
4652        if source[range].ends_with("-->") {
4653            return Ok(());
4654        }
4655        Err(self
4656            .html_error(
4657                codes::UNTERMINATED_HTML_COMMENT,
4658                "unterminated HTML comment in <route>",
4659                source,
4660                start,
4661            )
4662            .help("close the comment with `-->`")
4663            .into())
4664    }
4665
4666    fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4667        let start = range.start;
4668        if source[range].ends_with('>') {
4669            return Ok(());
4670        }
4671        Err(self
4672            .html_error(
4673                codes::UNTERMINATED_HTML_DECLARATION,
4674                "unterminated HTML declaration in <route>",
4675                source,
4676                start,
4677            )
4678            .help("close the declaration with `>`")
4679            .into())
4680    }
4681
4682    fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
4683        let start = range.start;
4684        let title = match html::parse_tag(&source[range]) {
4685            Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
4686            None => "unexpected closing tag in <route>".to_string(),
4687        };
4688        self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
4689            .help("remove it, or add the matching opening tag before it")
4690            .into()
4691    }
4692
4693    fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
4694        route_parser::RouteAttributes {
4695            path: element.attr("path").unwrap_or_default().to_string(),
4696            component: element.attr("component").unwrap_or_default().to_string(),
4697            exact: element.has_attr("exact"),
4698            query: element.attr("query").unwrap_or_default().to_string(),
4699            keep_alive: element.has_attr("keep-alive"),
4700            cache_tags: element
4701                .attr("cache-tags")
4702                .map(route_parser::parse_tag_list)
4703                .unwrap_or_default(),
4704            invalidates: element
4705                .attr("invalidates")
4706                .map(route_parser::parse_tag_list)
4707                .unwrap_or_default(),
4708            pending_component: element.attr("pending").unwrap_or_default().to_string(),
4709            error_component: element.attr("error").unwrap_or_default().to_string(),
4710        }
4711    }
4712
4713    fn parse_route_as_fragment(
4714        &mut self,
4715        element: &Element<'_>,
4716        ancestor_params: &std::collections::HashSet<String>,
4717        depth: usize,
4718    ) -> Result<WebUiFragmentRoute> {
4719        let attrs = Self::route_attrs_from_element(element);
4720        let path = attrs.path.clone();
4721        let component = attrs.component.clone();
4722
4723        route_parser::validate_attributes(&attrs)?;
4724
4725        let own_params: std::collections::HashSet<String> =
4726            route_parser::extract_params(&path)?.into_iter().collect();
4727        let mut all_params = ancestor_params.clone();
4728        all_params.extend(own_params.iter().cloned());
4729        if !attrs.cache_tags.is_empty() {
4730            route_parser::validate_tag_placeholders(
4731                &attrs.cache_tags,
4732                &all_params,
4733                "cache-tags",
4734                &path,
4735            )?;
4736        }
4737        if !attrs.invalidates.is_empty() {
4738            route_parser::validate_tag_placeholders(
4739                &attrs.invalidates,
4740                &all_params,
4741                "invalidates",
4742                &path,
4743            )?;
4744        }
4745
4746        if !attrs.pending_component.is_empty() {
4747            self.ensure_route_component_parsed(&attrs.pending_component)?;
4748        }
4749        if !attrs.error_component.is_empty() {
4750            self.ensure_route_component_parsed(&attrs.error_component)?;
4751        }
4752
4753        self.ensure_route_component_parsed(&component)?;
4754        let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4755        let children =
4756            self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
4757
4758        let mut route = route_parser::build_route_fragment(&attrs, component, children);
4759        route.content_fragment_id = content_fragment_id;
4760        Ok(route)
4761    }
4762
4763    /// Ensure a route-referenced component is parsed and registered.
4764    fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
4765        if component.is_empty()
4766            || !self.component_registry.contains(component)
4767            || self.fragment_records.contains_key(component)
4768        {
4769            return Ok(());
4770        }
4771
4772        let component_data = self
4773            .component_registry
4774            .get(component)
4775            .ok_or_else(|| {
4776                self.authoring_error(
4777                    codes::UNKNOWN_COMPONENT,
4778                    format!("unknown component <{component}>"),
4779                )
4780                .help(self.unknown_component_help(component))
4781            })?
4782            .clone();
4783        let artifact_html = self
4784            .component_registry
4785            .component_artifact_source(component)
4786            .map(str::to_string);
4787        let authored_html = self.component_registry.component_authored_source(component);
4788
4789        let built = self.build_component_templates(ComponentTemplateInput {
4790            tag_name: component,
4791            html: &component_data.html_content,
4792            artifact_html: artifact_html.as_deref(),
4793            authored_html: authored_html.as_deref(),
4794            css_content: component_data.css_content.as_deref(),
4795            artifact_needed: self.plugin.is_some(),
4796        })?;
4797
4798        if let Some(ref mut p) = self.plugin {
4799            let component_data = self
4800                .component_registry
4801                .get(component)
4802                .ok_or_else(|| {
4803                    ParserError::NotFound(format!(
4804                        "component <{component}> disappeared during CSS compilation"
4805                    ))
4806                })?
4807                .clone();
4808            p.component_built(built.plugin_context(&component_data))?;
4809        }
4810
4811        let saved_buffer = std::mem::take(&mut self.raw_buffer);
4812        self.parse(component, &built.ssr)?;
4813        self.raw_buffer = saved_buffer;
4814
4815        Ok(())
4816    }
4817
4818    /// Skipped attribute names for components.
4819    const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
4820    /// Skipped attribute prefixes for components.
4821    const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-"];
4822    const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
4823
4824    fn is_skipped_attribute(name: &str) -> bool {
4825        Self::SKIPPED_ATTRIBUTES.contains(&name)
4826            || Self::SKIPPED_ATTRIBUTE_PREFIXES
4827                .iter()
4828                .any(|prefix| name.starts_with(prefix))
4829    }
4830
4831    /// Properties that must never be set via `:attr` complex bindings.
4832    /// These enable XSS (HTML injection) or arbitrary code execution.
4833    fn is_blocked_complex_property(name: &str) -> bool {
4834        matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
4835    }
4836
4837    /// Build both SSR-facing and plugin-facing component template views.
4838    fn build_component_templates(
4839        &mut self,
4840        input: ComponentTemplateInput<'_>,
4841    ) -> Result<BuiltComponentTemplate> {
4842        let tag_name = input.tag_name;
4843        let synthesize_fast_shadow =
4844            input.artifact_html.is_some() && self.options.dom_strategy == DomStrategy::Shadow;
4845        let fast_html = synthesize_fast_shadow
4846            .then(|| Self::append_shadow_mode_if_missing(input.html.to_string()));
4847        let html = fast_html.as_deref().unwrap_or(input.html);
4848        let css_content = input.css_content;
4849        let dom_analysis = self.analyze_component_dom(tag_name, html, input.authored_html)?;
4850        let is_light = !dom_analysis.uses_shadow_dom;
4851        let mut inline_style_ranges = Vec::new();
4852        if is_light {
4853            let mut comment_ranges = Vec::new();
4854            Self::collect_html_comment_and_style_ranges(
4855                html,
4856                &mut comment_ranges,
4857                &mut inline_style_ranges,
4858            )?;
4859            if let Some(css) = css_content {
4860                css_light::validate_global_css(tag_name, css)?;
4861            }
4862            for (start, end) in &inline_style_ranges {
4863                css_light::validate_global_css(tag_name, &html[*start..*end])?;
4864            }
4865        }
4866        if self.component_registry.contains(tag_name) {
4867            self.component_registry
4868                .prepare_policy_css(tag_name, dom_analysis.uses_shadow_dom)?;
4869        }
4870        let css_content = self
4871            .component_registry
4872            .get(tag_name)
4873            .and_then(|component| component.css_content.as_deref())
4874            .or(css_content);
4875        let adopted_specifier = match self.options.css_strategy {
4876            CssStrategy::Module if css_content.is_some() => Some(tag_name),
4877            _ => None,
4878        };
4879        // CSS resources are installed by the handler from the precomputed
4880        // tree-local closure. Keeping template serialization style-free avoids
4881        // duplicating Light descendants and lets repeated Shadow instances each
4882        // receive an exact, claimable resource set.
4883        //
4884        // The resolved delivery is reported to the plugin as build context; a
4885        // plugin whose client runtime builds its own roots from the captured
4886        // template decides for itself what to do with it. Light CSS is
4887        // Document/tree-owned and is installed by the precomputed closure.
4888        let style = if dom_analysis.uses_shadow_dom && self.plugin.is_some() {
4889            self.component_style_delivery(tag_name, css_content)
4890        } else {
4891            None
4892        };
4893
4894        let runtime_attr_source = match dom_analysis.authored_shadow_root {
4895            Some((start, end)) => &html[start..end],
4896            None => html,
4897        };
4898        let artifact_source = if input.artifact_needed {
4899            input.artifact_html.or_else(|| {
4900                Self::template_has_stripped_runtime_attrs(runtime_attr_source).then_some(html)
4901            })
4902        } else {
4903            None
4904        };
4905        // A `w-render`/`w-hydrate` component authors its policy on a plain
4906        // `<template>` wrapper. The policy lands on the host element as
4907        // generated CSS, so the wrapper itself must not survive into a Light
4908        // template, where its contents would never render.
4909        let policy_wrapper = parse_component_render_policy(tag_name, html)?.is_authored();
4910        // Keep root runtime attributes only when the plugin processes them.
4911        let preserve_root_bindings = self.component_processing.process_root_template_attributes;
4912        // Keep CSS braces after client bindings when requested by the plugin.
4913        let styles_at_end = matches!(self.options.css_strategy, CssStrategy::Style)
4914            && self.component_processing.inline_styles_after_content;
4915        let ssr = self.process_component_template_with_mode(
4916            html,
4917            ComponentStyleInjection {
4918                css_snippet: None,
4919                adopted_specifier,
4920            },
4921            dom_analysis,
4922            ComponentTemplateMode {
4923                preserve_runtime_attrs: preserve_root_bindings,
4924                policy_wrapper,
4925                styles_at_end,
4926            },
4927        )?;
4928        let artifact = match artifact_source {
4929            Some(source) => {
4930                let fast_artifact = synthesize_fast_shadow
4931                    .then(|| Self::append_shadow_mode_if_missing(source.to_string()));
4932                let source = fast_artifact.as_deref().unwrap_or(source);
4933                let artifact_dom_analysis = analyze_component_dom_with_diagnostic_source(
4934                    tag_name,
4935                    source,
4936                    self.options.dom_strategy,
4937                    input.authored_html,
4938                )?;
4939                Some(self.process_component_template_with_mode(
4940                    source,
4941                    ComponentStyleInjection {
4942                        css_snippet: None,
4943                        adopted_specifier,
4944                    },
4945                    artifact_dom_analysis,
4946                    ComponentTemplateMode {
4947                        // Client artifacts preserve runtime-only attributes
4948                        // (`@event`, `:bind`, `?cond`) for the plugin runtime.
4949                        preserve_runtime_attrs: true,
4950                        policy_wrapper,
4951                        styles_at_end,
4952                    },
4953                )?)
4954            }
4955            None => None,
4956        };
4957        Ok(BuiltComponentTemplate {
4958            ssr,
4959            artifact,
4960            uses_shadow_dom: dom_analysis.uses_shadow_dom,
4961            style,
4962        })
4963    }
4964
4965    /// Resolve how a component's compiled CSS reaches the browser.
4966    ///
4967    /// Build-time only, once per component definition, and only when a plugin
4968    /// can observe it.
4969    fn component_style_delivery(
4970        &self,
4971        tag_name: &str,
4972        css_content: Option<&str>,
4973    ) -> Option<OwnedComponentStyle> {
4974        let css = css_content?;
4975        Some(match self.options.css_strategy {
4976            CssStrategy::Link => {
4977                OwnedComponentStyle::Link(self.options.css_link_options.resolve(tag_name, css).href)
4978            }
4979            CssStrategy::Style => OwnedComponentStyle::Inline(css.trim().to_string()),
4980            CssStrategy::Module => OwnedComponentStyle::Adopted(tag_name.to_string()),
4981        })
4982    }
4983
4984    /// Process component template HTML for SSR output.
4985    ///
4986    /// An authored declarative Shadow DOM wrapper is the source of truth.
4987    ///
4988    /// - **Dev supplied `<template shadowrootmode="open">`:** preserved verbatim — including
4989    ///   `shadowrootmode`, `shadowrootadoptedstylesheets`, signal fragments,
4990    ///   and any other custom attributes. SSR strips runtime-only attributes
4991    ///   (`@event`, `:bind`, `?cond`) from the opening tag, since those are
4992    ///   protocol metadata and never appear in HTML output. Plugin-facing
4993    ///   artifacts preserve them. If a CSS snippet is supplied, it is injected
4994    ///   immediately inside the opening tag (before the dev's children) so
4995    ///   styles still apply. For `CssStrategy::Module`, the parser appends
4996    ///   `shadowrootadoptedstylesheets="<tag>"` when it is missing.
4997    ///
4998    /// - **Sole bare `<template>` root:** unwraps the wrapper and emits its
4999    ///   contents as explicit Light DOM.
5000    ///
5001    /// - **No authored root:** follows the configured fallback, generating an
5002    ///   open Shadow wrapper for `DomStrategy::Shadow` or emitting direct Light
5003    ///   DOM for `DomStrategy::Light`.
5004    ///
5005    /// Performance: zero recursion, zero regex. The dev-template path uses
5006    /// quote-aware scanners for opening-tag queries and pre-sizes the output
5007    /// buffer to avoid reallocation in the hot path.
5008    #[cfg(test)]
5009    fn process_component_template(
5010        &mut self,
5011        html: &str,
5012        css_snippet: Option<&str>,
5013        adopted_specifier: Option<&str>,
5014    ) -> Result<String> {
5015        let dom_analysis = analyze_component_dom("component", html, self.options.dom_strategy)?;
5016        self.process_component_template_with_mode(
5017            html,
5018            ComponentStyleInjection {
5019                css_snippet,
5020                adopted_specifier,
5021            },
5022            dom_analysis,
5023            ComponentTemplateMode {
5024                preserve_runtime_attrs: false,
5025                policy_wrapper: false,
5026                styles_at_end: false,
5027            },
5028        )
5029    }
5030
5031    fn process_component_template_with_mode(
5032        &mut self,
5033        html: &str,
5034        style: ComponentStyleInjection<'_>,
5035        dom_analysis: ComponentDomAnalysis,
5036        mode: ComponentTemplateMode,
5037    ) -> Result<String> {
5038        let trimmed_end = html.trim_end();
5039        let (trimmed, content_start) = html::leading_content(trimmed_end);
5040        let snippet = style.css_snippet.unwrap_or_default();
5041
5042        let processed = if let Some((root_start, root_end)) = dom_analysis.authored_shadow_root {
5043            let trim_start = content_start;
5044            let trim_end = html.trim_end().len();
5045            if root_start < trim_start || root_end > trim_end || root_start >= root_end {
5046                return Err(ParserError::Html(
5047                    "invalid declarative Shadow DOM source range".to_string(),
5048                ));
5049            }
5050            let root = &html[root_start..root_end];
5051            // Compiler-owned policy attributes are stripped from both views;
5052            // runtime attributes survive only in the captured client template.
5053            let base = self.strip_template_build_attrs(root, mode.preserve_runtime_attrs);
5054            let with_adopted = Self::append_adopted_attr_if_missing(base, style.adopted_specifier);
5055            let root =
5056                Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end);
5057            let mut result =
5058                String::with_capacity(trimmed.len() + root.len() - (root_end - root_start));
5059            result.push_str(&html[trim_start..root_start]);
5060            result.push_str(&root);
5061            result.push_str(&html[root_end..trim_end]);
5062            result
5063        } else if let Some((content_start, content_end)) = dom_analysis.authored_light_root {
5064            let base = html[content_start..content_end].to_string();
5065            if snippet.is_empty() {
5066                base
5067            } else {
5068                let mut result = String::with_capacity(snippet.len() + base.len());
5069                result.push_str(snippet);
5070                result.push_str(&base);
5071                result
5072            }
5073        } else if dom_analysis.uses_shadow_dom {
5074            if mode.policy_wrapper {
5075                let base = self.strip_template_build_attrs(trimmed, mode.preserve_runtime_attrs);
5076                let with_shadow = Self::append_shadow_mode_if_missing(base);
5077                let with_adopted =
5078                    Self::append_adopted_attr_if_missing(with_shadow, style.adopted_specifier);
5079                Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end)
5080            } else {
5081                let adopted = style.adopted_specifier.unwrap_or_default();
5082                let adopted_extra = if adopted.is_empty() {
5083                    0
5084                } else {
5085                    Self::adopted_attr_len(adopted)
5086                };
5087                let mut result =
5088                    String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
5089                result.push_str("<template shadowrootmode=\"open\"");
5090                if !adopted.is_empty() {
5091                    Self::push_adopted_attr(&mut result, adopted);
5092                }
5093                result.push('>');
5094                if mode.styles_at_end {
5095                    result.push_str(trimmed);
5096                    result.push_str(snippet);
5097                } else {
5098                    result.push_str(snippet);
5099                    result.push_str(trimmed);
5100                }
5101                result.push_str("</template>");
5102                result
5103            }
5104        } else {
5105            // A policy component authors its `w-render`/`w-hydrate` on a plain
5106            // `<template>` wrapper. The policy is applied to the host element
5107            // through generated CSS, so a Light template must drop the wrapper
5108            // or its contents would never render.
5109            let base = if mode.policy_wrapper {
5110                Self::unwrap_component_template(trimmed)
5111            } else {
5112                trimmed.to_string()
5113            };
5114            if snippet.is_empty() {
5115                base
5116            } else {
5117                let mut result = String::with_capacity(snippet.len() + base.len());
5118                result.push_str(snippet);
5119                result.push_str(&base);
5120                result
5121            }
5122        };
5123
5124        self.strip_template_comments(processed)
5125    }
5126
5127    fn inject_css_snippet_into_template(html: String, snippet: &str, at_end: bool) -> String {
5128        if snippet.is_empty() {
5129            return html;
5130        }
5131
5132        // Trail the snippet after the template body (before the closing
5133        // `</template>`) so a client runtime that scans the body for `{`/`}`
5134        // bindings (FAST's declarative TemplateParser) never mistakes raw CSS
5135        // rule blocks for bindings. Styles apply regardless of shadow-root
5136        // position. Falls back to opening-tag injection when no closing tag is
5137        // present so styles are never silently dropped.
5138        if at_end {
5139            if let Some(close_start) = html.rfind("</template>") {
5140                let mut result = String::with_capacity(html.len() + snippet.len());
5141                result.push_str(&html[..close_start]);
5142                result.push_str(snippet);
5143                result.push_str(&html[close_start..]);
5144                return result;
5145            }
5146        }
5147
5148        match html::find_tag_close(&html) {
5149            Some(open_end) => {
5150                let mut result = String::with_capacity(html.len() + snippet.len());
5151                result.push_str(&html[..=open_end]);
5152                result.push_str(snippet);
5153                result.push_str(&html[open_end + 1..]);
5154                result
5155            }
5156            // Malformed opening tag (no closing `>`): emit as-is rather than
5157            // panic. The downstream parser surfaces the error.
5158            None => html,
5159        }
5160    }
5161
5162    fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
5163        let Some(adopted) = adopted_specifier else {
5164            return html;
5165        };
5166        let Some(tag) = html::parse_tag(&html) else {
5167            return html;
5168        };
5169        if !tag.name.eq_ignore_ascii_case("template")
5170            || tag.closing
5171            || tag.attrs().any(|attr| {
5172                attr.name
5173                    .eq_ignore_ascii_case(Self::ADOPTED_STYLESHEETS_ATTR)
5174            })
5175        {
5176            return html;
5177        }
5178
5179        let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
5180        result.push_str(&html[..tag.close]);
5181        Self::push_adopted_attr(&mut result, adopted);
5182        result.push_str(&html[tag.close..]);
5183        result
5184    }
5185
5186    fn append_shadow_mode_if_missing(html: String) -> String {
5187        let Some(tag) = html::parse_tag(&html) else {
5188            return html;
5189        };
5190        if !tag.name.eq_ignore_ascii_case("template")
5191            || tag.closing
5192            || tag
5193                .attrs()
5194                .any(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
5195        {
5196            return html;
5197        }
5198
5199        let mut result = String::with_capacity(html.len() + 22);
5200        result.push_str(&html[..tag.close]);
5201        result.push_str(" shadowrootmode=\"open\"");
5202        result.push_str(&html[tag.close..]);
5203        result
5204    }
5205
5206    fn unwrap_component_template(html: &str) -> String {
5207        let Some(tag) = html::parse_tag(html) else {
5208            return html.to_string();
5209        };
5210        let inner_start = tag.close + 1;
5211        let inner_end = html.rfind("</template>").unwrap_or(html.len());
5212        if inner_start >= inner_end {
5213            return String::new();
5214        }
5215        html[inner_start..inner_end].to_string()
5216    }
5217
5218    fn push_adopted_attr(out: &mut String, adopted: &str) {
5219        out.push(' ');
5220        out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
5221        out.push_str("=\"");
5222        out.push_str(adopted);
5223        out.push('"');
5224    }
5225
5226    fn adopted_attr_len(adopted: &str) -> usize {
5227        4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
5228    }
5229
5230    fn template_has_stripped_runtime_attrs(html: &str) -> bool {
5231        let (trimmed, _) = html::leading_content(html);
5232        let Some(tag) = html::parse_tag(trimmed) else {
5233            return false;
5234        };
5235        if !tag.name.eq_ignore_ascii_case("template") || tag.closing {
5236            return false;
5237        }
5238        tag.attrs().any(|attr| {
5239            attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
5240        })
5241    }
5242
5243    fn strip_template_comments(&mut self, html: String) -> Result<String> {
5244        let mut ranges = Vec::new();
5245        let mut style_ranges = Vec::new();
5246        Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
5247
5248        for (style_start, style_end) in style_ranges {
5249            let css = &html[style_start..style_end];
5250            let (_defs, _requirements, comments) = self
5251                .css_parser
5252                .extract_definitions_requirements_and_comments(css, self.options.legal_comments)
5253                .map_err(|e| self.css_diagnostic(e))?;
5254            for comment in comments {
5255                let comment_text = &css[comment.start_byte..comment.end_byte];
5256                if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
5257                    continue;
5258                }
5259                ranges.push((
5260                    style_start + comment.start_byte,
5261                    style_start + comment.end_byte,
5262                ));
5263            }
5264        }
5265
5266        if ranges.is_empty() {
5267            return Ok(html);
5268        }
5269
5270        Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
5271    }
5272
5273    /// Collect HTML comment ranges and `<style>` raw-text ranges in one scan.
5274    fn collect_html_comment_and_style_ranges(
5275        html: &str,
5276        comment_ranges: &mut Vec<(usize, usize)>,
5277        style_ranges: &mut Vec<(usize, usize)>,
5278    ) -> Result<()> {
5279        let mut index = 0usize;
5280        while index < html.len() {
5281            let remaining = &html[index..];
5282            if remaining.starts_with("<!--") {
5283                let Some(end) = html::find_comment_close(remaining) else {
5284                    return Err(Diagnostic::error("unterminated HTML comment")
5285                        .code(codes::UNTERMINATED_HTML_COMMENT)
5286                        .at_offset(html, index)
5287                        .help("close the comment with `-->`")
5288                        .into());
5289                };
5290                comment_ranges.push((index, index + end));
5291                index += end;
5292                continue;
5293            }
5294
5295            if remaining.starts_with('<') {
5296                if let Some((style_start, style_end, close_end)) =
5297                    html::style_element_bounds(remaining)
5298                {
5299                    style_ranges.push((index + style_start, index + style_end));
5300                    index += close_end;
5301                    continue;
5302                }
5303
5304                if let Some(tag) = html::parse_tag(remaining) {
5305                    index += tag.close + 1;
5306                    continue;
5307                }
5308            }
5309
5310            let Some(ch) = remaining.chars().next() else {
5311                break;
5312            };
5313            index += ch.len_utf8();
5314        }
5315        Ok(())
5316    }
5317
5318    /// Strip compiler-owned component policy attributes and, for the SSR view,
5319    /// runtime attributes starting with `@`, `:`, or `?` from the opening
5320    /// `<template>` tag.
5321    ///
5322    /// Uses the same quote-aware tag scanner as the main HTML pipeline.
5323    fn strip_template_build_attrs(&mut self, html: &str, preserve_runtime_attrs: bool) -> String {
5324        let Some(tag) = html::parse_tag(html) else {
5325            return html.to_string();
5326        };
5327
5328        let mut removals: Vec<(usize, usize)> = Vec::new();
5329        for attr in tag.attrs() {
5330            let runtime_attr = attr.name.starts_with('@')
5331                || attr.name.starts_with(':')
5332                || attr.name.starts_with('?');
5333            let policy_attr = matches!(
5334                attr.name,
5335                COMPONENT_RENDER_ATTR | COMPONENT_HYDRATE_ATTR | COMPONENT_RESERVE_BLOCK_SIZE_ATTR
5336            );
5337            if policy_attr || (!preserve_runtime_attrs && runtime_attr) {
5338                let mut start = attr.raw_range.start;
5339                while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
5340                    start -= 1;
5341                }
5342                removals.push((start, attr.raw_range.end));
5343            }
5344        }
5345
5346        if removals.is_empty() {
5347            return html.to_string();
5348        }
5349
5350        // Rebuild the string, skipping removed ranges
5351        let mut result = String::with_capacity(html.len());
5352        let mut pos = 0;
5353        for (start, end) in &removals {
5354            result.push_str(&html[pos..*start]);
5355            pos = *end;
5356        }
5357        result.push_str(&html[pos..]);
5358        result
5359    }
5360}
5361
5362#[cfg(test)]
5363mod tests {
5364    use webui_test_utils::*;
5365
5366    use super::*;
5367
5368    #[derive(Default)]
5369    struct ContextArtifactPlugin {
5370        artifacts: Vec<crate::plugin::ComponentTemplateArtifact>,
5371    }
5372
5373    impl ParserPlugin for ContextArtifactPlugin {
5374        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5375            self.artifacts
5376                .push(crate::plugin::ComponentTemplateArtifact::template(
5377                    context.component.tag_name.clone(),
5378                    context.template.to_string(),
5379                    context.uses_shadow_dom,
5380                ));
5381            Ok(())
5382        }
5383
5384        fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
5385            Ok(ParserPluginArtifacts::ComponentTemplates(self.artifacts))
5386        }
5387    }
5388
5389    fn structural_matcher(value: &str) -> FragmentMatcher {
5390        signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
5391    }
5392
5393    mod fast_parser;
5394
5395    fn boundary_matcher(name: &str, declaration: u32) -> FragmentMatcher {
5396        webui_test_utils::boundary(name, declaration)
5397    }
5398
5399    fn boundary_end_matcher(declaration: u32) -> FragmentMatcher {
5400        webui_test_utils::boundary_end(declaration)
5401    }
5402
5403    #[test]
5404    fn unwrapped_component_uses_configured_dom_strategy() {
5405        assert!(
5406            analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Shadow)
5407                .expect("valid component")
5408                .uses_shadow_dom
5409        );
5410        assert!(
5411            !analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Light)
5412                .expect("valid component")
5413                .uses_shadow_dom
5414        );
5415    }
5416
5417    #[test]
5418    fn bare_template_explicitly_selects_light_dom_over_shadow_fallback() {
5419        let analysis = analyze_component_dom(
5420            "x-card",
5421            "<template><div>content</div></template>",
5422            DomStrategy::Shadow,
5423        )
5424        .expect("valid explicit Light wrapper");
5425        assert!(!analysis.uses_shadow_dom);
5426        assert!(analysis.authored_light_root.is_some());
5427
5428        let attributed = analyze_component_dom(
5429            "x-card",
5430            r#"<template data-purpose="content"><div>content</div></template>"#,
5431            DomStrategy::Shadow,
5432        )
5433        .expect("ordinary template remains valid");
5434        assert!(attributed.uses_shadow_dom);
5435        assert!(attributed.authored_light_root.is_none());
5436    }
5437
5438    #[test]
5439    fn plugin_artifacts_preserve_resolved_shadow_mode() {
5440        let component_html = r#"<template shadowrootmode="open"><p>shadow</p></template>"#;
5441        {
5442            let mut parser = HtmlParser::with_plugin_options(
5443                Box::new(ContextArtifactPlugin::default()),
5444                CssStrategy::Style,
5445            );
5446            parser
5447                .component_registry_mut()
5448                .register_component(ComponentRegistration::new(
5449                    "x-card",
5450                    component_html,
5451                    None,
5452                    false,
5453                ))
5454                .expect("register component");
5455            parser
5456                .parse("index.html", "<x-card></x-card>")
5457                .expect("parse component");
5458
5459            let ParserPluginArtifacts::ComponentTemplates(artifacts) =
5460                parser.take_plugin_artifacts().expect("consume artifacts")
5461            else {
5462                panic!("expected component artifacts");
5463            };
5464            assert!(artifacts[0].uses_shadow_dom);
5465        }
5466    }
5467
5468    #[test]
5469    fn open_shadow_wrapper_marks_component_shadow() {
5470        let html =
5471            "<!-- lead --><template shadowrootmode='open'><slot></slot></template><!-- tail -->";
5472        assert!(
5473            analyze_component_dom("x-card", html, DomStrategy::Light)
5474                .expect("valid Shadow wrapper")
5475                .uses_shadow_dom
5476        );
5477    }
5478
5479    #[test]
5480    fn closed_shadow_wrapper_is_rejected() {
5481        let error = analyze_component_dom(
5482            "x-card",
5483            "<template shadowrootmode=\"closed\"><p>content</p></template>",
5484            DomStrategy::Shadow,
5485        )
5486        .expect_err("closed mode must fail");
5487        assert!(matches!(
5488            error,
5489            ParserError::Template(ref diagnostic)
5490                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5491                    && diagnostic.component_name() == Some("x-card")
5492        ));
5493    }
5494
5495    #[test]
5496    fn shadow_wrapper_must_be_the_only_top_level_element() {
5497        let error = analyze_component_dom(
5498            "x-card",
5499            "<template shadowrootmode=\"open\"><p>content</p></template><span>extra</span>",
5500            DomStrategy::Light,
5501        )
5502        .expect_err("multiple roots must fail");
5503        assert!(matches!(
5504            error,
5505            ParserError::Template(ref diagnostic)
5506                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5507        ));
5508    }
5509
5510    #[test]
5511    fn shadowrootmode_rejects_invalid_value_and_placement() {
5512        for html in [
5513            "<template shadowrootmode=\"invalid\"><p>content</p></template>",
5514            "<template shadowrootmode=\" open \"><p>content</p></template>",
5515            "<template :shadowrootmode=\"{{mode}}\"><p>content</p></template>",
5516            "<template shadowrootmode=\"open\" />",
5517            "<div><template shadowrootmode=\"open\"><p>content</p></template></div>",
5518            "<div shadowrootmode=\"open\">content</div>",
5519        ] {
5520            let error = analyze_component_dom("x-card", html, DomStrategy::Shadow)
5521                .expect_err("invalid shadowrootmode must fail");
5522            assert!(matches!(
5523                error,
5524                ParserError::Template(ref diagnostic)
5525                    if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5526                        && diagnostic.component_name() == Some("x-card")
5527                        && diagnostic.help_text().is_some()
5528                        && diagnostic.snippet_text().is_some()
5529            ));
5530        }
5531    }
5532
5533    #[test]
5534    fn light_component_slot_is_rejected() {
5535        let source = "<div><slot name=\"label\"></slot></div>";
5536        let error = analyze_component_dom("x-card", source, DomStrategy::Light)
5537            .expect_err("Light slot must fail");
5538        assert!(matches!(
5539            error,
5540            ParserError::Template(ref diagnostic)
5541                if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5542                    && diagnostic.help_text().is_some_and(|help| help.contains("shadowrootmode"))
5543        ));
5544        assert!(
5545            analyze_component_dom("x-card", source, DomStrategy::Shadow)
5546                .expect("implicit Shadow components support slots")
5547                .uses_shadow_dom
5548        );
5549
5550        let explicit_light = "<template><slot name=\"label\"></slot></template>";
5551        let error = analyze_component_dom("x-card", explicit_light, DomStrategy::Shadow)
5552            .expect_err("bare template explicitly selects Light");
5553        assert!(matches!(
5554            error,
5555            ParserError::Template(ref diagnostic)
5556                if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5557        ));
5558    }
5559
5560    #[test]
5561    fn webui_plugin_records_authored_shadow_wrapper() {
5562        let mut parser =
5563            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5564        parser
5565            .component_registry_mut()
5566            .register_component(ComponentRegistration::new(
5567                "x-card",
5568                "<!-- lead --><template shadowrootmode=\"open\" @click=\"{onClick()}\"><slot></slot></template><!-- tail -->",
5569                None,
5570                true,
5571            ))
5572            .expect("register component");
5573        parser
5574            .parse("index.html", "<x-card>projected</x-card>")
5575            .expect("parse component");
5576
5577        let ParserPluginArtifacts::ComponentTemplates(templates) =
5578            parser.take_plugin_artifacts().expect("plugin artifacts")
5579        else {
5580            panic!("expected component templates");
5581        };
5582        assert!(templates[0].uses_shadow_dom);
5583        assert!(templates[0].template_json.contains("\"sd\":1"));
5584        assert!(templates[0]
5585            .template_json
5586            .contains(r#""re":[["click","onClick",[]]]"#));
5587    }
5588
5589    /// Records the build context so tests can assert what the parser reports
5590    /// without asserting how any particular plugin reacts to it.
5591    struct StyleContextPlugin {
5592        captured: std::rc::Rc<std::cell::RefCell<Vec<(bool, Option<String>, String)>>>,
5593    }
5594
5595    impl crate::plugin::ParserPlugin for StyleContextPlugin {
5596        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5597            self.captured.borrow_mut().push((
5598                context.uses_shadow_dom,
5599                context.style.map(|style| format!("{style:?}")),
5600                context.template.to_string(),
5601            ));
5602            Ok(())
5603        }
5604    }
5605
5606    fn capture_style_context(
5607        strategy: CssStrategy,
5608        dom_strategy: DomStrategy,
5609        template: &str,
5610        css: Option<&str>,
5611    ) -> (bool, Option<String>, String) {
5612        let captured = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
5613        let mut parser = HtmlParser::with_plugin_options(
5614            Box::new(StyleContextPlugin {
5615                captured: std::rc::Rc::clone(&captured),
5616            }),
5617            ParserOptions {
5618                css_strategy: strategy,
5619                dom_strategy,
5620                ..ParserOptions::default()
5621            },
5622        );
5623        parser
5624            .component_registry_mut()
5625            .register_component(ComponentRegistration::new("x-card", template, css, true))
5626            .expect("register component");
5627        parser
5628            .parse("index.html", "<x-card></x-card>")
5629            .expect("parse component");
5630        let captured = captured.borrow();
5631        captured.first().cloned().expect("component registered")
5632    }
5633
5634    /// The parser reports the resolved delivery as data and injects nothing:
5635    /// a plugin that ignores `context.style` gets a style-free template even
5636    /// for an authored Shadow component.
5637    #[test]
5638    fn parser_reports_shadow_style_delivery_without_injecting_it() {
5639        for (strategy, expected) in [
5640            (CssStrategy::Link, "Link { href: \"x-card.css\" }"),
5641            (CssStrategy::Style, "Inline { css: \".card{color:red}\" }"),
5642            (CssStrategy::Module, "Adopted { specifier: \"x-card\" }"),
5643        ] {
5644            let (uses_shadow_dom, style, template) = capture_style_context(
5645                strategy,
5646                DomStrategy::Shadow,
5647                "<template shadowrootmode=\"open\" shadowrootadoptedstylesheets=\"x-card\"><div class=\"card\"></div></template>",
5648                Some(".card{color:red}"),
5649            );
5650            assert!(uses_shadow_dom, "{strategy:?} should be shadow");
5651            assert_eq!(style.as_deref(), Some(expected), "{strategy:?} delivery");
5652            assert!(
5653                !template.contains("<style>") && !template.contains("<link"),
5654                "{strategy:?} template must stay injection-free: {template}"
5655            );
5656        }
5657    }
5658
5659    /// Light CSS belongs to the owning CSS tree, so there is nothing for a
5660    /// plugin to place inside a runtime-created root.
5661    #[test]
5662    fn parser_reports_no_style_delivery_for_light_components() {
5663        let (uses_shadow_dom, style, _) = capture_style_context(
5664            CssStrategy::Style,
5665            DomStrategy::Light,
5666            "<div class=\"card\"></div>",
5667            Some(".card{color:red}"),
5668        );
5669        assert!(!uses_shadow_dom);
5670        assert_eq!(style, None);
5671    }
5672
5673    /// A component without CSS reports no delivery at all.
5674    #[test]
5675    fn parser_reports_no_style_delivery_without_css() {
5676        let (uses_shadow_dom, style, _) = capture_style_context(
5677            CssStrategy::Style,
5678            DomStrategy::Shadow,
5679            "<template shadowrootmode=\"open\"><div></div></template>",
5680            None,
5681        );
5682        assert!(uses_shadow_dom);
5683        assert_eq!(style, None);
5684    }
5685
5686    #[test]
5687    fn test_plugin_display_names() {
5688        assert_eq!(Plugin::Fast.to_string(), "fast");
5689        assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
5690        assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
5691        assert_eq!(Plugin::WebUI.to_string(), "webui");
5692    }
5693
5694    #[test]
5695    fn test_plugin_from_str_names() {
5696        assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
5697        assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
5698        assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
5699        assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
5700    }
5701
5702    #[test]
5703    fn test_plugin_from_str_unknown_mentions_supported_names() {
5704        let err = "legacy".parse::<Plugin>().unwrap_err();
5705        assert!(
5706            err.contains("fast-v3"),
5707            "error should mention fast-v3: {err}"
5708        );
5709        assert!(
5710            err.contains("fast-v2"),
5711            "error should mention fast-v2: {err}"
5712        );
5713        assert!(err.contains("fast"), "error should mention fast: {err}");
5714    }
5715
5716    #[test]
5717    fn test_parse_signal() {
5718        let mut parser = HtmlParser::new();
5719        let html = "Hello, {{name}}!";
5720        let result = parser.parse("test.html", html);
5721
5722        assert!(result.is_ok());
5723        let fragment_records = parser.into_fragment_records();
5724
5725        assert_stream!(
5726            fragment_records,
5727            "test.html",
5728            [raw("Hello, "), signal("name"), raw("!"),]
5729        );
5730    }
5731
5732    #[test]
5733    fn test_parse_raw_signal() {
5734        let mut parser = HtmlParser::new();
5735        let html = "Hello, {{{html_content}}}!";
5736        let result = parser.parse("test.html", html);
5737
5738        assert!(result.is_ok());
5739        let fragment_records = parser.into_fragment_records();
5740
5741        assert_stream!(
5742            fragment_records,
5743            "test.html",
5744            [raw("Hello, "), signal_raw("html_content"), raw("!"),]
5745        );
5746    }
5747
5748    #[test]
5749    fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
5750        let mut parser = HtmlParser::new();
5751        let html = "<nav>{{sectionName}} &gt; {{topicName}}</nav>";
5752        let result = parser.parse("test.html", html);
5753
5754        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5755        let fragment_records = parser.into_fragment_records();
5756
5757        assert_stream!(
5758            fragment_records,
5759            "test.html",
5760            [
5761                raw("<nav>"),
5762                signal("sectionName"),
5763                raw(" &gt; "),
5764                signal("topicName"),
5765                raw("</nav>"),
5766            ]
5767        );
5768    }
5769
5770    #[test]
5771    fn test_parse_preserves_whitespace_inside_bound_text_runs() -> Result<()> {
5772        let mut parser = HtmlParser::new();
5773        parser.parse(
5774            "test.html",
5775            "\n  <button>\n    {{title}}\n  </button>\n  <pre>  {{details}}\n  </pre>\n",
5776        )?;
5777        let records = parser.into_fragment_records();
5778        assert_stream!(
5779            records,
5780            "test.html",
5781            [
5782                raw("<button>\n    "),
5783                signal("title"),
5784                raw("\n  </button><pre>  "),
5785                signal("details"),
5786                raw("\n  </pre>"),
5787            ]
5788        );
5789        Ok(())
5790    }
5791
5792    #[test]
5793    fn test_invalid_for_each_is_template_diagnostic() {
5794        let mut parser = HtmlParser::new();
5795        let err = parser
5796            .parse(
5797                "index.html",
5798                "<div>\n  <for each=\"person inpeople\"><p>x</p></for>\n</div>",
5799            )
5800            .expect_err("invalid for-each must error");
5801        assert!(matches!(err, ParserError::Template(_)));
5802        let msg = err.to_string();
5803        assert!(msg.contains("invalid <for> each expression"), "{msg}");
5804        assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
5805        // The <for> sits on line 2, column 3 (after the two-space indent),
5806        // reported rustc-style as `--> index.html:2:3`.
5807        assert!(
5808            msg.contains("--> index.html:2:3"),
5809            "missing line:column — {msg}"
5810        );
5811        assert!(msg.contains("help:"), "{msg}");
5812    }
5813
5814    #[test]
5815    fn test_invalid_if_condition_is_template_diagnostic() {
5816        let mut parser = HtmlParser::new();
5817        let err = parser
5818            .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
5819            .expect_err("invalid condition must error");
5820        assert!(matches!(err, ParserError::Template(_)));
5821        let msg = err.to_string();
5822        assert!(msg.contains("invalid <if> condition expression"), "{msg}");
5823        assert!(
5824            msg.contains("--> index.html:1:1"),
5825            "missing line:column — {msg}"
5826        );
5827    }
5828
5829    #[test]
5830    fn test_parse_for_directive() {
5831        let mut parser = HtmlParser::new();
5832        let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
5833
5834        let result = parser.parse("test.html", html);
5835        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5836        let fragment_records = parser.into_fragment_records();
5837
5838        assert_stream!(
5839            fragment_records,
5840            "test.html",
5841            [for_loop("item", "items", "for-1"),]
5842        );
5843
5844        // Verify the sub-fragment contains our item content
5845        assert_stream!(
5846            fragment_records,
5847            "for-1",
5848            [
5849                raw("<div class=\"item\">"),
5850                signal("item.name"),
5851                raw("</div>"),
5852            ]
5853        );
5854    }
5855
5856    #[test]
5857    fn test_parse_if_directive() {
5858        let mut parser = HtmlParser::new();
5859        let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
5860
5861        let result = parser.parse("test.html", html);
5862        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5863        let fragment_records = parser.into_fragment_records();
5864
5865        assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
5866
5867        // Verify the sub-fragment contains our content
5868        assert_stream!(
5869            fragment_records,
5870            "if-1",
5871            [
5872                raw("<div>Welcome back, "),
5873                signal("username"),
5874                raw("!</div>"),
5875            ]
5876        );
5877    }
5878
5879    #[test]
5880    fn test_component_directive() {
5881        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5882        parser
5883            .component_registry
5884            .register_component(ComponentRegistration::new(
5885                "my-component",
5886                "<div>My Component</div>",
5887                Some("div { color: blue; }"),
5888                true,
5889            ))
5890            .expect("Failed to register component");
5891
5892        let result = parser.parse("test.html", "<my-component></my-component>");
5893        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5894        let records = parser.into_fragment_records();
5895
5896        assert_stream!(
5897            records,
5898            "test.html",
5899            [
5900                raw("<my-component"),
5901                structural_matcher("streaming_root:my-component"),
5902                raw(">"),
5903                component("my-component"),
5904                raw("</my-component>"),
5905            ]
5906        );
5907
5908        // Component template stream should contain the component content with
5909        // no compiler-owned Light DOM markers.
5910        let comp = &records["my-component"].fragments;
5911        assert_eq!(comp.len(), 1);
5912        assert!(
5913            matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5914                !raw.value.contains("<template shadowrootmode")
5915                    && raw.value.contains("<div")
5916                    && raw.value.contains(">My Component</div>"))
5917        );
5918    }
5919
5920    #[test]
5921    fn shadow_component_emits_style_hook_inside_declarative_root() {
5922        let mut parser = HtmlParser::new();
5923        parser
5924            .component_registry
5925            .register_component(ComponentRegistration::new(
5926                "my-component",
5927                r#"<template shadowrootmode="open"><div>My Component</div></template>"#,
5928                Some("div { color: blue; }"),
5929                true,
5930            ))
5931            .expect("register");
5932
5933        parser
5934            .parse("test.html", "<my-component></my-component>")
5935            .expect("parse");
5936        let records = parser.into_fragment_records();
5937        assert_stream!(
5938            records,
5939            "my-component",
5940            [
5941                raw("<template shadowrootmode=\"open\">"),
5942                structural_matcher("shadow_styles:my-component"),
5943                raw("<div>My Component</div></template>"),
5944            ]
5945        );
5946        assert!(
5947            records["my-component"]
5948                .fragments
5949                .iter()
5950                .all(|fragment| !matches!(
5951                    fragment.fragment.as_ref(),
5952                    Some(Fragment::Raw(raw)) if raw.value.contains("color: blue")
5953                )),
5954            "component CSS is delivered by the handler closure"
5955        );
5956    }
5957
5958    #[test]
5959    fn authored_shadow_attribute_name_is_ascii_case_insensitive_for_style_hook() {
5960        let mut parser = HtmlParser::new();
5961        parser
5962            .component_registry
5963            .register_component(ComponentRegistration::new(
5964                "my-component",
5965                r#"<template shadowRootMode="open"><slot></slot></template>"#,
5966                None,
5967                true,
5968            ))
5969            .expect("register");
5970
5971        parser
5972            .parse("test.html", "<my-component></my-component>")
5973            .expect("parse");
5974        let records = parser.into_fragment_records();
5975        assert_stream!(
5976            records,
5977            "my-component",
5978            [
5979                raw(r#"<template shadowRootMode="open">"#),
5980                structural_matcher("shadow_styles:my-component"),
5981                raw("<slot></slot></template>"),
5982            ]
5983        );
5984    }
5985
5986    #[test]
5987    fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
5988        // The compiler-owned streamed SSR root signal must sit between the
5989        // component's attribute run and the closing `>`, carrying the tag,
5990        // so the streaming handler can inject ` data-ws` inside the tag before
5991        // custom-element upgrade. Ordinary rendering ignores the signal.
5992        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5993        parser
5994            .component_registry
5995            .register_component(ComponentRegistration::new(
5996                "my-widget",
5997                "<div>w</div>",
5998                None,
5999                true,
6000            ))
6001            .expect("register");
6002
6003        let result = parser.parse("test.html", "<my-widget></my-widget>");
6004        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6005        let records = parser.into_fragment_records();
6006
6007        assert_stream!(
6008            records,
6009            "test.html",
6010            [
6011                raw("<my-widget"),
6012                structural_matcher("streaming_root:my-widget"),
6013                raw(">"),
6014                component("my-widget"),
6015                raw("</my-widget>"),
6016            ]
6017        );
6018    }
6019
6020    #[test]
6021    fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
6022        // Each distinct component host emits its own streaming_root signal,
6023        // including nested hosts and hosts repeated across sibling positions.
6024        use webui_protocol::web_ui_fragment::Fragment;
6025        let mut parser = HtmlParser::new();
6026        for name in ["outer-box", "inner-pill"] {
6027            parser
6028                .component_registry
6029                .register_component(ComponentRegistration::new(
6030                    name,
6031                    "<div>content</div>",
6032                    None,
6033                    true,
6034                ))
6035                .expect("register");
6036        }
6037
6038        let result = parser.parse(
6039            "test.html",
6040            "<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
6041        );
6042        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6043        let records = parser.into_fragment_records();
6044        let fragments = &records["test.html"].fragments;
6045
6046        let signals: Vec<&str> = fragments
6047            .iter()
6048            .filter_map(|f| match f.fragment.as_ref() {
6049                Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
6050                _ => None,
6051            })
6052            .collect();
6053        assert_eq!(
6054            signals,
6055            [
6056                "}}}webui:streaming_root:outer-box",
6057                "}}}webui:streaming_root:inner-pill",
6058                "}}}webui:streaming_root:inner-pill",
6059            ]
6060        );
6061
6062        // Every streaming_root signal is immediately followed by a `>` raw and
6063        // preceded by a raw ending in the host tag name (never a bare `<tag>`).
6064        for (i, frag) in fragments.iter().enumerate() {
6065            let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
6066                continue;
6067            };
6068            if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
6069                continue;
6070            }
6071            match fragments[i + 1].fragment.as_ref() {
6072                Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
6073                other => panic!("expected raw(\">\") after signal, got {other:?}"),
6074            }
6075        }
6076    }
6077
6078    #[test]
6079    fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
6080        let mut parser = HtmlParser::new();
6081        parser
6082            .component_registry
6083            .register_component(ComponentRegistration::new(
6084                "mp-button",
6085                "<button>b</button>",
6086                None,
6087                true,
6088            ))
6089            .expect("Failed to register component");
6090
6091        // `<mp-buton>` is a same-namespace one-character typo of `mp-button`.
6092        let err = parser
6093            .parse("test.html", "<mp-buton></mp-buton>")
6094            .expect_err("a near-typo component tag should error");
6095        let ParserError::Template(diag) = err else {
6096            panic!("expected ParserError::Template, got {err:?}");
6097        };
6098        assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
6099        let help = diag
6100            .help_text()
6101            .expect("diagnostic should carry a help line");
6102        assert!(
6103            help.contains("did you mean <mp-button>?"),
6104            "expected a suggestion, got: {help}"
6105        );
6106    }
6107
6108    #[test]
6109    fn external_custom_element_in_other_namespace_passes_through() {
6110        let mut parser = HtmlParser::new();
6111        parser
6112            .component_registry
6113            .register_component(ComponentRegistration::new(
6114                "mp-button",
6115                "<button>b</button>",
6116                None,
6117                true,
6118            ))
6119            .expect("Failed to register component");
6120
6121        // `<md-button>` is a different namespace (md- vs mp-): a genuine
6122        // third-party custom element must pass through as raw HTML, not error.
6123        let result = parser.parse("test.html", "<md-button></md-button>");
6124        assert!(
6125            result.is_ok(),
6126            "external custom element should pass through, got: {:?}",
6127            result.err()
6128        );
6129    }
6130
6131    #[test]
6132    fn for_missing_each_suggests_typoed_attribute() {
6133        let mut parser = HtmlParser::new();
6134        // `eahc` is a transposition of the required `each` attribute.
6135        let err = parser
6136            .parse("test.html", "<for eahc=\"todo in todos\"></for>")
6137            .expect_err("a <for> without `each` should error");
6138        let ParserError::Template(diag) = err else {
6139            panic!("expected ParserError::Template, got {err:?}");
6140        };
6141        assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
6142        let help = diag
6143            .help_text()
6144            .expect("diagnostic should carry a help line");
6145        assert!(
6146            help.contains("found `eahc`") && help.contains("did you mean"),
6147            "expected an attribute-typo suggestion, got: {help}"
6148        );
6149    }
6150
6151    #[test]
6152    fn parent_diagnostic_owner_survives_nested_component_parse() {
6153        // Regression: a component parse recurses through `self.parse(child, …)`,
6154        // which used to leave `current_fragment_id` pointing at the child. A
6155        // later error in the PARENT template must still be attributed to the
6156        // parent (here `index.html`), not the nested component.
6157        let mut parser = HtmlParser::new();
6158        parser
6159            .component_registry
6160            .register_component(ComponentRegistration::new(
6161                "my-card",
6162                "<div>card</div>",
6163                None,
6164                true,
6165            ))
6166            .expect("register");
6167
6168        // A registered component first, then a broken <for> in the same template.
6169        let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
6170        let err = parser
6171            .parse("index.html", html)
6172            .expect_err("the malformed <for> should error");
6173        let ParserError::Template(diag) = err else {
6174            panic!("expected ParserError::Template, got {err:?}");
6175        };
6176        assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
6177        assert_eq!(
6178            diag.component_name(),
6179            Some("index.html"),
6180            "parent diagnostic must be owned by index.html, not the nested component"
6181        );
6182    }
6183
6184    #[test]
6185    fn test_component_directive_with_slots() {
6186        let mut parser = HtmlParser::new();
6187        parser
6188            .component_registry
6189            .register_component(ComponentRegistration::new(
6190                "my-component",
6191                "<div>My Component</div>",
6192                Some("div { color: blue; }"),
6193                true,
6194            ))
6195            .expect("Failed to register component");
6196
6197        let result = parser.parse(
6198            "test.html",
6199            "Hello<my-component><p>World</p></my-component>",
6200        );
6201        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6202        let records = parser.into_fragment_records();
6203        let fragments = &records["test.html"].fragments;
6204
6205        // Entry: raw(Hello<my-component>) + component + raw(<p>World</p></my-component>)
6206        assert!(fragments.len() >= 3);
6207        // First fragment should contain "Hello" and "<my-component" (the opening
6208        // tag is split by the compiler-owned streaming_root signal before `>`).
6209        assert!(
6210            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
6211        );
6212        // Should have component fragment
6213        assert!(fragments.iter().any(|f| matches!(
6214            f.fragment.as_ref(),
6215            Some(Fragment::Component(c)) if c.fragment_id == "my-component"
6216        )));
6217        // Should end with closing tag
6218        let last = fragments.last().unwrap();
6219        assert!(
6220            matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
6221        );
6222    }
6223
6224    #[test]
6225    fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
6226        // Reproduces commerce app: mp-app has a shadow DOM template containing
6227        // child components (mp-navbar, mp-cart-panel, mp-footer) plus an <outlet>.
6228        // The parser must emit Fragment::Component entries for ALL child components
6229        // so the inventory walk finds them.
6230        let mut parser = HtmlParser::new();
6231
6232        parser
6233            .component_registry
6234            .register_component(ComponentRegistration::new(
6235                "app-shell",
6236                r#"<template shadowrootmode="open">
6237                  <my-navbar></my-navbar>
6238                  <main><outlet /></main>
6239                  <cart-panel></cart-panel>
6240                  <my-footer></my-footer>
6241                </template>"#,
6242                Some(":host{display:flex}"),
6243                true,
6244            ))
6245            .expect("register app-shell");
6246        parser
6247            .component_registry
6248            .register_component(ComponentRegistration::new(
6249                "my-navbar",
6250                "<nav>Nav</nav>",
6251                Some("nav{color:red}"),
6252                true,
6253            ))
6254            .expect("register my-navbar");
6255        parser
6256            .component_registry
6257            .register_component(ComponentRegistration::new(
6258                "cart-panel",
6259                "<aside>Cart</aside>",
6260                Some("aside{color:green}"),
6261                true,
6262            ))
6263            .expect("register cart-panel");
6264        parser
6265            .component_registry
6266            .register_component(ComponentRegistration::new(
6267                "my-footer",
6268                "<footer>Footer</footer>",
6269                Some("footer{color:blue}"),
6270                true,
6271            ))
6272            .expect("register my-footer");
6273
6274        parser
6275            .parse("index.html", "<app-shell></app-shell>")
6276            .expect("parse index.html");
6277        let records = parser.into_fragment_records();
6278
6279        // app-shell's fragment list must contain Component entries for all children
6280        let app_frags = &records["app-shell"].fragments;
6281        let component_ids: Vec<&str> = app_frags
6282            .iter()
6283            .filter_map(|f| match f.fragment.as_ref() {
6284                Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
6285                _ => None,
6286            })
6287            .collect();
6288
6289        assert!(
6290            component_ids.contains(&"my-navbar"),
6291            "app-shell fragments should contain my-navbar: {component_ids:?}"
6292        );
6293        assert!(
6294            component_ids.contains(&"cart-panel"),
6295            "app-shell fragments should contain cart-panel: {component_ids:?}"
6296        );
6297        assert!(
6298            component_ids.contains(&"my-footer"),
6299            "app-shell fragments should contain my-footer: {component_ids:?}"
6300        );
6301    }
6302
6303    // ── Component template wrapping tests ────────────────────────────
6304
6305    #[test]
6306    fn test_component_no_double_wrap_template() {
6307        // Developer-authored <template foo="bar"> must be preserved verbatim
6308        // in Light DOM. The framework only strips runtime-only attrs;
6309        // every other attribute is the developer's responsibility.
6310        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6311        parser
6312            .component_registry
6313            .register_component(ComponentRegistration::new(
6314                "custom-element",
6315                r#"<template foo="bar"><div>content</div></template>"#,
6316                None,
6317                true,
6318            ))
6319            .expect("register");
6320        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6321        assert!(result.is_ok());
6322        let records = parser.into_fragment_records();
6323
6324        assert_fragments!(
6325            records["index.html"].fragments,
6326            [
6327                raw("<custom-element"),
6328                structural_matcher("streaming_root:custom-element"),
6329                raw(">"),
6330                component("custom-element"),
6331                raw("Hello</custom-element>"),
6332            ]
6333        );
6334
6335        assert_stream!(
6336            records,
6337            "custom-element",
6338            [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6339        );
6340    }
6341
6342    #[test]
6343    fn test_component_styled_no_double_wrap() {
6344        // Light DOM with a developer-supplied ordinary <template> wrapper
6345        // preserves the wrapper verbatim. CSS is delivered by the owning
6346        // closure rather than spliced into the template.
6347        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6348        parser
6349            .component_registry
6350            .register_component(ComponentRegistration::new(
6351                "custom-element",
6352                r#"<template foo="bar"><div>content</div></template>"#,
6353                Some("div { color: red; }"),
6354                true,
6355            ))
6356            .expect("register");
6357        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6358        assert!(result.is_ok());
6359        let records = parser.into_fragment_records();
6360
6361        assert_stream!(
6362            records,
6363            "custom-element",
6364            [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6365        );
6366    }
6367
6368    #[test]
6369    fn test_component_strip_runtime_attrs() {
6370        // Runtime-only attributes (`@event`, `:bind`, `?cond`) are stripped
6371        // from the opening <template> tag, but the wrapper itself is
6372        // preserved. The declarative Shadow DOM attribute remains.
6373        let mut parser = HtmlParser::new();
6374        parser
6375            .component_registry
6376            .register_component(ComponentRegistration::new(
6377                "custom-element",
6378                r#"<template shadowrootmode="open" @click={foo} :bar="baz" ?bool="true"><div>content</div></template>"#,
6379                None,
6380                true,
6381            ))
6382            .expect("register");
6383        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6384        assert!(result.is_ok());
6385        let records = parser.into_fragment_records();
6386
6387        assert_stream!(
6388            records,
6389            "custom-element",
6390            [
6391                raw(r#"<template shadowrootmode="open">"#),
6392                structural_matcher("shadow_styles:custom-element"),
6393                raw("<div>content</div></template>"),
6394            ]
6395        );
6396    }
6397
6398    #[test]
6399    fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
6400        let mut parser = HtmlParser::new();
6401        parser
6402            .component_registry
6403            .register_component(ComponentRegistration::new(
6404                "custom-element",
6405                r#"<template shadowrootmode="open" data-note="@click={foo}" @click={foo}><div>content</div></template>"#,
6406                None,
6407                true,
6408            ))
6409            .expect("register");
6410        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6411        assert!(result.is_ok());
6412        let records = parser.into_fragment_records();
6413
6414        assert_stream!(
6415            records,
6416            "custom-element",
6417            [
6418                raw(r#"<template shadowrootmode="open" data-note="@click={foo}">"#),
6419                structural_matcher("shadow_styles:custom-element"),
6420                raw("<div>content</div></template>"),
6421            ]
6422        );
6423    }
6424
6425    #[test]
6426    fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
6427        let mut parser = HtmlParser::new();
6428        parser
6429            .component_registry
6430            .register_component(ComponentRegistration::new(
6431                "custom-element",
6432                r#"<template shadowrootmode="open" @click={foo} @click={bar}><div>content</div></template>"#,
6433                None,
6434                true,
6435            ))
6436            .expect("register");
6437        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6438        assert!(result.is_ok());
6439        let records = parser.into_fragment_records();
6440
6441        assert_stream!(
6442            records,
6443            "custom-element",
6444            [
6445                raw(r#"<template shadowrootmode="open">"#),
6446                structural_matcher("shadow_styles:custom-element"),
6447                raw("<div>content</div></template>"),
6448            ]
6449        );
6450    }
6451
6452    #[test]
6453    fn test_component_with_slots_and_attrs() {
6454        let mut parser = HtmlParser::new();
6455        parser
6456            .component_registry
6457            .register_component(ComponentRegistration::new(
6458                "custom-element",
6459                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6460                None,
6461                true,
6462            ))
6463            .expect("register");
6464        let result = parser.parse(
6465            "index.html",
6466            r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
6467        );
6468        assert!(result.is_ok());
6469        let records = parser.into_fragment_records();
6470        assert_fragments!(
6471            records["index.html"].fragments,
6472            [
6473                raw("<custom-element"),
6474                attr_raw_start("appearance", "subtle"),
6475                structural_matcher("streaming_root:custom-element"),
6476                raw(">"),
6477                component("custom-element"),
6478                raw("Hello World</custom-element>"),
6479            ]
6480        );
6481    }
6482
6483    #[test]
6484    fn test_component_legacy_no_styles() {
6485        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6486        parser
6487            .component_registry
6488            .register_component(ComponentRegistration::new(
6489                "custom-element",
6490                "<div>Custom Element</div>",
6491                None,
6492                true,
6493            ))
6494            .expect("register");
6495        let result = parser.parse("index.html", "<custom-element></custom-element>");
6496        assert!(result.is_ok());
6497        let records = parser.into_fragment_records();
6498
6499        assert_fragments!(
6500            records["index.html"].fragments,
6501            [
6502                raw("<custom-element"),
6503                structural_matcher("streaming_root:custom-element"),
6504                raw(">"),
6505                component("custom-element"),
6506                raw("</custom-element>"),
6507            ]
6508        );
6509
6510        assert_stream!(
6511            records,
6512            "custom-element",
6513            [raw("<div>Custom Element</div>"),]
6514        );
6515    }
6516
6517    #[test]
6518    fn test_component_self_closing() {
6519        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6520        parser
6521            .component_registry
6522            .register_component(ComponentRegistration::new(
6523                "custom-widget",
6524                "<div>Widget Content</div>",
6525                None,
6526                true,
6527            ))
6528            .expect("register");
6529        let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
6530        assert!(result.is_ok());
6531        let records = parser.into_fragment_records();
6532
6533        assert_fragments!(
6534            records["index.html"].fragments,
6535            [
6536                raw("<custom-widget"),
6537                attr_start("config", "settings"),
6538                structural_matcher("streaming_root:custom-widget"),
6539                raw("/>"),
6540                component("custom-widget"),
6541            ]
6542        );
6543    }
6544
6545    #[test]
6546    fn test_component_nested_self_closing_in_slot() {
6547        let mut parser = HtmlParser::new();
6548        parser
6549            .component_registry
6550            .register_component(ComponentRegistration::new(
6551                "custom-icon",
6552                "<template shadowrootmode=\"open\"><svg><slot></slot></svg></template>",
6553                None,
6554                true,
6555            ))
6556            .expect("register");
6557        let result = parser.parse(
6558            "index.html",
6559            r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
6560        );
6561        assert!(result.is_ok());
6562        let records = parser.into_fragment_records();
6563
6564        assert_fragments!(
6565            records["index.html"].fragments,
6566            [
6567                raw("<custom-icon"),
6568                structural_matcher("streaming_root:custom-icon"),
6569                raw(">"),
6570                component("custom-icon"),
6571                raw("<use"),
6572                attr_template("href", "attr-1"),
6573                raw("/></custom-icon>"),
6574            ]
6575        );
6576
6577        assert_stream!(
6578            records,
6579            "custom-icon",
6580            [
6581                raw("<template shadowrootmode=\"open\">"),
6582                structural_matcher("shadow_styles:custom-icon"),
6583                raw("<svg><slot></slot></svg></template>"),
6584            ]
6585        );
6586    }
6587
6588    #[test]
6589    fn test_component_leading_boolean_attr_start() {
6590        let mut parser = HtmlParser::new();
6591        parser
6592            .component_registry
6593            .register_component(ComponentRegistration::new(
6594                "custom-element",
6595                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6596                None,
6597                true,
6598            ))
6599            .expect("register");
6600        let result = parser.parse(
6601            "index.html",
6602            r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
6603        );
6604        assert!(result.is_ok());
6605        let records = parser.into_fragment_records();
6606
6607        assert_fragments!(
6608            records["index.html"].fragments,
6609            [
6610                raw("<custom-element"),
6611                // First dynamic attr: boolean with attrStart
6612                bool_attr_start("disabled", "isDisabled"),
6613                // Static attr after dynamic: rawValue
6614                attr_raw("title", "Hello"),
6615                structural_matcher("streaming_root:custom-element"),
6616                raw(">"),
6617                component("custom-element"),
6618                raw("</custom-element>"),
6619            ]
6620        );
6621    }
6622
6623    #[test]
6624    fn test_component_boolean_predicate_preserves_condition_tree() {
6625        let mut parser = HtmlParser::new();
6626        parser
6627            .component_registry
6628            .register_component(ComponentRegistration::new(
6629                "custom-element",
6630                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6631                None,
6632                true,
6633            ))
6634            .expect("register");
6635        let result = parser.parse(
6636            "index.html",
6637            r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
6638        );
6639        assert!(result.is_ok());
6640
6641        let records = parser.into_fragment_records();
6642        let fragments = &records["index.html"].fragments;
6643        match fragments
6644            .get(1)
6645            .and_then(|fragment| fragment.fragment.as_ref())
6646        {
6647            Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
6648                assert_eq!(attr.name, "disabled");
6649                assert!(attr.attr_start);
6650                match attr
6651                    .condition_tree
6652                    .as_ref()
6653                    .and_then(|condition| condition.expr.as_ref())
6654                {
6655                    Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
6656                        assert_eq!(pred.left, "page");
6657                        assert_eq!(pred.operator, 3);
6658                        assert_eq!(pred.right, "'dashboard'");
6659                    }
6660                    other => panic!("expected predicate condition tree, got {:?}", other),
6661                }
6662            }
6663            other => panic!("expected attribute fragment, got {:?}", other),
6664        }
6665    }
6666
6667    #[test]
6668    fn test_component_meta_link_tags() {
6669        let (fragments, _) = parse_and_get_fragments(
6670            r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
6671        );
6672        assert!(fragments.len() >= 7);
6673        assert!(
6674            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
6675        );
6676        assert!(
6677            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
6678                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
6679        );
6680        assert!(
6681            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
6682        );
6683        assert!(
6684            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
6685        );
6686        assert!(
6687            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
6688        );
6689        assert!(
6690            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
6691                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
6692        );
6693        assert!(
6694            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
6695        );
6696    }
6697
6698    #[test]
6699    fn test_nested_directives() {
6700        let mut parser = HtmlParser::new();
6701        let html = r#"<for each="category in categories">
6702            <if condition="category.hasItems">
6703                <for each="item in category.items">
6704                   {{item.title}}
6705                </for>
6706            </if>
6707        </for>"#;
6708
6709        let result = parser.parse("test.html", html);
6710
6711        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6712        let fragment_records = parser.into_fragment_records();
6713
6714        assert_stream!(
6715            fragment_records,
6716            "test.html",
6717            [for_loop("category", "categories", "for-1"),]
6718        );
6719
6720        assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
6721
6722        assert_stream!(
6723            fragment_records,
6724            "if-1",
6725            [for_loop("item", "category.items", "for-2"),]
6726        );
6727
6728        assert_stream!(
6729            fragment_records,
6730            "for-2",
6731            [
6732                raw("\n                   "),
6733                signal("item.title"),
6734                raw("\n                "),
6735            ]
6736        );
6737    }
6738
6739    #[test]
6740    fn test_complex_directives() {
6741        let mut parser = HtmlParser::new();
6742        let html = r#"<for each="category in categories">
6743            <div class="category">
6744                <h2>{{category.name}}</h2>
6745                <if condition="category.hasItems">
6746                    <ul>
6747                        <for each="item in category.items">
6748                            <li>{{item.title}}</li>
6749                        </for>
6750                    </ul>
6751                </if>
6752            </div>
6753        </for>"#;
6754
6755        let result = parser.parse("test.html", html);
6756
6757        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6758        let fragment_records = parser.into_fragment_records();
6759
6760        assert_stream!(
6761            fragment_records,
6762            "test.html",
6763            [for_loop("category", "categories", "for-1"),]
6764        );
6765
6766        // Verify for fragments contains the category.name signal
6767        assert_stream!(
6768            fragment_records,
6769            "for-1",
6770            [
6771                raw("<div class=\"category\"><h2>"),
6772                signal("category.name"),
6773                raw("</h2>"),
6774                if_cond("if-1"),
6775                raw("</div>"),
6776            ]
6777        );
6778
6779        // Verify nested if condition.
6780        assert_stream!(
6781            fragment_records,
6782            "if-1",
6783            [
6784                raw("<ul>"),
6785                for_loop("item", "category.items", "for-2"),
6786                raw("</ul>"),
6787            ]
6788        );
6789
6790        // Verify nested for each.
6791        assert_stream!(
6792            fragment_records,
6793            "for-2",
6794            [raw("<li>"), signal("item.title"), raw("</li>"),]
6795        );
6796    }
6797
6798    // ── Attribute fragment tests ─────────────────────────────────────────
6799
6800    /// Helper to parse HTML and return the fragments for the entry stream.
6801    fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6802        let mut parser = HtmlParser::new();
6803        let result = parser.parse("index.html", html);
6804        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6805        let records = parser.into_fragment_records();
6806        let fragments = records
6807            .get("index.html")
6808            .expect("Failed to get index.html fragment")
6809            .fragments
6810            .clone();
6811        (fragments, records)
6812    }
6813
6814    /// Helper to parse HTML with a pre-registered component.
6815    fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6816        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6817        parser
6818            .component_registry
6819            .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
6820            .expect("register");
6821        let result = parser.parse("index.html", html);
6822        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6823        let records = parser.into_fragment_records();
6824        let fragments = records
6825            .get("index.html")
6826            .expect("Failed to get index.html fragment")
6827            .fragments
6828            .clone();
6829        (fragments, records)
6830    }
6831
6832    #[test]
6833    fn test_attribute_handlebars_in_href() {
6834        // Port of: 'should process handlebars from attributes as signals'
6835        let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
6836        assert_fragments!(
6837            fragments,
6838            [
6839                raw("<a"),
6840                attr("href", "url"),
6841                raw(">"),
6842                signal("name"),
6843                raw("</a>"),
6844            ]
6845        );
6846    }
6847
6848    #[test]
6849    fn test_attribute_boolean_with_handlebars() {
6850        // Port of: 'should process boolean attribute with handlebars expression'
6851        let (fragments, _) =
6852            parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
6853        assert_fragments!(
6854            fragments,
6855            [
6856                raw("<button"),
6857                bool_attr("disabled", "isDisabled"),
6858                raw(">Click</button>"),
6859            ]
6860        );
6861    }
6862
6863    #[test]
6864    fn test_attribute_multiple_boolean() {
6865        // Port of: 'should process multiple boolean attributes'
6866        // <input ?checked={{isChecked}} ?disabled={{isDisabled}} />
6867        let (fragments, _) =
6868            parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
6869
6870        assert_fragments!(
6871            fragments,
6872            [
6873                raw("<input"),
6874                bool_attr("checked", "isChecked"),
6875                bool_attr("disabled", "isDisabled"),
6876                raw("/>"),
6877            ]
6878        );
6879    }
6880
6881    #[test]
6882    fn test_attribute_boolean_and_regular_together() {
6883        // Port of: 'should process a boolean attribute and a regular attribute together'
6884        // <button ?disabled="{{isDisabled}}" type="button">Hi</button>
6885        let (fragments, _) = parse_and_get_fragments(
6886            r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6887        );
6888
6889        assert_fragments!(
6890            fragments,
6891            [
6892                raw("<button"),
6893                bool_attr("disabled", "isDisabled"),
6894                raw(" type=\"button\">Hi</button>"),
6895            ]
6896        );
6897    }
6898
6899    #[test]
6900    fn test_attribute_boolean_sandwiched() {
6901        // Port of: 'should process a boolean attribute sandwiched between regular attributes'
6902        // <button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>
6903        let (fragments, _) = parse_and_get_fragments(
6904            r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6905        );
6906
6907        assert_fragments!(
6908            fragments,
6909            [
6910                raw("<button"),
6911                attr("version", "edition"),
6912                bool_attr("disabled", "isDisabled"),
6913                raw(" type=\"button\">Hi</button>"),
6914            ]
6915        );
6916    }
6917
6918    #[test]
6919    fn test_attribute_boolean_ending() {
6920        // Port of: 'should process html ending with boolean attribute correctly'
6921        // <button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>
6922        let (fragments, _) = parse_and_get_fragments(
6923            r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
6924        );
6925
6926        assert_fragments!(
6927            fragments,
6928            [
6929                raw("<button"),
6930                attr("version", "edition"),
6931                bool_attr("disabled", "isDisabled"),
6932                raw(">Hi</button>"),
6933            ]
6934        );
6935    }
6936
6937    #[test]
6938    fn test_attribute_boolean_dotted_path() {
6939        // Port of: 'should process boolean attribute with dotted path'
6940        // <div ?checked={{layout.isPinned}}>Content</div>
6941        let (fragments, _) =
6942            parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
6943
6944        assert_fragments!(
6945            fragments,
6946            [
6947                raw("<div"),
6948                bool_attr("checked", "layout.isPinned"),
6949                raw(">Content</div>"),
6950            ]
6951        );
6952    }
6953
6954    #[test]
6955    fn test_attribute_colon_prefixed_complex() {
6956        // Port of: 'should process colon-prefixed attribute with handlebars'
6957        // <my-component :config="{{settings}}"></my-component>
6958        let (fragments, _) = parse_with_component(
6959            "my-component",
6960            r#"<my-component :config="{{settings}}"></my-component>"#,
6961        );
6962
6963        assert_fragments!(
6964            fragments,
6965            [
6966                raw("<my-component"),
6967                attr_complex_start(":config", "settings"),
6968                structural_matcher("streaming_root:my-component"),
6969                raw(">"),
6970                component("my-component"),
6971                raw("</my-component>"),
6972            ]
6973        );
6974    }
6975
6976    #[test]
6977    fn test_attribute_multiple_colon_prefixed() {
6978        // Port of: 'should process multiple colon-prefixed complex attributes'
6979        // <my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>
6980        let (fragments, _) = parse_with_component(
6981            "my-component",
6982            r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
6983        );
6984
6985        assert_fragments!(
6986            fragments,
6987            [
6988                raw("<my-component"),
6989                attr_complex_start(":prop1", "val1"),
6990                attr_complex(":prop2", "val2"),
6991                structural_matcher("streaming_root:my-component"),
6992                raw(">"),
6993                component("my-component"),
6994                raw("</my-component>"),
6995            ]
6996        );
6997    }
6998
6999    #[test]
7000    fn test_blocked_complex_property_innerhtml() {
7001        let mut parser = HtmlParser::new();
7002        let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
7003        assert!(
7004            result.is_err(),
7005            "Expected error for :innerHTML on native element"
7006        );
7007        let err = result.unwrap_err().to_string();
7008        assert!(
7009            err.contains("only allowed on custom elements"),
7010            "Error: {err}"
7011        );
7012    }
7013
7014    #[test]
7015    fn test_blocked_complex_on_native_element() {
7016        let mut parser = HtmlParser::new();
7017        let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
7018        assert!(
7019            result.is_err(),
7020            "Expected error for :data on native element"
7021        );
7022        let err = result.unwrap_err().to_string();
7023        assert!(
7024            err.contains("only allowed on custom elements"),
7025            "Error: {err}"
7026        );
7027    }
7028
7029    #[test]
7030    fn test_blocked_complex_property_on_component() {
7031        let mut parser = HtmlParser::new();
7032        parser
7033            .component_registry
7034            .register_component(ComponentRegistration::new(
7035                "my-widget",
7036                "<div></div>",
7037                None,
7038                true,
7039            ))
7040            .expect("register");
7041        let result = parser.parse(
7042            "index.html",
7043            r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
7044        );
7045        assert!(
7046            result.is_err(),
7047            "Expected error for :innerHTML on component"
7048        );
7049        let err = result.unwrap_err().to_string();
7050        assert!(err.contains("HTML injection"), "Error: {err}");
7051    }
7052
7053    #[test]
7054    fn test_allowed_complex_property() {
7055        // :config on a component should still work
7056        let (fragments, _) = parse_with_component(
7057            "my-component",
7058            r#"<my-component :config="{{settings}}"></my-component>"#,
7059        );
7060        assert_fragments!(
7061            fragments,
7062            [
7063                raw("<my-component"),
7064                attr_complex_start(":config", "settings"),
7065                structural_matcher("streaming_root:my-component"),
7066                raw(">"),
7067                component("my-component"),
7068                raw("</my-component>"),
7069            ]
7070        );
7071    }
7072
7073    #[test]
7074    fn test_attribute_mixed_normal_boolean_colon() {
7075        // Port of: 'should process mixed normal, boolean, and colon-prefixed attributes'
7076        // <my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>
7077        let (fragments, _) = parse_with_component(
7078            "my-component",
7079            r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
7080        );
7081
7082        assert_fragments!(
7083            fragments,
7084            [
7085                raw("<my-component"),
7086                attr_raw_start("id", "comp"),
7087                attr_complex(":config", "settings"),
7088                bool_attr("enabled", "isEnabled"),
7089                structural_matcher("streaming_root:my-component"),
7090                raw(">"),
7091                component("my-component"),
7092                raw("</my-component>"),
7093            ]
7094        );
7095    }
7096
7097    #[test]
7098    fn test_attribute_reject_boolean_without_handlebars() {
7099        // Port of: 'should reject boolean attribute without handlebars'
7100        // <button ?checked="name"></button>
7101        let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
7102
7103        // Boolean attribute is silently dropped
7104        assert_fragments!(fragments, [raw("<button></button>"),]);
7105    }
7106
7107    #[test]
7108    fn test_attribute_reject_boolean_with_partial_handlebars() {
7109        // Port of: 'should reject boolean attribute with partial handlebars'
7110        // <button ?checked="Hello {{name}}"></button>
7111        let (fragments, _) =
7112            parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
7113
7114        // Boolean attribute is silently dropped
7115        assert_fragments!(fragments, [raw("<button></button>"),]);
7116    }
7117
7118    #[test]
7119    fn test_attribute_reject_boolean_with_plain_value() {
7120        // Port of: 'should reject boolean attribute with plain value'
7121        // <button ?disabled="true">Click</button>
7122        let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
7123
7124        // Boolean attribute is silently dropped
7125        assert_fragments!(fragments, [raw("<button>Click</button>"),]);
7126    }
7127
7128    #[test]
7129    fn test_attribute_boolean_predicate_equal() {
7130        // Boolean attribute with == predicate expression
7131        let (fragments, _) =
7132            parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
7133        assert_fragments!(
7134            fragments,
7135            [
7136                raw("<div"),
7137                bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
7138                raw(">X</div>"),
7139            ]
7140        );
7141    }
7142
7143    #[test]
7144    fn test_attribute_boolean_predicate_greater_than() {
7145        // Boolean attribute with > predicate expression
7146        let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
7147        assert_fragments!(
7148            fragments,
7149            [
7150                raw("<span"),
7151                bool_attr_predicate("hidden", "num", 1, "9"),
7152                raw(">X</span>"),
7153            ]
7154        );
7155    }
7156
7157    #[test]
7158    fn test_attribute_boolean_predicate_not_equal() {
7159        // Boolean attribute with != predicate expression
7160        let (fragments, _) =
7161            parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
7162        assert_fragments!(
7163            fragments,
7164            [
7165                raw("<a"),
7166                bool_attr_predicate("data-active", "status", 4, "'inactive'"),
7167                raw(">X</a>"),
7168            ]
7169        );
7170    }
7171
7172    #[test]
7173    fn test_attribute_boolean_negation() {
7174        // Boolean attribute with negated expression
7175        let (fragments, _) =
7176            parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
7177        assert_fragments!(
7178            fragments,
7179            [
7180                raw("<button"),
7181                bool_attr_not("disabled", "isReady"),
7182                raw(">X</button>"),
7183            ]
7184        );
7185    }
7186
7187    #[test]
7188    fn test_attribute_mixed_static_dynamic() {
7189        // Port of: 'should process mixed attributes correctly'
7190        // <textarea value="hello {{world}}">Hi</textarea>
7191        let (fragments, records) =
7192            parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
7193
7194        assert_fragments!(
7195            fragments,
7196            [
7197                raw("<textarea"),
7198                attr_template("value", "attr-1"),
7199                raw(">Hi</textarea>"),
7200            ]
7201        );
7202
7203        // Verify the template sub-stream
7204        assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
7205    }
7206
7207    // ── Body signal tests ─────────────────────────────────────────────
7208
7209    #[test]
7210    fn test_body_signals() {
7211        let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
7212        assert_fragments!(
7213            fragments,
7214            [
7215                raw("<body>"),
7216                structural_matcher("body_start"),
7217                raw("<app-shell></app-shell>"),
7218                structural_matcher("body_end"),
7219                raw("</body>"),
7220            ]
7221        );
7222    }
7223
7224    // ── Empty for handling tests ──────────────────────────────────────
7225
7226    #[test]
7227    fn test_empty_for_produces_nothing() {
7228        let (fragments, records) =
7229            parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
7230        assert_fragments!(fragments, [raw("<div></div>"),]);
7231        assert!(!records.contains_key("for-1"));
7232    }
7233
7234    // ── Self-closing / void element tests ─────────────────────────────
7235
7236    #[test]
7237    fn test_self_closing_svg_path() {
7238        let (fragments, _) =
7239            parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
7240        assert_fragments!(
7241            fragments,
7242            [raw(
7243                r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
7244            ),]
7245        );
7246    }
7247
7248    #[test]
7249    fn test_html5_void_elements() {
7250        let (fragments, _) = parse_and_get_fragments(
7251            r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
7252        );
7253        assert_fragments!(
7254            fragments,
7255            [raw(
7256                r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
7257            ),]
7258        );
7259    }
7260
7261    #[test]
7262    fn test_self_closing_with_dynamic_attributes() {
7263        let (fragments, _) =
7264            parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
7265        assert_fragments!(
7266            fragments,
7267            [
7268                raw("<img"),
7269                attr("src", "imageUrl"),
7270                attr("alt", "imageAlt"),
7271                raw("/>"),
7272            ]
7273        );
7274    }
7275
7276    #[test]
7277    fn test_self_closing_with_boolean_attributes() {
7278        let (fragments, _) = parse_and_get_fragments(
7279            r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
7280        );
7281        assert_fragments!(
7282            fragments,
7283            [
7284                raw("<input type=\"checkbox\""),
7285                bool_attr("checked", "isSelected"),
7286                bool_attr("disabled", "isDisabled"),
7287                raw("/>"),
7288            ]
7289        );
7290    }
7291
7292    #[test]
7293    fn test_multiple_self_closing_in_sequence() {
7294        let (fragments, _) =
7295            parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
7296        assert_fragments!(
7297            fragments,
7298            [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
7299        );
7300    }
7301
7302    #[test]
7303    fn test_self_closing_with_mixed_content() {
7304        let (fragments, _) =
7305            parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
7306        assert_fragments!(
7307            fragments,
7308            [
7309                raw("<div>Text before<img"),
7310                attr("src", "url"),
7311                raw("/>Text after</div>"),
7312            ]
7313        );
7314    }
7315
7316    #[test]
7317    fn test_self_closing_svg_elements() {
7318        let (fragments, _) = parse_and_get_fragments(
7319            r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
7320        );
7321        assert_fragments!(
7322            fragments,
7323            [
7324                raw("<svg><circle"),
7325                attr("cx", "x"),
7326                attr("cy", "y"),
7327                raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
7328            ]
7329        );
7330    }
7331
7332    #[test]
7333    fn test_self_closing_inside_for_loop() {
7334        let (fragments, records) = parse_and_get_fragments(
7335            r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
7336        );
7337        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7338        assert_stream!(
7339            records,
7340            "for-1",
7341            [raw("<img"), attr("src", "item.url"), raw("/>"),]
7342        );
7343    }
7344
7345    #[test]
7346    fn test_self_closing_whitespace_variations() {
7347        let (fragments, _) =
7348            parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
7349        assert_fragments!(
7350            fragments,
7351            [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
7352        );
7353    }
7354
7355    #[test]
7356    fn test_deeply_nested_self_closing() {
7357        let (fragments, _) = parse_and_get_fragments(
7358            r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
7359        );
7360        assert_fragments!(
7361            fragments,
7362            [raw(
7363                r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
7364            ),]
7365        );
7366    }
7367
7368    #[test]
7369    fn test_self_closing_vs_empty_regular_tags() {
7370        let (fragments, _) =
7371            parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
7372        assert_fragments!(
7373            fragments,
7374            [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
7375        );
7376    }
7377
7378    // ── Feature 1: Named <for> bodies ─────────────────────────────────
7379
7380    #[test]
7381    fn test_for_custom_id_attribute() {
7382        let (fragments, records) = parse_and_get_fragments(
7383            r#"<for each="item in items" id="static"><span>Item</span></for>"#,
7384        );
7385        assert_fragments!(fragments, [for_loop("item", "items", "static-1"),]);
7386        assert_stream!(records, "static-1", [raw("<span>Item</span>"),]);
7387    }
7388
7389    #[test]
7390    fn test_for_recursive_template() {
7391        let mut parser = HtmlParser::new();
7392        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>"#;
7393        let result = parser.parse("index.html", html);
7394        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7395        let records = parser.into_fragment_records();
7396
7397        assert_fragments!(
7398            records["index.html"].fragments,
7399            [for_loop("item", "items", "static-1"),]
7400        );
7401
7402        assert_stream!(
7403            records,
7404            "static-1",
7405            [
7406                raw("<div><span>"),
7407                signal("item.name"),
7408                raw("</span>"),
7409                for_loop("item", "item.children", "static-1"),
7410                raw("</div>"),
7411            ]
7412        );
7413    }
7414
7415    #[test]
7416    fn each_accepts_only_direct_item_in_collection_syntax() {
7417        assert_eq!(
7418            parse_for_each("test-tree", "child in items").unwrap(),
7419            ("child", "items")
7420        );
7421        assert_eq!(
7422            parse_for_each("test-tree", " \tchild \n in \r child.children ").unwrap(),
7423            ("child", "child.children")
7424        );
7425    }
7426
7427    #[test]
7428    fn braced_each_is_rejected_by_server_and_client_compilation() {
7429        use crate::plugin::webui::generate_compiled_template;
7430
7431        for source in [
7432            r#"<for each="{{child in items}}"><span>{{child.name}}</span></for>"#,
7433            r#"<for id="tree" each="{{child in items}}"><span>{{child.name}}</span></for>"#,
7434            r#"<for id="tree" each="child in items"><for id="tree" each="{{child in child.children}}" /></for>"#,
7435            r#"<for id="tree" each="{{ child in items }}"><span>{{child.name}}</span></for>"#,
7436            r#"<for id="tree" each="{child in items}"><span>{{child.name}}</span></for>"#,
7437        ] {
7438            let server = HtmlParser::new().parse("test-tree", source).unwrap_err();
7439            let client = generate_compiled_template("test-tree", source).unwrap_err();
7440            let ParserError::Template(server) = server else {
7441                panic!("expected a server authoring diagnostic");
7442            };
7443            let ParserError::Template(client) = client else {
7444                panic!("expected a client authoring diagnostic");
7445            };
7446            assert_eq!(server.error_code(), client.error_code(), "{source}");
7447            assert!(matches!(
7448                server.error_code(),
7449                Some(codes::INVALID_FOR_EACH | codes::INVALID_FOR_IDENTIFIER)
7450            ));
7451            assert!(server.position_line_column().is_some());
7452            assert!(server.help_text().unwrap().contains("without braces"));
7453            assert!(client.help_text().unwrap().contains("without braces"));
7454        }
7455    }
7456
7457    #[test]
7458    fn named_for_recursion_reuses_one_scoped_record() {
7459        let mut parser = HtmlParser::new();
7460        parser
7461            .parse(
7462                "file-a.html",
7463                r#"<for id="tree-item" each="child in items"><li>{{child.name}}</li><for id="tree-item" each="child in child.children" /></for>"#,
7464            )
7465            .unwrap();
7466        let records = parser.into_fragment_records();
7467        assert_eq!(records.len(), 2);
7468        assert_stream!(
7469            records,
7470            "file-a.html",
7471            [for_loop("child", "items", "tree-item-1")]
7472        );
7473        assert_stream!(
7474            records,
7475            "tree-item-1",
7476            [
7477                raw("<li>"),
7478                signal("child.name"),
7479                raw("</li>"),
7480                for_loop("child", "child.children", "tree-item-1"),
7481            ]
7482        );
7483    }
7484
7485    #[test]
7486    fn named_for_forward_references_and_empty_definitions() {
7487        let mut parser = HtmlParser::new();
7488        parser
7489            .parse(
7490                "file-a.html",
7491                r#"<for id="loop" each="item in earlier" /><for id="loop" each="item in later"></for>"#,
7492            )
7493            .unwrap();
7494        let records = parser.into_fragment_records();
7495        assert_stream!(
7496            records,
7497            "file-a.html",
7498            [
7499                for_loop("item", "earlier", "loop-1"),
7500                for_loop("item", "later", "loop-1")
7501            ]
7502        );
7503        assert!(records["loop-1"].fragments.is_empty());
7504    }
7505
7506    #[test]
7507    fn named_for_scope_survives_component_parsing_and_reparsing() {
7508        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7509        parser
7510            .component_registry_mut()
7511            .register_component(ComponentRegistration::new(
7512                "other-tree",
7513                r#"<for id="loop" each="item in other"><b>{{item.label}}</b></for>"#,
7514                None,
7515                false,
7516            ))
7517            .unwrap();
7518        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>"#;
7519        parser.parse("file-a.html", source).unwrap();
7520        parser
7521            .parse(
7522                "file-b.html",
7523                r#"<for id="loop" each="item in items">B</for>"#,
7524            )
7525            .unwrap();
7526        parser.parse("file-a.html", source).unwrap();
7527        let records = parser.into_fragment_records();
7528        assert_stream!(
7529            records,
7530            "file-a.html",
7531            [for_loop("item", "items", "loop-1")]
7532        );
7533        assert_stream!(records, "other-tree", [for_loop("item", "other", "loop-2")]);
7534        assert_stream!(
7535            records,
7536            "file-b.html",
7537            [for_loop("item", "items", "loop-3")]
7538        );
7539        assert_stream!(records, "loop-3", [raw("B")]);
7540        assert!(records["loop-1"].fragments.iter().any(|fragment| {
7541            matches!(fragment.fragment.as_ref(), Some(Fragment::ForLoop(repeat)) if repeat.fragment_id == "loop-1")
7542        }));
7543    }
7544
7545    #[test]
7546    fn named_for_reports_authoring_errors() {
7547        for (source, code) in [
7548            (r#"<for id each="x in xs" />"#, codes::INVALID_FOR_ID),
7549            (r#"<for id="" each="x in xs" />"#, codes::INVALID_FOR_ID),
7550            (
7551                r#"<for id="{{name}}" each="x in xs" />"#,
7552                codes::INVALID_FOR_ID,
7553            ),
7554            (
7555                r#"<for id="tree" template="tree" each="x in xs" />"#,
7556                codes::INVALID_FOR_ID,
7557            ),
7558            (
7559                r#"<for id="tree" id="tree" each="x in xs" />"#,
7560                codes::INVALID_FOR_ID,
7561            ),
7562            (r#"<for id="tree" each="x in xs" />"#, codes::UNKNOWN_FOR_ID),
7563            (
7564                r#"<for id="tree" each="x in xs">a</for><for id="tree" each="x in ys">b</for>"#,
7565                codes::DUPLICATE_FOR_ID,
7566            ),
7567            (
7568                r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children"><b>{{y.name}}</b></for></for>"#,
7569                codes::DUPLICATE_FOR_ID,
7570            ),
7571            (
7572                r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children" /></for>"#,
7573                codes::INCOMPATIBLE_FOR_ITEM,
7574            ),
7575        ] {
7576            let error = HtmlParser::new().parse("file-a.html", source).unwrap_err();
7577            let ParserError::Template(diagnostic) = error else {
7578                panic!("expected structured diagnostic for {source}");
7579            };
7580            assert_eq!(diagnostic.error_code(), Some(code), "{source}");
7581            assert!(!diagnostic.to_string().contains('\u{1b}'));
7582            assert!(diagnostic.to_string().contains("help:"));
7583            assert!(diagnostic.to_string().contains("file-a.html"));
7584            assert!(diagnostic.position_line_column().is_some(), "{source}");
7585        }
7586    }
7587
7588    #[test]
7589    fn named_for_cannot_reference_a_definition_from_another_file() {
7590        let mut parser = HtmlParser::new();
7591        parser
7592            .parse("a.html", r#"<for id="tree" each="x in xs">a</for>"#)
7593            .unwrap();
7594        let error = parser
7595            .parse("b.html", r#"<for id="tree" each="x in xs" />"#)
7596            .unwrap_err();
7597        assert!(
7598            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::UNKNOWN_FOR_ID))
7599        );
7600    }
7601
7602    #[test]
7603    fn named_for_does_not_overwrite_generated_fragments() {
7604        let mut parser = HtmlParser::new();
7605        parser
7606            .parse(
7607                "a.html",
7608                r#"<for id="for" each="x in xs">named</for><for each="x in xs">ordinary</for>"#,
7609            )
7610            .unwrap();
7611        let records = parser.into_fragment_records();
7612        assert_stream!(records, "for-1", [raw("named")]);
7613        assert_stream!(records, "for-2", [raw("ordinary")]);
7614        let error = HtmlParser::new()
7615            .parse(
7616                "a.html",
7617                r#"<for each="x in xs">ordinary</for><for id="for" each="x in xs">named</for>"#,
7618            )
7619            .unwrap_err();
7620        assert!(
7621            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::INVALID_FOR_ID))
7622        );
7623    }
7624
7625    #[test]
7626    fn named_for_css_analysis_terminates_without_losing_sibling_scopes() {
7627        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7628        for (name, source, css) in [
7629            ("token-leaf", "<b>leaf</b>", "b { color: var(--accent); }"),
7630            (
7631                "defined-tree",
7632                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
7633                ":root { --accent: red; }",
7634            ),
7635            (
7636                "undefined-tree",
7637                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
7638                "",
7639            ),
7640        ] {
7641            parser
7642                .component_registry_mut()
7643                .register_component(ComponentRegistration::new(name, source, Some(css), false))
7644                .unwrap();
7645        }
7646        parser
7647            .parse(
7648                "index.html",
7649                "<defined-tree></defined-tree><undefined-tree></undefined-tree>",
7650            )
7651            .unwrap();
7652        let analysis = parser.token_analysis();
7653        assert_eq!(analysis.protocol_tokens, ["accent"]);
7654        assert_eq!(analysis.fallback_chains.len(), 1);
7655    }
7656
7657    #[test]
7658    fn named_for_css_analysis_deduplicates_equivalent_diamond_paths() {
7659        use std::fmt::Write;
7660
7661        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7662        parser
7663            .component_registry_mut()
7664            .register_component(ComponentRegistration::new(
7665                "token-leaf",
7666                "<b>leaf</b>",
7667                Some("b { color: var(--accent); }"),
7668                false,
7669            ))
7670            .unwrap();
7671        let mut source = String::with_capacity(12 * 150);
7672        for index in 0..12 {
7673            write!(source, r#"<for id="b{index}" each="item in items">"#).unwrap();
7674            if index < 11 {
7675                let next = index + 1;
7676                write!(source, r#"<for id="b{next}" each="item in item.children"/><for id="b{next}" each="item in item.children"/>"#).unwrap();
7677            } else {
7678                source.push_str("<token-leaf></token-leaf>");
7679            }
7680            source.push_str("</for>");
7681        }
7682        parser.parse("index.html", &source).unwrap();
7683        let analysis = parser.token_analysis();
7684        assert_eq!(analysis.protocol_tokens, ["accent"]);
7685        assert_eq!(analysis.fallback_chains.len(), 1);
7686    }
7687
7688    #[test]
7689    fn named_for_css_analysis_preserves_distinct_same_size_definition_sets() {
7690        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7691        for (name, source, css) in [
7692            (
7693                "token-leaf",
7694                "<b>leaf</b>",
7695                "b { color: var(--accent); background: var(--shade); }",
7696            ),
7697            (
7698                "shared-tree",
7699                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children"/></for>"#,
7700                "",
7701            ),
7702            (
7703                "accent-tree",
7704                "<shared-tree></shared-tree>",
7705                ":root { --accent: red; }",
7706            ),
7707            (
7708                "shade-tree",
7709                "<shared-tree></shared-tree>",
7710                ":root { --shade: blue; }",
7711            ),
7712        ] {
7713            parser
7714                .component_registry_mut()
7715                .register_component(ComponentRegistration::new(name, source, Some(css), false))
7716                .unwrap();
7717        }
7718        parser
7719            .parse(
7720                "index.html",
7721                "<accent-tree></accent-tree><shade-tree></shade-tree>",
7722            )
7723            .unwrap();
7724        assert_eq!(parser.token_analysis().protocol_tokens, ["accent", "shade"]);
7725    }
7726
7727    #[test]
7728    fn legacy_for_template_attribute_is_rejected() {
7729        for source in [
7730            r#"<for template="legacy" each="x in xs"><span>{{x.name}}</span></for>"#,
7731            r#"<for template="legacy" each="x in xs" />"#,
7732            r#"<for template each="x in xs"></for>"#,
7733            r#"<for template="" each="x in xs"></for>"#,
7734        ] {
7735            let error = HtmlParser::new().parse("index.html", source).unwrap_err();
7736            let ParserError::Template(diagnostic) = error else {
7737                panic!("expected an authoring diagnostic for {source}");
7738            };
7739            assert_eq!(diagnostic.error_code(), Some(codes::INVALID_FOR_ID));
7740            assert!(diagnostic
7741                .help_text()
7742                .unwrap()
7743                .contains("replace template with id"));
7744            assert!(diagnostic.position_line_column().is_some());
7745        }
7746    }
7747
7748    #[test]
7749    fn named_for_recursion_still_rejects_reachable_boundaries() {
7750        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();
7751        assert!(
7752            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::BOUNDARY_IN_REPEAT))
7753        );
7754    }
7755
7756    #[test]
7757    fn named_for_fast_components_reject_references_but_keep_plain_loops() {
7758        use crate::plugin::{
7759            fast_v2::FastV2ParserPlugin, fast_v3::FastV3ParserPlugin, ParserPlugin,
7760            ParserPluginArtifacts,
7761        };
7762
7763        for fast_v2 in [true, false] {
7764            for references in [true, false] {
7765                let plugin: Box<dyn ParserPlugin> = if fast_v2 {
7766                    Box::new(FastV2ParserPlugin::new())
7767                } else {
7768                    Box::new(FastV3ParserPlugin::new())
7769                };
7770                let mut parser = HtmlParser::with_plugin(plugin);
7771                let body = if references {
7772                    r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#
7773                } else {
7774                    r#"<for id="tree" each="item in items"><if condition="{{item.name}}"><b>{{item.name}}</b></if></for>"#
7775                };
7776                parser
7777                    .component_registry_mut()
7778                    .register_component(ComponentRegistration::new("my-tree", body, None, true))
7779                    .unwrap();
7780                let result = parser.parse("index.html", "<my-tree></my-tree>");
7781                if references {
7782                    let error = result.unwrap_err();
7783                    assert!(
7784                        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())
7785                    );
7786                } else {
7787                    result.unwrap();
7788                    let ParserPluginArtifacts::ComponentTemplates(templates) =
7789                        parser.take_plugin_artifacts().unwrap()
7790                    else {
7791                        panic!("FAST produces component templates");
7792                    };
7793                    assert!(templates[0]
7794                        .template
7795                        .contains(r#"<f-repeat value="{{item in items}}">"#));
7796                    assert!(templates[0]
7797                        .template
7798                        .contains(r#"<f-when value="{{item.name}}">"#));
7799                }
7800            }
7801        }
7802    }
7803
7804    // ── Feature 2: <if> / <for> with multiple children ──────────────────
7805
7806    #[test]
7807    fn test_if_multiple_children() {
7808        // Port of: 'should handle <if> with multiple children'
7809        let (fragments, records) =
7810            parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
7811        assert_fragments!(fragments, [if_cond("if-1"),]);
7812        assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
7813    }
7814
7815    #[test]
7816    fn if_condition_with_double_quoted_literal_round_trips_through_parser() {
7817        // The FAST converter emits a single-quoted `<if condition='…'>` when the
7818        // expression contains a double-quoted literal. The WebUI parser must
7819        // extract the full expression (no truncation at the embedded quote), so
7820        // SSR and client see the same predicate.
7821        let (fragments, _) =
7822            parse_and_get_fragments(r#"<if condition='status == "ready"'><span>ok</span></if>"#);
7823        let condition = fragments
7824            .iter()
7825            .find_map(|fragment| match fragment.fragment.as_ref() {
7826                Some(webui_protocol::web_ui_fragment::Fragment::IfCond(if_cond)) => {
7827                    if_cond.condition.as_ref()
7828                }
7829                _ => None,
7830            })
7831            .expect("if-condition fragment with a parsed condition");
7832        match condition.expr.as_ref() {
7833            Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
7834                assert_eq!(pred.left, "status");
7835                assert_eq!(pred.right, "\"ready\"");
7836            }
7837            other => panic!("expected a predicate condition, got {other:?}"),
7838        }
7839    }
7840
7841    #[test]
7842    fn test_for_multiple_children() {
7843        // Port of: 'should handle <for> with multiple children'
7844        let (fragments, records) =
7845            parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
7846        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7847        assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
7848    }
7849
7850    // ── Feature 3: Handlebars at beginning/end of text ──────────────────
7851
7852    #[test]
7853    fn test_handlebars_at_beginning() {
7854        // Port of: 'should process handlebars from text at beginning'
7855        let (fragments, _) = parse_and_get_fragments("{{first}}");
7856        assert_fragments!(fragments, [signal("first"),]);
7857    }
7858
7859    #[test]
7860    fn test_handlebars_at_beginning_and_raw() {
7861        // Port of: 'should process handlebars from text at beginning and raw'
7862        let (fragments, _) = parse_and_get_fragments("{{first}}test");
7863        assert_fragments!(fragments, [signal("first"), raw("test"),]);
7864    }
7865
7866    #[test]
7867    fn test_handlebars_raw_and_end() {
7868        // Port of: 'should process handlebars from text at raw and end'
7869        let (fragments, _) = parse_and_get_fragments("test{{first}}");
7870        assert_fragments!(fragments, [raw("test"), signal("first"),]);
7871    }
7872
7873    // ── Feature 4: Handlebars edge cases ────────────────────────────────
7874
7875    #[test]
7876    fn test_handlebars_invalid_triple_open() {
7877        // Port of: 'should not process handlebars when invalid'
7878        let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
7879        assert_fragments!(fragments, [raw("{{{invalid}}"),]);
7880    }
7881
7882    #[test]
7883    fn test_handlebars_four_open_braces() {
7884        // Port of: 'should not process handlebars when invalid since triple exists'
7885        let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
7886        assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
7887    }
7888
7889    #[test]
7890    fn test_handlebars_five_open_with_valid_double() {
7891        // Port of: 'should not process handlebars when invalid but with valid triple'
7892        let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
7893        assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
7894    }
7895
7896    #[test]
7897    fn test_entities_preserved() {
7898        // Port of: 'should process entities correctly'
7899        let (fragments, _) = parse_and_get_fragments("<p>Hello&#125;World</p>");
7900        assert_fragments!(fragments, [raw("<p>Hello&#125;World</p>"),]);
7901    }
7902
7903    // ── Feature 5: DOCTYPE handling ─────────────────────────────────────
7904
7905    #[test]
7906    fn test_doctype_preserved() {
7907        // DOCTYPE should be preserved as raw content
7908        let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
7909        assert!(!fragments.is_empty());
7910        assert!(
7911            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
7912        );
7913    }
7914
7915    // ── Feature 6: Component attribute skip / multiple nested ───────────
7916
7917    #[test]
7918    fn test_component_attr_skip() {
7919        // Port of: 'should set attrSkip for skipped component attributes'
7920        let mut parser = HtmlParser::new();
7921        parser
7922            .component_registry
7923            .register_component(ComponentRegistration::new(
7924                "custom-element",
7925                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7926                None,
7927                true,
7928            ))
7929            .expect("register");
7930        let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
7931        let result = parser.parse("index.html", html);
7932        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7933        let records = parser.into_fragment_records();
7934
7935        // <custom-element, :config(attrStart), class(attrSkip), style(attrSkip),
7936        // role(attrSkip), data-test(attrSkip), aria-test, >, component, </custom-element>
7937        assert_fragments!(
7938            records["index.html"].fragments,
7939            [
7940                raw("<custom-element"),
7941                // :config with attrStart
7942                attr_complex_start(":config", "config"),
7943                // Skipped attrs
7944                attr_skip("class", "value0"),
7945                attr_skip("style", "value1"),
7946                attr_skip("role", "value2"),
7947                attr_skip("data-test", "value3"),
7948                attr("aria-test", "value4"),
7949                structural_matcher("streaming_root:custom-element"),
7950                raw(">"),
7951                component("custom-element"),
7952                raw("</custom-element>"),
7953            ]
7954        );
7955    }
7956
7957    #[test]
7958    fn test_component_attr_skip_static_and_embedded() {
7959        // Skipped attrs with static values and embedded bindings should
7960        // emit fragments (not be silently dropped).
7961        let mut parser = HtmlParser::new();
7962        parser
7963            .component_registry
7964            .register_component(ComponentRegistration::new(
7965                "item-group",
7966                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7967                None,
7968                true,
7969            ))
7970            .expect("register");
7971
7972        let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
7973        let result = parser.parse("index.html", html);
7974        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7975        let records = parser.into_fragment_records();
7976
7977        assert_fragments!(
7978            records["index.html"].fragments,
7979            [
7980                raw("<item-group"),
7981                attr_skip_raw("role", "list"),
7982                FragmentMatcher::Attribute(AttrMatcher {
7983                    name: "aria-labelledby".into(),
7984                    template: Some("attr-1".into()),
7985                    attr_start: true,
7986                    ..Default::default()
7987                }),
7988                attr_skip_template("data-testid", "attr-2"),
7989                attr_skip_raw("class", "fixed-class"),
7990                structural_matcher("streaming_root:item-group"),
7991                raw(">"),
7992                component("item-group"),
7993                raw("</item-group>"),
7994            ]
7995        );
7996
7997        // Verify the embedded-binding template sub-streams exist and
7998        // contain the expected static + signal fragments.
7999        assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
8000        assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
8001    }
8002
8003    #[test]
8004    fn test_component_multiple_nested() {
8005        // Port of: 'handle multiple nested web components'
8006        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8007        parser
8008            .component_registry
8009            .register_component(ComponentRegistration::new(
8010                "custom-element",
8011                "<custom-child></custom-child><span></span>",
8012                None,
8013                true,
8014            ))
8015            .expect("register");
8016        parser
8017            .component_registry
8018            .register_component(ComponentRegistration::new(
8019                "custom-button",
8020                "<span></span>",
8021                None,
8022                true,
8023            ))
8024            .expect("register");
8025        parser
8026            .component_registry
8027            .register_component(ComponentRegistration::new(
8028                "custom-child",
8029                "<h1>Hello World!</h1>",
8030                None,
8031                true,
8032            ))
8033            .expect("register");
8034
8035        let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
8036        let result = parser.parse("index.html", html);
8037        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8038        let records = parser.into_fragment_records();
8039
8040        // Entry stream
8041        assert_fragments!(
8042            records["index.html"].fragments,
8043            [for_loop("item", "items", "for-1"),]
8044        );
8045
8046        // For stream
8047        assert_stream!(
8048            records,
8049            "for-1",
8050            [
8051                raw("<custom-element"),
8052                structural_matcher("streaming_root:custom-element"),
8053                raw(">"),
8054                component("custom-element"),
8055                raw("<custom-button"),
8056                structural_matcher("streaming_root:custom-button"),
8057                raw(">"),
8058                component("custom-button"),
8059                raw("Ok</custom-button></custom-element>"),
8060            ]
8061        );
8062
8063        // Component streams — custom-element has contains() checks, keep manual
8064        let ce = &records["custom-element"].fragments;
8065        assert_eq!(ce.len(), 5);
8066        assert!(
8067            matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
8068        );
8069        assert!(
8070            matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
8071        );
8072        assert!(
8073            matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><span></span>"))
8074        );
8075
8076        assert_stream!(records, "custom-button", [raw("<span></span>"),]);
8077
8078        assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
8079    }
8080
8081    // ── Error handling tests ──────────────────────────────────────────
8082
8083    #[test]
8084    fn test_invalid_markup_returns_error() {
8085        // Port of: 'should fail with invalid markup'
8086        let mut parser = HtmlParser::new();
8087        let result = parser.parse("index.html", "<div><span>Unclosed div");
8088        assert!(matches!(result, Err(ParserError::Template(diag)) if
8089            diag.to_string().contains("unclosed <div> tag")
8090        ));
8091    }
8092
8093    #[test]
8094    fn test_unterminated_opening_tag_returns_error() {
8095        let mut parser = HtmlParser::new();
8096        let result = parser.parse("index.html", "before <div");
8097        assert!(matches!(result, Err(ParserError::Template(diag)) if
8098            diag.to_string().contains("malformed HTML tag")
8099        ));
8100    }
8101
8102    #[test]
8103    fn test_unterminated_html_comment_returns_error() {
8104        let mut parser = HtmlParser::new();
8105        let result = parser.parse("index.html", "<div><!-- missing close</div>");
8106        assert!(matches!(result, Err(ParserError::Template(diag)) if
8107            diag.to_string().contains("unclosed <div> tag")
8108                || diag.to_string().contains("unterminated HTML comment")
8109        ));
8110    }
8111
8112    #[test]
8113    fn test_unexpected_closing_tag_returns_error() {
8114        let mut parser = HtmlParser::new();
8115        let result = parser.parse("index.html", "</span>");
8116        assert!(matches!(result, Err(ParserError::Template(diag)) if
8117            diag.to_string().contains("unexpected closing tag </span>")
8118        ));
8119    }
8120
8121    #[test]
8122    fn test_rejects_templates_over_size_limit() {
8123        let mut parser = HtmlParser::new();
8124        let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
8125        let result = parser.parse("index.html", &html);
8126
8127        assert!(matches!(result, Err(ParserError::Html(message)) if
8128            message.contains("exceeds") && message.contains("parser limit")
8129        ));
8130    }
8131
8132    #[test]
8133    fn test_rejects_templates_over_depth_limit() {
8134        let mut parser = HtmlParser::new();
8135        let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
8136        for _ in 0..=MAX_TEMPLATE_DEPTH {
8137            html.push_str("<div>");
8138        }
8139        for _ in 0..=MAX_TEMPLATE_DEPTH {
8140            html.push_str("</div>");
8141        }
8142
8143        let result = parser.parse("index.html", &html);
8144
8145        assert!(matches!(result, Err(ParserError::Template(diag)) if
8146            diag.to_string().contains("nesting exceeds")
8147        ));
8148    }
8149
8150    #[test]
8151    fn test_rejects_routes_over_depth_limit() {
8152        let mut parser = HtmlParser::new();
8153        parser
8154            .component_registry
8155            .register_component(ComponentRegistration::new(
8156                "route-page",
8157                r#"<template shadowrootmode="open"><slot></slot></template>"#,
8158                None,
8159                true,
8160            ))
8161            .expect("register");
8162
8163        let depth = MAX_TEMPLATE_DEPTH + 2;
8164        let mut html = String::with_capacity(depth * 48);
8165        for _ in 0..depth {
8166            html.push_str(r#"<route path="x" component="route-page">"#);
8167        }
8168        for _ in 0..depth {
8169            html.push_str("</route>");
8170        }
8171
8172        let result = parser.parse("index.html", &html);
8173
8174        assert!(matches!(result, Err(ParserError::Template(diag)) if
8175            diag.error_code() == Some(codes::EXCESSIVE_NESTING)
8176                && diag.to_string().contains("route nesting exceeds")
8177        ));
8178    }
8179
8180    #[test]
8181    fn test_rejects_recursive_component_templates() {
8182        let mut parser = HtmlParser::new();
8183        parser
8184            .component_registry
8185            .register_component(ComponentRegistration::new(
8186                "self-card",
8187                "<self-card></self-card>",
8188                None,
8189                true,
8190            ))
8191            .expect("register");
8192
8193        let result = parser.parse("index.html", "<self-card></self-card>");
8194
8195        assert!(matches!(result, Err(ParserError::Template(diag)) if
8196            diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
8197                && diag.to_string().contains("recursive template reference")
8198        ));
8199    }
8200
8201    // ── Integration tests ─────────────────────────────────────────────
8202
8203    #[test]
8204    fn test_complex_raw_text_full_page() {
8205        // Port of: 'should process a complex raw text'
8206        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>"#;
8207        let (fragments, _) = parse_and_get_fragments(html);
8208
8209        // DOCTYPE + <head>, head_start, head content, head_end, </head><body>,
8210        // body_start, body content, body_end, </body></html>
8211        assert!(fragments.len() >= 9);
8212        assert!(
8213            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8214                raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
8215        );
8216        assert!(
8217            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
8218                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
8219        );
8220        assert!(
8221            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8222                raw.value.contains("<title>Test</title>"))
8223        );
8224        assert!(
8225            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
8226                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
8227        );
8228        assert!(
8229            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8230                raw.value.contains("</head>") && raw.value.ends_with("<body>"))
8231        );
8232        assert!(
8233            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
8234                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
8235        );
8236        assert!(
8237            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8238                raw.value.contains("<app-shell>"))
8239        );
8240        assert!(
8241            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
8242                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
8243        );
8244        assert!(
8245            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8246                raw.value.contains("</body>") && raw.value.contains("</html>"))
8247        );
8248    }
8249
8250    #[test]
8251    fn raw_bindings_in_html_text_contexts_do_not_own_html_ranges() {
8252        for element in ["title", "textarea", "script", "xmp"] {
8253            let html = format!("<{element}>{{{{{{value}}}}}}</{element}>");
8254            let (fragments, _) = parse_and_get_fragments(&html);
8255            let signal = fragments
8256                .iter()
8257                .find_map(|fragment| match fragment.fragment.as_ref() {
8258                    Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
8259                    _ => None,
8260                });
8261            let signal = signal
8262                .unwrap_or_else(|| panic!("expected raw signal inside <{element}>: {fragments:?}"));
8263            assert!(
8264                signal.raw,
8265                "<{element}> should preserve raw brace semantics"
8266            );
8267            assert!(
8268                signal.raw_text_context,
8269                "<{element}> should suppress HTML ownership markers"
8270            );
8271        }
8272    }
8273
8274    #[test]
8275    fn raw_bindings_in_template_content_own_html_ranges() {
8276        let (fragments, _) = parse_and_get_fragments("<template>{{{value}}}</template>");
8277        let signal = fragments
8278            .iter()
8279            .find_map(|fragment| match fragment.fragment.as_ref() {
8280                Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
8281                _ => None,
8282            });
8283        let signal =
8284            signal.unwrap_or_else(|| panic!("expected raw signal inside template: {fragments:?}"));
8285        assert!(signal.raw);
8286        assert!(!signal.raw_text_context);
8287    }
8288
8289    #[test]
8290    fn test_css_strategy_external_defers_link_to_server_closure() {
8291        let mut parser = HtmlParser::new();
8292        parser
8293            .component_registry_mut()
8294            .register_component(ComponentRegistration::new(
8295                "my-card",
8296                r#"<template shadowrootmode="open"><p>content</p></template>"#,
8297                Some("p { color: red; }"),
8298                true,
8299            ))
8300            .ok();
8301        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
8302        let records = parser.into_fragment_records();
8303        let my_card = &records["my-card"].fragments;
8304        let raw_text: String = my_card
8305            .iter()
8306            .filter_map(|f| match &f.fragment {
8307                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8308                _ => None,
8309            })
8310            .collect();
8311        assert!(
8312            !raw_text.contains(r#"<link rel="stylesheet""#),
8313            "component templates must not own tree-local links: {}",
8314            raw_text
8315        );
8316        assert!(my_card.iter().any(|fragment| matches!(
8317            fragment.fragment.as_ref(),
8318            Some(Fragment::Signal(signal))
8319                if signal.value == "}}}webui:shadow_styles:my-card"
8320        )));
8321    }
8322
8323    #[test]
8324    fn test_uppercase_style_element_is_processed_as_css() {
8325        // HTML tag names are ASCII case-insensitive: an uppercase `<STYLE>`
8326        // block must be CSS-processed exactly like `<style>`, so a CSS signal
8327        // comment becomes a Signal fragment (not inert raw text) and the
8328        // reconstructed closing tag mirrors the source case.
8329        let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
8330        assert!(
8331            fragments.iter().any(|f| matches!(
8332                f.fragment.as_ref(),
8333                Some(Fragment::Signal(s))
8334                    if s.value == "tokens" && !s.raw && s.raw_text_context
8335            )),
8336            "uppercase <STYLE> should be CSS-processed, got: {:?}",
8337            fragments
8338        );
8339        let raw: String = fragments
8340            .iter()
8341            .filter_map(|f| match f.fragment.as_ref() {
8342                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8343                _ => None,
8344            })
8345            .collect();
8346        assert!(
8347            raw.contains("</STYLE>"),
8348            "closing tag should preserve source case, got: {raw}"
8349        );
8350    }
8351
8352    #[test]
8353    fn test_css_strategy_inline_defers_style_to_server_closure() {
8354        let mut parser = HtmlParser::with_options(CssStrategy::Style);
8355        parser
8356            .component_registry_mut()
8357            .register_component(ComponentRegistration::new(
8358                "my-card",
8359                "<p>content</p>",
8360                Some("p { color: red; }"),
8361                true,
8362            ))
8363            .expect("register component");
8364        parser
8365            .parse("index.html", "<my-card>Hello</my-card>")
8366            .expect("parse component");
8367        let records = parser.into_fragment_records();
8368        let my_card = &records["my-card"].fragments;
8369        let raw_text: String = my_card
8370            .iter()
8371            .filter_map(|f| match &f.fragment {
8372                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8373                _ => None,
8374            })
8375            .collect();
8376        assert!(
8377            !raw_text.contains("<style>"),
8378            "component templates must not own tree-local styles: {}",
8379            raw_text
8380        );
8381        assert!(
8382            !raw_text.contains("<link"),
8383            "Should not have <link> tag in inline mode: {}",
8384            raw_text
8385        );
8386    }
8387
8388    #[test]
8389    fn test_css_strategy_module_emits_adopted_stylesheets() {
8390        let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
8391        parser
8392            .component_registry_mut()
8393            .register_component(ComponentRegistration::new(
8394                "my-card",
8395                "<p>content</p>",
8396                Some("p { color: red; }"),
8397                true,
8398            ))
8399            .expect("register component");
8400        parser
8401            .parse("index.html", "<my-card>Hello</my-card>")
8402            .expect("parse component");
8403        let records = parser.into_fragment_records();
8404
8405        // Component template should have shadowrootadoptedstylesheets, no CSS
8406        // module in raw fragments (CSS lives on the component fragment's css field)
8407        let my_card = &records["my-card"].fragments;
8408        let template_text: String = my_card
8409            .iter()
8410            .filter_map(|f| match &f.fragment {
8411                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8412                _ => None,
8413            })
8414            .collect();
8415        assert!(
8416            !template_text.contains("shadowrootadoptedstylesheets"),
8417            "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
8418        );
8419        assert!(
8420            !template_text.contains("<link"),
8421            "Should not have <link> in module mode: {template_text}"
8422        );
8423        // No CSS module baked into raw fragments — CSS is stored in
8424        // protocol.components, populated by the build system, and emitted
8425        // by the handler (SSR inline + SPA partials) as importmap scripts.
8426        assert!(
8427            !template_text.contains(r#"<style type="module""#),
8428            "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
8429        );
8430        assert!(
8431            !template_text.contains(r#"<script type="importmap""#),
8432            "CSS module importmap should NOT be in raw fragments: {template_text}"
8433        );
8434    }
8435
8436    #[test]
8437    fn light_component_css_remains_authored_and_unscoped() {
8438        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8439        parser
8440            .component_registry_mut()
8441            .register_component(ComponentRegistration::new(
8442                "my-card",
8443                "<p class=\"label\">content</p>",
8444                Some(".label{color:red}"),
8445                false,
8446            ))
8447            .expect("register component");
8448        parser
8449            .parse("index.html", "<my-card></my-card>")
8450            .expect("parse component");
8451
8452        let css = parser
8453            .component_registry()
8454            .get("my-card")
8455            .and_then(|component| component.css_content.as_deref())
8456            .expect("authored CSS");
8457        assert_eq!(css, ".label{color:red}");
8458    }
8459
8460    #[test]
8461    fn light_component_inline_styles_remain_unscoped_and_unmarked() {
8462        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8463        let built = parser
8464            .build_component_templates(ComponentTemplateInput {
8465                tag_name: "my-card",
8466                html: "<style>.label{color:red}</style><p class=\"label\">content</p>",
8467                artifact_html: None,
8468                authored_html: None,
8469                css_content: None,
8470                artifact_needed: false,
8471            })
8472            .expect("build Light component");
8473
8474        assert_eq!(
8475            built.ssr,
8476            "<style>.label{color:red}</style><p class=\"label\">content</p>"
8477        );
8478        assert!(!built.ssr.contains("data-wl"));
8479    }
8480
8481    #[test]
8482    fn light_component_rejects_shadow_only_selectors() {
8483        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8484        for selector in [":host", ":host-context(body.dark)", "::slotted(*)"] {
8485            let html = format!("<style>{selector}{{color:red}}</style><p>content</p>");
8486            let result = parser.build_component_templates(ComponentTemplateInput {
8487                tag_name: "my-card",
8488                html: &html,
8489                artifact_html: None,
8490                authored_html: None,
8491                css_content: None,
8492                artifact_needed: false,
8493            });
8494            let error = match result {
8495                Ok(_) => panic!("Shadow-only selector must fail in Light DOM"),
8496                Err(error) => error,
8497            };
8498            assert!(matches!(
8499                error,
8500                ParserError::Template(ref diagnostic)
8501                    if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
8502            ));
8503        }
8504    }
8505
8506    #[test]
8507    fn light_component_external_css_rejects_host_selector() {
8508        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8509        parser
8510            .component_registry_mut()
8511            .register_component(ComponentRegistration::new(
8512                "my-card",
8513                "<p>content</p>",
8514                Some(":host{display:block}"),
8515                false,
8516            ))
8517            .expect("register component");
8518        let error = parser
8519            .parse("index.html", "<my-card></my-card>")
8520            .expect_err("external :host must fail in Light DOM");
8521        assert!(matches!(
8522            error,
8523            ParserError::Template(ref diagnostic)
8524                if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
8525        ));
8526    }
8527
8528    #[test]
8529    fn shadow_component_inline_styles_remain_tree_local_and_unchanged() {
8530        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
8531        let built = parser
8532            .build_component_templates(ComponentTemplateInput {
8533                tag_name: "my-card",
8534                html: "<style>:host{display:block}.label{color:red}</style><p class=\"label\">content</p>",
8535                artifact_html: None,
8536                authored_html: None,
8537                css_content: None,
8538                artifact_needed: false,
8539            })
8540            .expect("build Shadow component");
8541
8542        assert!(built.ssr.contains(
8543            "<template shadowrootmode=\"open\"><style>:host{display:block}.label{color:red}</style>"
8544        ));
8545        assert!(!built.ssr.contains("data-wl"));
8546    }
8547
8548    #[test]
8549    fn nested_light_hosts_are_unmarked() {
8550        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8551        parser
8552            .component_registry_mut()
8553            .register_component(ComponentRegistration::new(
8554                "my-child",
8555                "<p class=\"inner\">child</p>",
8556                Some(".inner{color:green}"),
8557                false,
8558            ))
8559            .expect("register child");
8560        parser
8561            .component_registry_mut()
8562            .register_component(ComponentRegistration::new(
8563                "my-card",
8564                "<my-child></my-child>",
8565                Some("my-child{display:block}"),
8566                false,
8567            ))
8568            .expect("register parent");
8569        parser
8570            .parse("index.html", "<my-card></my-card>")
8571            .expect("parse component");
8572
8573        let records = parser.into_fragment_records();
8574        let raw: String = records["my-card"]
8575            .fragments
8576            .iter()
8577            .filter_map(|fragment| match &fragment.fragment {
8578                Some(web_ui_fragment::Fragment::Raw(raw)) => Some(raw.value.as_str()),
8579                _ => None,
8580            })
8581            .collect();
8582        assert!(raw.contains("<my-child>"), "nested host was changed: {raw}");
8583        assert!(!raw.contains("data-wl"), "Light markers remain: {raw}");
8584    }
8585
8586    #[test]
8587    fn raw_html_binding_does_not_change_global_css() {
8588        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8589        parser
8590            .component_registry_mut()
8591            .register_component(ComponentRegistration::new(
8592                "my-card",
8593                "<div class=\"body\">{{{descriptionHtml}}}</div>",
8594                Some(".body p{margin:0}"),
8595                false,
8596            ))
8597            .expect("register component");
8598        parser
8599            .parse("index.html", "<my-card></my-card>")
8600            .expect("parse component");
8601
8602        let css = parser
8603            .component_registry()
8604            .get("my-card")
8605            .and_then(|component| component.css_content.as_deref())
8606            .expect("authored CSS");
8607        assert_eq!(css, ".body p{margin:0}");
8608    }
8609
8610    #[test]
8611    fn test_css_strategy_module_no_css_no_adopted_attr() {
8612        let mut parser = HtmlParser::with_options(CssStrategy::Module);
8613        parser
8614            .component_registry_mut()
8615            .register_component(ComponentRegistration::new(
8616                "my-card",
8617                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
8618                None,
8619                true,
8620            ))
8621            .ok();
8622        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
8623        let records = parser.into_fragment_records();
8624        let my_card = &records["my-card"].fragments;
8625        let template_text: String = my_card
8626            .iter()
8627            .filter_map(|f| match &f.fragment {
8628                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8629                _ => None,
8630            })
8631            .collect();
8632        // No CSS → no shadowrootadoptedstylesheets attribute
8633        assert!(
8634            !template_text.contains("shadowrootadoptedstylesheets"),
8635            "Should not have adopted attr without CSS: {template_text}"
8636        );
8637    }
8638
8639    // ── Dev-authored <template> wrapper handling ────────────────────
8640    //
8641    // These tests distinguish an ordinary `<template>` element from a
8642    // declarative Shadow DOM root:
8643    //
8644    // An ordinary `<template ...>` is Light DOM content and is preserved
8645    // verbatim. Only a sole open declarative Shadow root selects Shadow DOM.
8646    //
8647    // Calls `process_component_template` directly so the assertions observe
8648    // the exact HTML string the framework emits for the component template
8649    // (this is what gets handed to the inner parse + plugin hooks).
8650
8651    #[test]
8652    fn light_preserves_dev_template_with_static_attrs() {
8653        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8654        let processed = parser
8655            .process_component_template(
8656                r#"<template foo="bar"><div>hi</div></template>"#,
8657                None,
8658                None,
8659            )
8660            .expect("process failed");
8661        assert!(
8662            processed.contains(r#"<template foo="bar">"#),
8663            "expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
8664        );
8665        assert!(
8666            processed.contains("</template>"),
8667            "expected closing </template> preserved, got: {processed}"
8668        );
8669    }
8670
8671    #[test]
8672    fn light_preserves_dev_template_with_signal_fragment_attrs() {
8673        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8674        let processed = parser
8675            .process_component_template(
8676                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8677                None,
8678                None,
8679            )
8680            .expect("process failed");
8681        assert!(
8682            processed.contains(r#"<template foo="{{foo}}">"#),
8683            "expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
8684        );
8685    }
8686
8687    #[test]
8688    fn light_preserves_dev_template_with_multiple_attrs() {
8689        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8690        let processed = parser.process_component_template(
8691            r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
8692            None,
8693            None,
8694        )
8695        .expect("process failed");
8696        assert!(
8697            processed.contains("autofocus")
8698                && processed.contains(r#"tabindex="0""#)
8699                && processed.contains(r#"role="region""#)
8700                && processed.contains(r#"data-x="y""#),
8701            "expected all dev template attrs preserved, got: {processed}"
8702        );
8703    }
8704
8705    #[test]
8706    fn light_no_template_no_wrapper_added() {
8707        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8708        let processed = parser
8709            .process_component_template("<div>hi</div>", None, None)
8710            .expect("process failed");
8711        assert!(
8712            !processed.contains("<template"),
8713            "framework must not add a <template> wrapper when dev omits one, got: {processed}"
8714        );
8715        assert!(
8716            processed.contains("<div>hi</div>"),
8717            "expected inner content emitted as-is, got: {processed}"
8718        );
8719    }
8720
8721    #[test]
8722    fn ordinary_template_remains_light_dom_content() {
8723        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8724        let processed = parser
8725            .process_component_template(
8726                r#"<template foo="bar"><div>hi</div></template>"#,
8727                None,
8728                None,
8729            )
8730            .expect("process failed");
8731        assert!(
8732            processed.contains(r#"<template foo="bar">"#),
8733            "ordinary template content must be preserved, got: {processed}"
8734        );
8735        assert!(
8736            !processed.contains("shadowrootmode"),
8737            "ordinary template must not gain a generated Shadow root, got: {processed}"
8738        );
8739    }
8740
8741    #[test]
8742    fn bare_template_is_unwrapped_as_explicit_light_dom() {
8743        let mut parser = HtmlParser::new();
8744        let built = parser
8745            .build_component_templates(ComponentTemplateInput {
8746                tag_name: "my-card",
8747                html: "<template><div class=\"label\">hi</div></template>",
8748                artifact_html: None,
8749                authored_html: None,
8750                css_content: Some(".label{color:red}"),
8751                artifact_needed: false,
8752            })
8753            .expect("bare template should select Light DOM");
8754        assert!(!built.uses_shadow_dom);
8755        assert_eq!(built.ssr, r#"<div class="label">hi</div>"#);
8756    }
8757
8758    #[test]
8759    fn attributed_template_keeps_the_shadow_fallback() {
8760        let mut parser = HtmlParser::new();
8761        let built = parser
8762            .build_component_templates(ComponentTemplateInput {
8763                tag_name: "my-card",
8764                html: r#"<template data-purpose="content"><div>hi</div></template>"#,
8765                artifact_html: None,
8766                authored_html: None,
8767                css_content: None,
8768                artifact_needed: false,
8769            })
8770            .expect("attributed template should remain ordinary content");
8771        assert!(built.uses_shadow_dom);
8772        assert_eq!(
8773            built.ssr,
8774            r#"<template shadowrootmode="open"><template data-purpose="content"><div>hi</div></template></template>"#
8775        );
8776    }
8777
8778    #[test]
8779    fn closed_shadow_wrapper_is_rejected_during_template_processing() {
8780        let mut parser = HtmlParser::new();
8781        let error = parser
8782            .process_component_template(
8783                r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
8784                None,
8785                None,
8786            )
8787            .expect_err("closed roots must fail");
8788        assert!(matches!(
8789            error,
8790            ParserError::Template(ref diagnostic)
8791                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
8792        ));
8793    }
8794
8795    #[test]
8796    fn ordinary_template_preserves_signal_fragment_attrs() {
8797        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8798        let processed = parser
8799            .process_component_template(
8800                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8801                None,
8802                None,
8803            )
8804            .expect("process failed");
8805        assert!(
8806            processed.starts_with(r#"<template foo="{{foo}}">"#),
8807            "ordinary template must remain Light content, got: {processed}"
8808        );
8809    }
8810
8811    #[test]
8812    fn unwrapped_component_never_gains_shadow_wrapper() {
8813        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8814        let processed = parser
8815            .process_component_template("<div>hi</div>", None, None)
8816            .expect("process failed");
8817        assert!(
8818            !processed.contains("shadowrootmode"),
8819            "framework must not generate a Shadow root, got: {processed}"
8820        );
8821        assert!(
8822            processed.contains("<div>hi</div>"),
8823            "Light content must survive processing, got: {processed}"
8824        );
8825    }
8826
8827    #[test]
8828    fn default_unwrapped_component_gains_open_shadow_wrapper() {
8829        let mut parser = HtmlParser::new();
8830        let processed = parser
8831            .process_component_template("<div>hi</div>", None, None)
8832            .expect("process failed");
8833        assert_eq!(
8834            processed,
8835            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8836        );
8837    }
8838
8839    #[test]
8840    fn leading_comments_do_not_change_root_template_detection() {
8841        const COMMENT: &str = "<!-- Copyright (C) Corporation. All rights reserved. -->";
8842        let fixtures = [
8843            (
8844                format!(
8845                    "{COMMENT}\n<template shadowrootmode=\"open\">\n  <h1>Hello</h1>\n</template>"
8846                ),
8847                "<template shadowrootmode=\"open\">\n  <h1>Hello</h1>\n</template>",
8848            ),
8849            (format!("{COMMENT}\n<h1>Hello</h1>"), "<h1>Hello</h1>"),
8850            (format!("{COMMENT}\nHello"), "Hello"),
8851        ];
8852
8853        for (input, expected) in fixtures {
8854            let mut parser = HtmlParser::with_options(DomStrategy::Light);
8855            let processed = parser
8856                .process_component_template(&input, None, None)
8857                .expect("leading-comment fixture should process");
8858            assert_eq!(processed, expected);
8859        }
8860    }
8861
8862    #[test]
8863    fn component_policy_wrapper_is_build_only_and_unwrapped_for_light() {
8864        // A policy wrapper is authored on a plain `<template>`; the policy is
8865        // applied to the host through generated CSS, so the wrapper must not
8866        // survive into a Light template.
8867        let html = r#"<template w-hydrate="lazy"><div>hi</div></template>"#;
8868        let mut shadow = HtmlParser::with_options(DomStrategy::Shadow);
8869        let shadow_built = shadow
8870            .build_component_templates(ComponentTemplateInput {
8871                tag_name: "my-comp",
8872                html,
8873                artifact_html: None,
8874                authored_html: None,
8875                css_content: None,
8876                artifact_needed: true,
8877            })
8878            .expect("shadow policy wrapper should compile");
8879        assert_eq!(
8880            shadow_built.ssr,
8881            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8882        );
8883        assert!(!shadow_built.artifact().contains("w-hydrate"));
8884        let mut light = HtmlParser::with_options(DomStrategy::Light);
8885        let light_built = light
8886            .build_component_templates(ComponentTemplateInput {
8887                tag_name: "my-comp",
8888                html,
8889                artifact_html: None,
8890                authored_html: None,
8891                css_content: None,
8892                artifact_needed: true,
8893            })
8894            .expect("light policy wrapper should compile");
8895        assert_eq!(light_built.ssr, "<div>hi</div>");
8896        assert_eq!(light_built.artifact(), "<div>hi</div>");
8897
8898        let mut implicit_shadow = HtmlParser::new();
8899        let shadow_built = implicit_shadow
8900            .build_component_templates(ComponentTemplateInput {
8901                tag_name: "my-comp",
8902                html,
8903                artifact_html: None,
8904                authored_html: None,
8905                css_content: None,
8906                artifact_needed: true,
8907            })
8908            .expect("default policy wrapper should compile as Shadow");
8909        assert_eq!(
8910            shadow_built.ssr,
8911            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8912        );
8913        assert!(!shadow_built.artifact().contains("w-hydrate"));
8914
8915        // An authored Shadow component keeps its wrapper; only the
8916        // compiler-owned policy attributes are stripped.
8917        let shadow_html =
8918            r#"<template shadowrootmode="open" w-hydrate="lazy"><div>hi</div></template>"#;
8919        let mut shadow = HtmlParser::new();
8920        let authored_shadow_built = shadow
8921            .build_component_templates(ComponentTemplateInput {
8922                tag_name: "my-comp",
8923                html: shadow_html,
8924                artifact_html: None,
8925                authored_html: None,
8926                css_content: None,
8927                artifact_needed: true,
8928            })
8929            .expect("shadow policy wrapper should compile");
8930        assert_eq!(
8931            authored_shadow_built.ssr,
8932            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8933        );
8934        assert!(!authored_shadow_built.artifact().contains("w-hydrate"));
8935    }
8936
8937    #[test]
8938    fn interaction_policy_marks_component_hosts_in_ssr_fragments() {
8939        let mut parser = HtmlParser::new();
8940        parser
8941            .component_registry
8942            .register_component(ComponentRegistration::new(
8943                "interaction-shell",
8944                r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
8945                None,
8946                true,
8947            ))
8948            .expect("component registration");
8949        parser
8950            .parse(
8951                "index.html",
8952                "<body><interaction-shell></interaction-shell></body>",
8953            )
8954            .expect("entry parse");
8955        let records = parser.into_fragment_records();
8956        let entry_html: String = records["index.html"]
8957            .fragments
8958            .iter()
8959            .filter_map(|fragment| match fragment.fragment.as_ref() {
8960                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8961                _ => None,
8962            })
8963            .collect();
8964        assert!(entry_html.contains("<interaction-shell data-webui-interaction"));
8965    }
8966
8967    #[test]
8968    fn combined_interaction_policy_marks_hosts_and_emits_render_css() {
8969        let mut parser = HtmlParser::new();
8970        parser
8971            .component_registry
8972            .register_component(ComponentRegistration::new(
8973                "interaction-panel",
8974                concat!(
8975                    r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
8976                    r#"w-hydrate="interaction"><button>Open</button></template>"#,
8977                ),
8978                None,
8979                true,
8980            ))
8981            .expect("component registration");
8982        parser
8983            .parse(
8984                "index.html",
8985                "<body><interaction-panel></interaction-panel></body>",
8986            )
8987            .expect("entry parse");
8988        assert!(parser
8989            .component_registry
8990            .render_policy_css(["interaction-panel"])
8991            .contains("content-visibility:auto"));
8992        let records = parser.into_fragment_records();
8993        let entry_html: String = records["index.html"]
8994            .fragments
8995            .iter()
8996            .filter_map(|fragment| match fragment.fragment.as_ref() {
8997                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8998                _ => None,
8999            })
9000            .collect();
9001        assert!(entry_html.contains("<interaction-panel data-webui-interaction"));
9002    }
9003
9004    // ── CSS-module adoption on dev-authored <template> wrappers ────────
9005    //
9006    // Regression guard: under `CssStrategy::Module`, the
9007    // `shadowrootadoptedstylesheets` attribute is the only wire between
9008    // the framework-emitted importmap/module-script and the shadow root.
9009    // When the developer supplies their own `<template>` wrapper (e.g.
9010    // to attach `@event` handlers), the framework preserves that wrapper
9011    // and appends the CSS-module adoption attribute when it is missing.
9012
9013    #[test]
9014    fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
9015        let mut parser = HtmlParser::new();
9016        let processed = parser
9017            .process_component_template(
9018                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
9019                None,
9020                Some("my-comp"),
9021            )
9022            .expect("process failed");
9023        assert!(
9024            processed.contains(r#"shadowrootmode="open""#),
9025            "dev-authored shadowrootmode must be preserved, got: {processed}"
9026        );
9027        assert!(
9028            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
9029            "module CSS should append adopted stylesheets, got: {processed}"
9030        );
9031    }
9032
9033    #[test]
9034    fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
9035        let mut parser = HtmlParser::new();
9036        let processed = parser
9037            .process_component_template(
9038                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
9039                None,
9040                Some("my-comp"),
9041            )
9042            .unwrap();
9043        assert!(
9044            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
9045            "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
9046        );
9047        assert_eq!(
9048            processed.matches("shadowrootadoptedstylesheets").count(),
9049            1,
9050            "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
9051        );
9052    }
9053
9054    #[test]
9055    fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
9056        for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
9057            let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
9058            let built = match parser.build_component_templates(ComponentTemplateInput {
9059                tag_name: "my-comp",
9060                html: r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
9061                artifact_html: None,
9062                authored_html: None,
9063                css_content: Some(":host { color: red; }"),
9064                artifact_needed: true,
9065            }) {
9066                Ok(built) => built,
9067                Err(err) => panic!(
9068                    "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
9069                ),
9070            };
9071            let artifact = built.artifact();
9072
9073            assert!(artifact.contains(r#"shadowrootmode="open""#));
9074            assert!(artifact.contains(r#"@click="{onClick()}""#));
9075            assert!(artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#));
9076            assert_eq!(artifact.matches("shadowrootadoptedstylesheets").count(), 1);
9077            assert!(!built.ssr.contains("@click"));
9078        }
9079    }
9080
9081    #[test]
9082    fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
9083        // Multi-specifier scenarios: the dev wants to adopt extra sheets
9084        // alongside their own component's module. Honored verbatim — the
9085        // framework's only job is to validate that *some*
9086        // `shadowrootadoptedstylesheets` is present.
9087        let mut parser = HtmlParser::new();
9088        let processed = parser
9089            .process_component_template(
9090                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
9091                None,
9092                Some("my-comp"),
9093            )
9094            .unwrap();
9095        assert!(
9096            processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
9097            "dev's multi-specifier list must be preserved verbatim, got: {processed}"
9098        );
9099    }
9100
9101    #[test]
9102    fn dev_template_no_adopted_specifier_does_not_validate() {
9103        // CssStrategy::Link or CssStrategy::Style pass `adopted_specifier=None`.
9104        // Dev's <template> must be preserved verbatim and the validation
9105        // must not fire.
9106        let mut parser = HtmlParser::new();
9107        let processed = parser
9108            .process_component_template(
9109                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
9110                None,
9111                None,
9112            )
9113            .unwrap();
9114        assert!(
9115            !processed.contains("shadowrootadoptedstylesheets"),
9116            "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
9117        );
9118    }
9119
9120    // test_signal_with_default_value — SKIPPED
9121    // The NodeJS `<f-signal value="testSignal">Default Text</f-signal>` feature
9122    // is not supported in the Rust parser. There is no `f-signal` element
9123    // handling in HtmlParser and no corresponding fragment type in
9124    // webui_protocol.
9125
9126    // test_estimated_buffer_size — SKIPPED
9127    // The NodeJS `estimatedBufferSize` field does not exist in the Rust
9128    // WebUIFragmentRecords / WebUIProtocol types. Buffer size estimation is
9129    // not part of the Rust parser output.
9130
9131    #[test]
9132    fn test_body_start_end_injection() {
9133        // Port of: 'should inject body_start and body_end signals around body content'
9134        // Verifies body_start appears immediately after <body> and body_end
9135        // appears immediately before </body> in a full HTML page.
9136        let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
9137        let (fragments, _) = parse_and_get_fragments(html);
9138
9139        assert_fragments!(
9140            fragments,
9141            [
9142                raw("<html><head>"),
9143                structural_matcher("head_start"),
9144                raw("<title>Test</title>"),
9145                structural_matcher("head_end"),
9146                raw("</head><body>"),
9147                structural_matcher("body_start"),
9148                raw("<div>Content</div><p>More</p>"),
9149                structural_matcher("body_end"),
9150                raw("</body></html>"),
9151            ]
9152        );
9153    }
9154
9155    #[test]
9156    fn test_body_preserves_static_attributes() {
9157        // The <body> tag's attributes must be carried through to the rendered
9158        // HTML, otherwise host frames (e.g. webui-press's `<body data-layout="…">`)
9159        // lose their styling hooks. Earlier versions of `process_body_with_signals`
9160        // emitted a bare `<body>` and dropped every attribute on the element.
9161        let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
9162        let (fragments, _) = parse_and_get_fragments(html);
9163
9164        assert_fragments!(
9165            fragments,
9166            [
9167                raw("<html><head>"),
9168                structural_matcher("head_start"),
9169                raw("<title>T</title>"),
9170                structural_matcher("head_end"),
9171                raw(r#"</head><body data-layout="doc" class="page">"#),
9172                structural_matcher("body_start"),
9173                raw("<p>x</p>"),
9174                structural_matcher("body_end"),
9175                raw("</body></html>"),
9176            ]
9177        );
9178    }
9179
9180    #[test]
9181    fn test_body_preserves_dynamic_attributes() {
9182        // Dynamic attributes on <body> (e.g. `data-layout="{{layout}}"`) must
9183        // emit attribute fragments rather than being inlined as literal text,
9184        // so the handler binds them to runtime state at render time.
9185        let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
9186        let (fragments, _) = parse_and_get_fragments(html);
9187
9188        assert_fragments!(
9189            fragments,
9190            [
9191                raw("<html><head>"),
9192                structural_matcher("head_start"),
9193                raw("<title>T</title>"),
9194                structural_matcher("head_end"),
9195                raw("</head><body"),
9196                attr("data-layout", "layout"),
9197                raw(">"),
9198                structural_matcher("body_start"),
9199                raw("<p>x</p>"),
9200                structural_matcher("body_end"),
9201                raw("</body></html>"),
9202            ]
9203        );
9204    }
9205
9206    #[test]
9207    fn test_head_start_signal_ordering_with_authored_script() {
9208        // `render_streaming` preflights on `head_start` before authored async
9209        // scripts in <head>, mirroring `body_start`. `head_start` must appear
9210        // immediately after the raw `<head>` opening fragment and before any
9211        // child content, including an authored `<script>`.
9212        let html = r#"<html><head><script async src="./analytics.js"></script></head><body></body></html>"#;
9213        let (fragments, _) = parse_and_get_fragments(html);
9214
9215        assert_fragments!(
9216            fragments,
9217            [
9218                raw("<html><head>"),
9219                structural_matcher("head_start"),
9220                raw(r#"<script async src="./analytics.js"></script>"#),
9221                structural_matcher("head_end"),
9222                raw("</head><body>"),
9223                structural_matcher("body_start"),
9224                structural_matcher("body_end"),
9225                raw("</body></html>"),
9226            ]
9227        );
9228    }
9229
9230    #[test]
9231    fn test_head_static_attributes_preserve_legacy_opening_bytes() {
9232        // Compatibility: origin/main emitted a literal `<head>` and ignored
9233        // authored head attributes. The structural signal must not alter that
9234        // ordinary protocol/output shape.
9235        let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
9236        let (fragments, _) = parse_and_get_fragments(html);
9237
9238        assert_fragments!(
9239            fragments,
9240            [
9241                raw("<html><head>"),
9242                structural_matcher("head_start"),
9243                raw("<title>T</title>"),
9244                structural_matcher("head_end"),
9245                raw("</head><body>"),
9246                structural_matcher("body_start"),
9247                structural_matcher("body_end"),
9248                raw("</body></html>"),
9249            ]
9250        );
9251    }
9252
9253    #[test]
9254    fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
9255        // Bound head attributes were also ignored by origin/main. In
9256        // particular, do not introduce an attribute fragment before
9257        // `head_start`, because ordinary rendering must remain byte-identical.
9258        let html =
9259            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
9260        let (fragments, _) = parse_and_get_fragments(html);
9261
9262        assert_fragments!(
9263            fragments,
9264            [
9265                raw("<html><head>"),
9266                structural_matcher("head_start"),
9267                raw("<title>T</title>"),
9268                structural_matcher("head_end"),
9269                raw("</head><body>"),
9270                structural_matcher("body_start"),
9271                structural_matcher("body_end"),
9272                raw("</body></html>"),
9273            ]
9274        );
9275    }
9276
9277    #[test]
9278    fn native_head_and_body_are_ascii_case_insensitive() {
9279        let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
9280        let (fragments, _) = parse_and_get_fragments(html);
9281
9282        assert_fragments!(
9283            fragments,
9284            [
9285                raw(r#"<html><HEAD data-theme="ignored">"#),
9286                structural_matcher("head_start"),
9287                raw("<title>T</title>"),
9288                structural_matcher("head_end"),
9289                raw("</HEAD><BODY"),
9290                attr("data-layout", "layout"),
9291                raw(">"),
9292                structural_matcher("body_start"),
9293                raw("<p>x</p>"),
9294                structural_matcher("body_end"),
9295                raw("</BODY></html>"),
9296            ]
9297        );
9298    }
9299
9300    // ── `<boundary>` / `<webui-hydrate>` tests (Progressive Streaming
9301    // Hydration) ───────────────────────────────────────────────────────────
9302
9303    #[test]
9304    fn entry_boundary_is_an_inline_start_end_tape() {
9305        let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
9306        let (fragments, records) = parse_and_get_fragments(html);
9307
9308        let boundary = match fragments[3].fragment.as_ref() {
9309            Some(Fragment::Boundary(boundary)) => boundary,
9310            other => panic!("expected typed boundary start, got {other:?}"),
9311        };
9312        assert_eq!(boundary.phase(), BoundaryPhase::Start);
9313        assert_eq!(boundary.declaration_id, 0);
9314        assert_eq!(boundary.owner_fragment_id, "index.html");
9315        assert_eq!(boundary.name, "counter-ready");
9316        assert_eq!(boundary.key, None);
9317        assert!(!boundary.may_repeat);
9318        // The body stays inline in the owner record, closed by an end marker,
9319        // so ordinary rendering never looks up a second record.
9320        assert_stream!(
9321            records,
9322            "index.html",
9323            [
9324                raw("<body>"),
9325                structural_matcher("body_start"),
9326                raw("<div>"),
9327                boundary_matcher("counter-ready", 0),
9328                raw("<my-counter></my-counter>"),
9329                boundary_end_matcher(0),
9330                raw("</div>"),
9331                structural_matcher("body_end"),
9332                raw("</body>"),
9333            ]
9334        );
9335        assert!(records["index.html"].contains_boundary);
9336        assert!(
9337            !records.contains_key("boundary-1"),
9338            "an inline tape must not create a body record"
9339        );
9340    }
9341
9342    #[test]
9343    fn boundary_names_are_scoped_per_owner_and_declaration_ids_are_protocol_wide() {
9344        let mut parser = HtmlParser::new();
9345        parser
9346            .parse(
9347                "first.html",
9348                r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
9349            )
9350            .expect("first entry parses");
9351        parser
9352            .parse(
9353                "second.html",
9354                r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
9355            )
9356            .expect("a second owner may reuse the name");
9357        let records = parser.into_fragment_records();
9358        let first = records["first.html"]
9359            .fragments
9360            .iter()
9361            .find_map(|fragment| match fragment.fragment.as_ref() {
9362                Some(Fragment::Boundary(boundary)) => Some(boundary),
9363                _ => None,
9364            })
9365            .expect("first boundary");
9366        let second = records["second.html"]
9367            .fragments
9368            .iter()
9369            .find_map(|fragment| match fragment.fragment.as_ref() {
9370                Some(Fragment::Boundary(boundary)) => Some(boundary),
9371                _ => None,
9372            })
9373            .expect("second boundary");
9374        assert_eq!((first.declaration_id, first.name.as_str()), (0, "a"));
9375        assert_eq!((second.declaration_id, second.name.as_str()), (1, "a"));
9376    }
9377
9378    #[test]
9379    fn boundary_missing_name_errors() {
9380        for html in [
9381            "<body><boundary><p>x</p></boundary></body>",
9382            r#"<body><boundary name=""><p>x</p></boundary></body>"#,
9383            r#"<body><boundary name="   "><p>x</p></boundary></body>"#,
9384        ] {
9385            let mut parser = HtmlParser::new();
9386            let err = parser
9387                .parse("index.html", html)
9388                .expect_err("a boundary without a non-empty name must error");
9389            let ParserError::Template(diag) = err else {
9390                panic!("expected ParserError::Template, got {err:?}");
9391            };
9392            assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
9393        }
9394    }
9395
9396    #[test]
9397    fn boundary_dynamic_name_errors() {
9398        let mut parser = HtmlParser::new();
9399        let err = parser
9400            .parse(
9401                "index.html",
9402                r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
9403            )
9404            .expect_err("a dynamic boundary name must error");
9405        let ParserError::Template(diag) = err else {
9406            panic!("expected ParserError::Template, got {err:?}");
9407        };
9408        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
9409    }
9410
9411    #[test]
9412    fn boundary_names_are_free_form_and_live_on_typed_fragments() {
9413        let mut parser = HtmlParser::new();
9414        let html = concat!(
9415            "<body>",
9416            r#"<boundary name="above the fold"><p>1</p></boundary>"#,
9417            r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
9418            r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
9419            "</body>",
9420        );
9421        parser
9422            .parse("index.html", html)
9423            .expect("free-form boundary names must be accepted");
9424        let records = parser.into_fragment_records();
9425        let names: Vec<&str> = records["index.html"]
9426            .fragments
9427            .iter()
9428            .filter_map(|fragment| match fragment.fragment.as_ref() {
9429                Some(Fragment::Boundary(boundary)) if boundary.phase() == BoundaryPhase::Start => {
9430                    Some(boundary.name.as_str())
9431                }
9432                _ => None,
9433            })
9434            .collect();
9435        assert_eq!(names, ["above the fold", "feed/items #2", "ダッシュボード"]);
9436    }
9437
9438    #[test]
9439    fn module_entry_srcs_records_only_preloadable_critical_entries() {
9440        let mut parser = HtmlParser::new();
9441        parser
9442            .component_registry
9443            .register_component(ComponentRegistration::new(
9444                "weather-island",
9445                r#"<script type="module" src="/nested-weather.js"></script>"#,
9446                None,
9447                true,
9448            ))
9449            .expect("register");
9450        let html = concat!(
9451            "<head>",
9452            // Recorded: a static, module-typed, non-boundary entry.
9453            r#"<script type="module" async src="/index.js"></script>"#,
9454            // Recorded: mixed case on the tag, and a padded/mixed-case `type`
9455            // value, still identify a module. (Attribute *names* are matched
9456            // case-sensitively here, as everywhere else in this parser.)
9457            r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
9458            // Skipped: classic scripts have no ES module graph to preload.
9459            r#"<script src="/legacy.js"></script>"#,
9460            // Skipped: no `src` means nothing to resolve.
9461            r#"<script type="module">import "./x.js";</script>"#,
9462            // Skipped: a per-request binding matches no build artifact.
9463            r#"<script type="module" src="/{{bundle}}.js"></script>"#,
9464            "</head>",
9465            "<body>",
9466            // Skipped: an island loader is deferred on purpose.
9467            r#"<boundary name="weather">"#,
9468            r#"<script type="module" async src="/weather-panel.js"></script>"#,
9469            "<weather-island></weather-island>",
9470            "</boundary>",
9471            // Recorded once: a bottom-of-body entry is still critical, and a
9472            // repeat of an already-seen src must not duplicate a hint.
9473            r#"<script type="module" src="/index.js"></script>"#,
9474            "</body>",
9475        );
9476        parser.parse("index.html", html).expect("parse");
9477
9478        assert_eq!(
9479            parser.module_entry_srcs(),
9480            ["/index.js", "/late.js"],
9481            "only static module entries outside boundaries are preloadable"
9482        );
9483    }
9484
9485    #[test]
9486    fn module_entry_srcs_reset_between_top_level_parses() {
9487        let mut parser = HtmlParser::new();
9488        parser
9489            .parse(
9490                "a.html",
9491                r#"<head><script type="module" src="/a.js"></script></head>"#,
9492            )
9493            .expect("parse a");
9494        assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
9495
9496        parser
9497            .parse(
9498                "b.html",
9499                r#"<head><script type="module" src="/b.js"></script></head>"#,
9500            )
9501            .expect("parse b");
9502        assert_eq!(
9503            parser.module_entry_srcs(),
9504            ["/b.js"],
9505            "each entry owns its own critical modules"
9506        );
9507    }
9508
9509    #[test]
9510    fn module_entry_srcs_preserve_low_priority_script_delivery() {
9511        let mut parser = HtmlParser::new();
9512        let html = concat!(
9513            "<head>",
9514            r#"<script type="module" async src="/streaming.js"></script>"#,
9515            r#"<script type="module" src="/low-head.js" fetchpriority="LOW"></script>"#,
9516            "</head><body>",
9517            r#"<script type="module" src="/application.js" fetchpriority="low"></script>"#,
9518            r#"<script type="module" src="/low-cased.js" FETCHPRIORITY="LoW"></script>"#,
9519            r#"<script type="module" src="/critical.js" fetchpriority="high"></script>"#,
9520            r#"<script type="module" src="/ordinary.js"></script>"#,
9521            "</body>",
9522        );
9523        parser.parse("index.html", html).expect("parse");
9524        assert_eq!(
9525            parser.module_entry_srcs(),
9526            ["/streaming.js", "/critical.js", "/ordinary.js"],
9527            "low-priority application chunks must not be pulled into head preloads"
9528        );
9529        let records = parser.into_fragment_records();
9530        let raw = records["index.html"]
9531            .fragments
9532            .iter()
9533            .filter_map(|fragment| match fragment.fragment.as_ref() {
9534                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
9535                _ => None,
9536            })
9537            .collect::<String>();
9538        assert!(
9539            raw.contains(r#"src="/application.js" fetchpriority="low""#),
9540            "excluding preloads must preserve the authored script"
9541        );
9542    }
9543
9544    #[test]
9545    fn boundary_duplicate_name_errors() {
9546        let mut parser = HtmlParser::new();
9547        let html = concat!(
9548            "<body>",
9549            r#"<boundary name="dup"><p>1</p></boundary>"#,
9550            r#"<boundary name="dup"><p>2</p></boundary>"#,
9551            "</body>",
9552        );
9553        let err = parser
9554            .parse("index.html", html)
9555            .expect_err("a duplicate boundary name must error");
9556        let ParserError::Template(diag) = err else {
9557            panic!("expected ParserError::Template, got {err:?}");
9558        };
9559        assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
9560    }
9561
9562    #[test]
9563    fn boundary_nested_boundary_errors() {
9564        let mut parser = HtmlParser::new();
9565        let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
9566        let err = parser
9567            .parse("index.html", html)
9568            .expect_err("a nested boundary must error");
9569        let ParserError::Template(diag) = err else {
9570            panic!("expected ParserError::Template, got {err:?}");
9571        };
9572        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
9573    }
9574
9575    #[test]
9576    fn boundary_transitively_nested_through_component_errors() {
9577        let mut parser = HtmlParser::new();
9578        parser
9579            .component_registry
9580            .register_component(ComponentRegistration::new(
9581                "inner-panel",
9582                r#"<boundary name="inner"><p>x</p></boundary>"#,
9583                None,
9584                true,
9585            ))
9586            .expect("register");
9587        let err = parser
9588            .parse(
9589                "index.html",
9590                r#"<body><boundary name="outer"><inner-panel></inner-panel></boundary></body>"#,
9591            )
9592            .expect_err("transitively nested boundaries must error");
9593        let ParserError::Template(diag) = err else {
9594            panic!("expected ParserError::Template, got {err:?}");
9595        };
9596        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
9597        assert!(diag
9598            .help_text()
9599            .is_some_and(|help| help.contains("runtime branches")));
9600    }
9601
9602    #[test]
9603    fn boundary_inside_if_preserves_false_branch_structure() {
9604        let mut parser = HtmlParser::new();
9605        let html =
9606            r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
9607        parser.parse("index.html", html).expect("parse");
9608        let records = parser.into_fragment_records();
9609        let if_fragment_id = records["index.html"]
9610            .fragments
9611            .iter()
9612            .find_map(|fragment| match fragment.fragment.as_ref() {
9613                Some(Fragment::IfCond(if_cond)) => Some(if_cond.fragment_id.as_str()),
9614                _ => None,
9615            })
9616            .expect("if reference");
9617        let boundary = records[if_fragment_id]
9618            .fragments
9619            .iter()
9620            .find_map(|fragment| match fragment.fragment.as_ref() {
9621                Some(Fragment::Boundary(boundary)) => Some(boundary),
9622                _ => None,
9623            })
9624            .expect("boundary remains behind the runtime if");
9625        assert_eq!(boundary.owner_fragment_id, "index.html");
9626        assert!(!boundary.may_repeat);
9627        assert!(records["index.html"].contains_boundary);
9628        assert!(records[if_fragment_id].contains_boundary);
9629        assert_stream!(
9630            records,
9631            if_fragment_id,
9632            [
9633                boundary_matcher("x", 0),
9634                raw("<p>x</p>"),
9635                boundary_end_matcher(0),
9636            ]
9637        );
9638    }
9639
9640    #[test]
9641    fn boundary_directly_inside_for_is_rejected() {
9642        let mut parser = HtmlParser::new();
9643        let err = parser
9644            .parse(
9645                "index.html",
9646                r#"<body><for each="item in items"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></for></body>"#,
9647            )
9648            .expect_err("a boundary inside a repeat must error");
9649        let ParserError::Template(diag) = err else {
9650            panic!("expected ParserError::Template, got {err:?}");
9651        };
9652        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9653        assert!(
9654            diag.help_text()
9655                .is_some_and(|help| help.contains("wrap the whole <for>")),
9656            "{diag}"
9657        );
9658    }
9659
9660    #[test]
9661    fn boundary_under_if_inside_for_is_rejected() {
9662        let mut parser = HtmlParser::new();
9663        let err = parser
9664            .parse(
9665                "index.html",
9666                r#"<body><for each="item in items"><if condition="item.ready"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></if></for></body>"#,
9667            )
9668            .expect_err("a boundary behind a runtime branch in a repeat must error");
9669        let ParserError::Template(diag) = err else {
9670            panic!("expected ParserError::Template, got {err:?}");
9671        };
9672        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9673    }
9674
9675    #[test]
9676    fn component_boundary_used_inside_for_is_rejected() {
9677        let mut parser = HtmlParser::new();
9678        parser
9679            .component_registry
9680            .register_component(ComponentRegistration::new(
9681                "row-item",
9682                r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9683                None,
9684                true,
9685            ))
9686            .expect("register");
9687        let err = parser
9688            .parse(
9689                "index.html",
9690                r#"<body><for each="item in items"><row-item></row-item></for></body>"#,
9691            )
9692            .expect_err("a component-local boundary reached from a repeat must error");
9693        let ParserError::Template(diag) = err else {
9694            panic!("expected ParserError::Template, got {err:?}");
9695        };
9696        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9697        // The diagnostic names both ends of the chain: the repeat that would
9698        // execute it and the template that declares it.
9699        assert!(diag.to_string().contains("row-ready"), "{diag}");
9700        assert_eq!(diag.component_name(), Some("index.html"));
9701        assert!(diag.to_string().contains("item in items"), "{diag}");
9702    }
9703
9704    #[test]
9705    fn transitive_repeat_boundary_diagnostic_uses_lowest_repeat_site() {
9706        for _ in 0..20 {
9707            let mut parser = HtmlParser::new();
9708            for (tag, template) in [
9709                (
9710                    "left-list",
9711                    r#"<for each="a in xs"><row-card></row-card></for>"#,
9712                ),
9713                (
9714                    "right-list",
9715                    r#"<for each="b in ys"><row-card></row-card></for>"#,
9716                ),
9717                (
9718                    "row-card",
9719                    r#"<boundary name="row-ready"><p>row</p></boundary>"#,
9720                ),
9721            ] {
9722                parser
9723                    .component_registry
9724                    .register_component(ComponentRegistration::new(tag, template, None, true))
9725                    .expect("register");
9726            }
9727            let err = parser
9728                .parse(
9729                    "index.html",
9730                    r#"<body><left-list></left-list><right-list></right-list></body>"#,
9731                )
9732                .expect_err("both repeats reach the same boundary declaration");
9733            let ParserError::Template(diag) = err else {
9734                panic!("expected ParserError::Template, got {err:?}");
9735            };
9736            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9737            assert_eq!(diag.component_name(), Some("left-list"));
9738            assert!(diag.to_string().contains("a in xs"), "{diag}");
9739        }
9740    }
9741
9742    #[test]
9743    fn route_boundary_reached_from_for_is_rejected() {
9744        let mut parser = HtmlParser::with_options(DomStrategy::Light);
9745        parser
9746            .component_registry
9747            .register_component(ComponentRegistration::new(
9748                "row-page",
9749                r#"<boundary name="row-ready"><p>row</p></boundary>"#,
9750                None,
9751                true,
9752            ))
9753            .expect("register");
9754        let err = parser
9755            .parse(
9756                "index.html",
9757                r#"<body><for each="item in items"><route path="/" component="row-page"></route></for></body>"#,
9758            )
9759            .expect_err("a route-mounted boundary inside a repeat must error");
9760        let ParserError::Template(diag) = err else {
9761            panic!("expected ParserError::Template, got {err:?}");
9762        };
9763        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9764    }
9765
9766    #[test]
9767    fn outlet_mounted_boundary_reached_from_for_is_rejected() {
9768        // The child route renders at the parent component's <outlet />, which
9769        // the repeat body reaches through <shell-page>. That edge is a runtime
9770        // mount with no lexical nesting, so only the mount analysis catches it.
9771        let mut parser = HtmlParser::with_options(DomStrategy::Light);
9772        for (tag, template) in [
9773            (
9774                "shell-page",
9775                r#"<div><for each="item in items"><outlet></outlet></for></div>"#,
9776            ),
9777            (
9778                "child-page",
9779                r#"<boundary name="child-ready"><p>child</p></boundary>"#,
9780            ),
9781        ] {
9782            parser
9783                .component_registry
9784                .register_component(ComponentRegistration::new(tag, template, None, true))
9785                .expect("register");
9786        }
9787        let err = parser
9788            .parse(
9789                "index.html",
9790                r#"<body><route path="/" component="shell-page"><route path="child" component="child-page"></route></route></body>"#,
9791            )
9792            .expect_err("an outlet-mounted boundary inside a repeat must error");
9793        let ParserError::Template(diag) = err else {
9794            panic!("expected ParserError::Template, got {err:?}");
9795        };
9796        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9797        assert!(diag.to_string().contains("child-ready"), "{diag}");
9798    }
9799
9800    #[test]
9801    fn for_inside_one_boundary_is_allowed_and_stays_atomic() {
9802        // The whole finite list is one atomic checkpoint, so the repeat body
9803        // record must not be marked as carrying a boundary of its own.
9804        let mut parser = HtmlParser::new();
9805        parser
9806            .parse(
9807                "index.html",
9808                r#"<body><boundary name="feed"><for each="item in items"><p>{{item.label}}</p></for></boundary></body>"#,
9809            )
9810            .expect("a repeat inside one boundary parses");
9811        let records = parser.into_fragment_records();
9812        assert!(records["index.html"].contains_boundary);
9813        assert!(
9814            !records["for-1"].contains_boundary,
9815            "the repeat body itself declares no boundary"
9816        );
9817        let boundary = records["index.html"]
9818            .fragments
9819            .iter()
9820            .find_map(|fragment| match fragment.fragment.as_ref() {
9821                Some(Fragment::Boundary(boundary)) => Some(boundary),
9822                _ => None,
9823            })
9824            .expect("boundary");
9825        assert!(!boundary.may_repeat);
9826    }
9827
9828    #[test]
9829    fn boundary_after_for_is_allowed() {
9830        let mut parser = HtmlParser::new();
9831        parser
9832            .parse(
9833                "index.html",
9834                r#"<body><for each="item in items"><p>{{item.label}}</p></for><boundary name="tail"><p>tail</p></boundary></body>"#,
9835            )
9836            .expect("a boundary following a repeat parses");
9837        let records = parser.into_fragment_records();
9838        assert!(records["index.html"].contains_boundary);
9839        assert!(!records["for-1"].contains_boundary);
9840    }
9841
9842    #[test]
9843    fn boundary_free_for_still_renders_without_boundary_metadata() {
9844        let mut parser = HtmlParser::new();
9845        parser
9846            .parse(
9847                "index.html",
9848                r#"<body><for each="item in items"><p>{{item.label}}</p></for></body>"#,
9849            )
9850            .expect("an ordinary repeat parses");
9851        let records = parser.into_fragment_records();
9852        assert!(!records["index.html"].contains_boundary);
9853        assert!(!records["for-1"].contains_boundary);
9854    }
9855
9856    #[test]
9857    fn empty_boundary_key_is_still_rejected() {
9858        let mut parser = HtmlParser::new();
9859        let err = parser
9860            .parse(
9861                "invalid.html",
9862                r#"<body><boundary name="x" key=""><p>x</p></boundary></body>"#,
9863            )
9864            .expect_err("an empty key must error");
9865        let ParserError::Template(diag) = err else {
9866            panic!("expected ParserError::Template, got {err:?}");
9867        };
9868        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_KEY));
9869    }
9870
9871    #[test]
9872    fn boundary_inside_reusable_component_is_component_local() {
9873        let mut parser = HtmlParser::new();
9874        parser
9875            .component_registry
9876            .register_component(ComponentRegistration::new(
9877                "my-widget",
9878                r#"<boundary name="x"><p>x</p></boundary>"#,
9879                None,
9880                true,
9881            ))
9882            .expect("register");
9883
9884        parser
9885            .parse(
9886                "index.html",
9887                r#"<body><my-widget></my-widget><boundary name="x"><p>entry</p></boundary></body>"#,
9888            )
9889            .expect("component-local boundary parses");
9890        let records = parser.into_fragment_records();
9891        let boundary = records["my-widget"]
9892            .fragments
9893            .iter()
9894            .find_map(|fragment| match fragment.fragment.as_ref() {
9895                Some(Fragment::Boundary(boundary)) => Some(boundary),
9896                _ => None,
9897            })
9898            .expect("component boundary");
9899        assert_eq!(boundary.owner_fragment_id, "my-widget");
9900        assert_eq!(boundary.name, "x");
9901        assert!(records["my-widget"].contains_boundary);
9902        assert!(records["index.html"].contains_boundary);
9903        let owners: HashSet<&str> = records
9904            .values()
9905            .flat_map(|list| list.fragments.iter())
9906            .filter_map(|fragment| match fragment.fragment.as_ref() {
9907                Some(Fragment::Boundary(boundary)) if boundary.name == "x" => {
9908                    Some(boundary.owner_fragment_id.as_str())
9909                }
9910                _ => None,
9911            })
9912            .collect();
9913        assert_eq!(owners, HashSet::from(["index.html", "my-widget"]));
9914    }
9915
9916    #[test]
9917    fn component_boundary_repeated_from_static_callsites_requires_a_key() {
9918        // `<for>` can no longer produce multiple occurrences, but two static
9919        // callsites of the same boundary-bearing component still can, so the
9920        // declaration keeps its key requirement.
9921        let mut parser = HtmlParser::new();
9922        parser
9923            .component_registry
9924            .register_component(ComponentRegistration::new(
9925                "row-item",
9926                r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9927                None,
9928                true,
9929            ))
9930            .expect("register");
9931        let err = parser
9932            .parse(
9933                "index.html",
9934                r#"<body><row-item></row-item><row-item></row-item></body>"#,
9935            )
9936            .expect_err("a repeated declaration without a key must error");
9937        let ParserError::Template(diag) = err else {
9938            panic!("expected ParserError::Template, got {err:?}");
9939        };
9940        assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_KEY));
9941        assert_eq!(diag.component_name(), Some("row-item"));
9942
9943        let mut parser = HtmlParser::new();
9944        parser
9945            .component_registry
9946            .register_component(ComponentRegistration::new(
9947                "row-item",
9948                r#"<boundary name="row-ready" key="{{rowId}}"><span>{{label}}</span></boundary>"#,
9949                None,
9950                true,
9951            ))
9952            .expect("register");
9953        parser
9954            .parse(
9955                "index.html",
9956                r#"<body><row-item row-id="a"></row-item><row-item row-id="b"></row-item></body>"#,
9957            )
9958            .expect("a keyed repeated declaration parses");
9959        let records = parser.into_fragment_records();
9960        let boundary = records["row-item"]
9961            .fragments
9962            .iter()
9963            .find_map(|fragment| match fragment.fragment.as_ref() {
9964                Some(Fragment::Boundary(boundary)) => Some(boundary),
9965                _ => None,
9966            })
9967            .expect("component boundary");
9968        assert!(boundary.may_repeat);
9969        assert_eq!(boundary.key.as_deref(), Some("{{rowId}}"));
9970    }
9971
9972    #[test]
9973    fn component_boundary_once_per_independent_entry_is_not_repeatable() {
9974        let mut parser = HtmlParser::new();
9975        parser
9976            .component_registry
9977            .register_component(ComponentRegistration::new(
9978                "shared-panel",
9979                r#"<boundary name="ready"><span>x</span></boundary>"#,
9980                None,
9981                true,
9982            ))
9983            .expect("register");
9984        for entry in ["first.html", "second.html"] {
9985            parser
9986                .parse(entry, "<body><shared-panel></shared-panel></body>")
9987                .expect("entry parses");
9988        }
9989        let records = parser.into_fragment_records();
9990        let boundary = records["shared-panel"]
9991            .fragments
9992            .iter()
9993            .find_map(|fragment| match fragment.fragment.as_ref() {
9994                Some(Fragment::Boundary(boundary)) => Some(boundary),
9995                _ => None,
9996            })
9997            .expect("component boundary");
9998        assert!(!boundary.may_repeat);
9999    }
10000
10001    #[test]
10002    fn boundary_inside_component_host_content_errors() {
10003        let mut parser = HtmlParser::new();
10004        parser
10005            .component_registry
10006            .register_component(ComponentRegistration::new(
10007                "my-widget",
10008                "<div>content</div>",
10009                None,
10010                true,
10011            ))
10012            .expect("register");
10013
10014        let err = parser
10015            .parse(
10016                "index.html",
10017                r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
10018            )
10019            .expect_err("a boundary inside component host content must error");
10020        let ParserError::Template(diag) = err else {
10021            panic!("expected ParserError::Template, got {err:?}");
10022        };
10023        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
10024        assert!(
10025            diag.to_string().contains("component host content"),
10026            "{diag}"
10027        );
10028    }
10029
10030    #[test]
10031    fn boundary_inside_raw_or_inert_html_context_errors() {
10032        for (element, expected_scope) in [
10033            ("textarea", "<textarea> text content"),
10034            ("title", "<title> text content"),
10035            ("script", "<script> raw-text content"),
10036            ("xmp", "<xmp> raw-text content"),
10037            ("iframe", "<iframe> raw-text content"),
10038            ("noembed", "<noembed> raw-text content"),
10039            ("noframes", "<noframes> raw-text content"),
10040            ("noscript", "<noscript> inert content"),
10041            ("plaintext", "<plaintext> text content"),
10042            ("template", "<template> inert content"),
10043        ] {
10044            let mut parser = HtmlParser::new();
10045            let html = format!(
10046                r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
10047            );
10048            let err = parser
10049                .parse("index.html", &html)
10050                .expect_err("a boundary in raw or inert content must error");
10051            let ParserError::Template(diag) = err else {
10052                panic!("expected ParserError::Template, got {err:?}");
10053            };
10054            assert_eq!(
10055                diag.error_code(),
10056                Some(codes::BOUNDARY_CROSSES_SCOPE),
10057                "unexpected code for <{element}>"
10058            );
10059            assert!(
10060                diag.to_string().contains(expected_scope),
10061                "unexpected scope for <{element}>: {diag}"
10062            );
10063        }
10064    }
10065
10066    #[test]
10067    fn route_contained_boundary_has_runtime_content_fragment() {
10068        let mut parser = HtmlParser::with_options(DomStrategy::Light);
10069        parser
10070            .component_registry
10071            .register_component(ComponentRegistration::new(
10072                "home-page",
10073                "<h1>Home</h1>",
10074                None,
10075                true,
10076            ))
10077            .expect("register");
10078        parser
10079            .parse(
10080                "index.html",
10081                r#"<route path="/" component="home-page"><boundary name="route-ready"><aside>Ready</aside></boundary></route>"#,
10082            )
10083            .expect("route-contained boundary parses");
10084        let records = parser.into_fragment_records();
10085        assert!(records["index.html"].contains_boundary);
10086        let route = records["index.html"]
10087            .fragments
10088            .iter()
10089            .find_map(|fragment| match fragment.fragment.as_ref() {
10090                Some(Fragment::Route(route)) => Some(route),
10091                _ => None,
10092            })
10093            .expect("route");
10094        assert!(!route.content_fragment_id.is_empty());
10095        let boundary = records[&route.content_fragment_id]
10096            .fragments
10097            .iter()
10098            .find_map(|fragment| match fragment.fragment.as_ref() {
10099                Some(Fragment::Boundary(boundary)) => Some(boundary),
10100                _ => None,
10101            })
10102            .expect("route boundary");
10103        assert_eq!(boundary.owner_fragment_id, "index.html");
10104        assert_eq!(boundary.name, "route-ready");
10105        assert_stream!(
10106            records,
10107            route.content_fragment_id.as_str(),
10108            [
10109                boundary_matcher("route-ready", 0),
10110                raw("<aside>Ready</aside>"),
10111                boundary_end_matcher(0),
10112            ]
10113        );
10114    }
10115
10116    #[test]
10117    fn route_boundary_nested_in_ignored_markup_errors() {
10118        let mut parser = HtmlParser::new();
10119        let err = parser
10120            .parse(
10121                "index.html",
10122                r#"<route path="/" component="home-page"><div><boundary name="late"><p>x</p></boundary></div></route>"#,
10123            )
10124            .expect_err("route boundaries must be direct children");
10125        let ParserError::Template(diag) = err else {
10126            panic!("expected ParserError::Template, got {err:?}");
10127        };
10128        assert_eq!(
10129            diag.error_code(),
10130            Some(codes::INVALID_ROUTE_BOUNDARY_PLACEMENT)
10131        );
10132    }
10133
10134    #[test]
10135    fn boundary_must_be_inside_an_open_body() {
10136        for html in [
10137            r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
10138            r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
10139        ] {
10140            let mut parser = HtmlParser::new();
10141            let err = parser
10142                .parse("index.html", html)
10143                .expect_err("a boundary outside body must error");
10144            let ParserError::Template(diag) = err else {
10145                panic!("expected ParserError::Template, got {err:?}");
10146            };
10147            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
10148            assert_eq!(
10149                diag.help_text(),
10150                Some("move the <boundary> between the opening <body> and its matching </body>")
10151            );
10152        }
10153
10154        let mut parser = HtmlParser::new();
10155        parser
10156            .parse(
10157                "index.html",
10158                r#"<BODY><boundary name="inside"></boundary></BODY>"#,
10159            )
10160            .expect("mixed-case native body keeps the boundary inside an open body");
10161    }
10162
10163    #[test]
10164    fn boundary_inside_foster_parenting_context_errors() {
10165        // Each of these insertion modes moves an unknown element (the
10166        // generated <webui-hydrate> sentinel) out of the subtree, which would
10167        // split it from its payload script and halt hydration at runtime.
10168        for html in [
10169            r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
10170            r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
10171            r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
10172            r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
10173            r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
10174            r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
10175            r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
10176            r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
10177            // Mixed case must be rejected identically: HTML tag names are
10178            // case-insensitive, so the browser foster-parents these too.
10179            r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
10180        ] {
10181            let mut parser = HtmlParser::new();
10182            let err = parser
10183                .parse("index.html", html)
10184                .expect_err("a boundary in a foster-parenting context must error");
10185            let ParserError::Template(diag) = err else {
10186                panic!("expected ParserError::Template, got {err:?}");
10187            };
10188            assert_eq!(
10189                diag.error_code(),
10190                Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
10191                "unexpected code for {html}"
10192            );
10193            assert!(
10194                diag.help_text()
10195                    .is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
10196                "help should suggest a safe placement for {html}"
10197            );
10198        }
10199    }
10200
10201    #[test]
10202    fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
10203        // <td>/<th>/<caption> switch back to "in body" rules, so an unknown
10204        // element is *not* foster-parented and the boundary is safe even
10205        // though <table>/<tr> ancestors are hostile.
10206        for html in [
10207            r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
10208            r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
10209            r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
10210            // Wrapping the whole table is the other documented fix.
10211            r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
10212            // A <td> barrier resets the depth, an inner table raises it again,
10213            // and closing that inner table must restore the barrier's zero —
10214            // not the outer table's non-zero depth.
10215            concat!(
10216                "<body><table><tbody><tr><td>",
10217                "<table><tbody><tr><td>inner</td></tr></tbody></table>",
10218                r#"<boundary name="after-inner"><p>x</p></boundary>"#,
10219                "</td></tr></tbody></table></body>",
10220            ),
10221            // Void and self-closing elements must not leak restore ops.
10222            r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
10223        ] {
10224            let mut parser = HtmlParser::new();
10225            parser
10226                .parse("index.html", html)
10227                .unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
10228        }
10229    }
10230
10231    #[test]
10232    fn foster_context_depth_is_restored_after_leaving_a_table() {
10233        // Regression guard for the save/restore op: a boundary *after* a table
10234        // must not inherit the table's hostile depth.
10235        let mut parser = HtmlParser::new();
10236        parser
10237            .parse(
10238                "index.html",
10239                concat!(
10240                    "<body>",
10241                    "<table><tbody><tr><td>x</td></tr></tbody></table>",
10242                    r#"<boundary name="after-table"><p>y</p></boundary>"#,
10243                    "</body>",
10244                ),
10245            )
10246            .expect("a boundary after a closed table must be accepted");
10247    }
10248
10249    #[test]
10250    fn authored_raw_bindings_cannot_forge_structural_signals() {
10251        let html = concat!(
10252            "<html><head></head><body>",
10253            "{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
10254            "{{{boundary_start:0}}}{{{boundary_end:0}}}",
10255            "{{{streaming_root:forged}}}",
10256            "</body></html>",
10257        );
10258        let (fragments, _) = parse_and_get_fragments(html);
10259        let raw_signals: Vec<&str> = fragments
10260            .iter()
10261            .filter_map(|fragment| match fragment.fragment.as_ref() {
10262                Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
10263                _ => None,
10264            })
10265            .collect();
10266
10267        assert_eq!(
10268            raw_signals,
10269            [
10270                "}}}webui:head_start",
10271                "}}}webui:head_end",
10272                "}}}webui:body_start",
10273                "head_start",
10274                "head_end",
10275                "body_start",
10276                "body_end",
10277                "boundary_start:0",
10278                "boundary_end:0",
10279                "streaming_root:forged",
10280                "}}}webui:body_end",
10281            ]
10282        );
10283    }
10284
10285    #[test]
10286    fn authored_webui_hydrate_errors_at_top_level() {
10287        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
10288            let mut parser = HtmlParser::new();
10289            let html = format!("<div><{tag}></{tag}></div>");
10290            let err = parser
10291                .parse("index.html", &html)
10292                .expect_err("an authored <webui-hydrate> must error in any ASCII casing");
10293            let ParserError::Template(diag) = err else {
10294                panic!("expected ParserError::Template, got {err:?}");
10295            };
10296            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
10297        }
10298    }
10299
10300    #[test]
10301    fn authored_webui_hydrate_errors_inside_route() {
10302        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
10303            let mut parser = HtmlParser::new();
10304            let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
10305            let err = parser
10306                .parse("index.html", &html)
10307                .expect_err("an authored <webui-hydrate> inside <route> must error");
10308            let ParserError::Template(diag) = err else {
10309                panic!("expected ParserError::Template, got {err:?}");
10310            };
10311            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
10312        }
10313    }
10314
10315    #[test]
10316    fn boundary_directive_remains_case_sensitive() {
10317        let (fragments, _) =
10318            parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
10319
10320        assert_fragments!(
10321            fragments,
10322            [raw(
10323                r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
10324            ),]
10325        );
10326    }
10327
10328    #[test]
10329    fn templates_without_webui_boundary_are_unaffected() {
10330        // Regression: ordinary templates with no `<boundary>` directive
10331        // must parse exactly as before — no boundary signals, no behavior
10332        // change for the non-streaming path.
10333        let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
10334        let (fragments, _) = parse_and_get_fragments(html);
10335
10336        assert_fragments!(
10337            fragments,
10338            [
10339                raw("<div><my-counter"),
10340                attr("count", "count"),
10341                raw("></my-counter></div>"),
10342            ]
10343        );
10344    }
10345
10346    #[test]
10347    fn test_fail_with_invalid_markup() {
10348        // Port of: 'should fail with invalid markup'
10349        let mut parser = HtmlParser::new();
10350        let result = parser.parse("index.html", "<div><span></div></span>");
10351
10352        assert!(matches!(result, Err(ParserError::Template(diag)) if
10353            diag.to_string().contains("unclosed <span> tag")
10354        ));
10355    }
10356
10357    #[test]
10358    fn test_complex_raw_text_page() {
10359        // Port of: 'should process a complex raw text page with DOCTYPE,
10360        // meta tags, styles, and scripts'
10361        let html = concat!(
10362            "<!DOCTYPE html>",
10363            "<html lang=\"en\">",
10364            "<head>",
10365            "<meta charset=\"utf-8\">",
10366            "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
10367            "<title>Complex Page</title>",
10368            "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
10369            "<link rel=\"stylesheet\" href=\"styles.css\">",
10370            "</head>",
10371            "<body>",
10372            "<h1>Hello World</h1>",
10373            "<script type=\"module\" src=\"./app.js\"></script>",
10374            "</body>",
10375            "</html>",
10376        );
10377        let (fragments, _) = parse_and_get_fragments(html);
10378
10379        // Should have: raw(DOCTYPE+<head>), head_start, raw(head content),
10380        // head_end, raw(</head><body>), body_start, raw(body content),
10381        // body_end, raw(</body></html>)
10382        assert!(
10383            fragments.len() >= 9,
10384            "Expected at least 9 fragments, got {}",
10385            fragments.len()
10386        );
10387
10388        // First fragment: DOCTYPE through the opening <head> tag
10389        assert!(
10390            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10391                raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
10392            "First fragment should contain DOCTYPE and end with <head>, got: {:?}",
10393            fragments[0]
10394        );
10395
10396        // head_start signal
10397        assert!(
10398            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
10399                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
10400            "Second fragment should be head_start signal"
10401        );
10402
10403        // Head content (meta, title, style, link)
10404        assert!(
10405            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10406                raw.value.contains("<meta charset=\"utf-8\">") &&
10407                raw.value.contains("<meta name=\"viewport\"") &&
10408                raw.value.contains("<title>Complex Page</title>") &&
10409                raw.value.contains("<style>") &&
10410                raw.value.contains("body { margin: 0; padding: 0; }")),
10411            "Third fragment should contain all head content, got: {:?}",
10412            fragments[2]
10413        );
10414
10415        // head_end signal
10416        assert!(
10417            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
10418                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
10419            "Fourth fragment should be head_end signal"
10420        );
10421
10422        // </head><body>
10423        assert!(
10424            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10425                raw.value.contains("</head>") && raw.value.ends_with("<body>")),
10426            "Fifth fragment should contain </head><body>"
10427        );
10428
10429        // body_start signal
10430        assert!(
10431            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
10432                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
10433            "Sixth fragment should be body_start signal"
10434        );
10435
10436        // Body content (h1 and script)
10437        assert!(
10438            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10439                raw.value.contains("<h1>Hello World</h1>") &&
10440                raw.value.contains("<script")),
10441            "Seventh fragment should contain body content"
10442        );
10443
10444        // body_end signal
10445        assert!(
10446            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
10447                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
10448            "Eighth fragment should be body_end signal"
10449        );
10450
10451        // Closing tags
10452        assert!(
10453            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10454                raw.value.contains("</body>") && raw.value.contains("</html>")),
10455            "Ninth fragment should contain closing tags"
10456        );
10457    }
10458
10459    // --- Binding count tests (with mock plugin) ---
10460
10461    /// Mock plugin that records the binding attribute count for each element.
10462    struct BindingCountPlugin {
10463        counts: Vec<u32>,
10464    }
10465
10466    impl BindingCountPlugin {
10467        fn new() -> Self {
10468            Self { counts: Vec::new() }
10469        }
10470    }
10471
10472    impl crate::plugin::ParserPlugin for BindingCountPlugin {
10473        fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
10474            let attr_name = context.name;
10475            if attr_name.starts_with('@') || attr_name == "f-ref" {
10476                AttributeAction::SkipAndCountBinding
10477            } else {
10478                AttributeAction::Keep
10479            }
10480        }
10481
10482        fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
10483            let binding_attribute_count = context.binding_count;
10484            self.counts.push(binding_attribute_count);
10485            if binding_attribute_count > 0 {
10486                Some(binding_attribute_count.to_le_bytes().to_vec())
10487            } else {
10488                None
10489            }
10490        }
10491    }
10492
10493    struct TemplateCapturePlugin {
10494        template: Option<String>,
10495        uses_shadow_dom: bool,
10496    }
10497
10498    impl TemplateCapturePlugin {
10499        fn new() -> Self {
10500            Self {
10501                template: None,
10502                uses_shadow_dom: false,
10503            }
10504        }
10505    }
10506
10507    impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
10508        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
10509            self.template = Some(context.template.to_string());
10510            self.uses_shadow_dom = context.uses_shadow_dom;
10511            Ok(())
10512        }
10513
10514        fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
10515            let attr_name = context.name;
10516            if attr_name.starts_with('@') || attr_name == "f-ref" {
10517                AttributeAction::SkipAndCountBinding
10518            } else {
10519                AttributeAction::Keep
10520            }
10521        }
10522
10523        fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
10524            match self.template {
10525                Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
10526                    crate::plugin::ComponentTemplateArtifact::template(
10527                        "todo-app".to_string(),
10528                        template,
10529                        self.uses_shadow_dom,
10530                    ),
10531                ])),
10532                None => Ok(ParserPluginArtifacts::None),
10533            }
10534        }
10535    }
10536
10537    #[test]
10538    fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
10539        let mut parser = HtmlParser::with_plugin_options(
10540            Box::new(TemplateCapturePlugin::new()),
10541            CssStrategy::Link,
10542        );
10543        parser
10544            .component_registry_mut()
10545            .register_component(ComponentRegistration::new(
10546                "todo-app",
10547                r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
10548                Some(":host { display: block; }"),
10549             true,))
10550            .expect("register todo-app");
10551
10552        parser
10553            .parse("index.html", "<todo-app></todo-app>")
10554            .expect("parse failed");
10555
10556        let artifacts = parser.take_plugin_artifacts().expect("artifacts");
10557        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10558            panic!("expected captured component template");
10559        };
10560        let template = &templates[0].template;
10561
10562        assert!(
10563            template.contains(r#"shadowrootmode="open""#),
10564            "parser must pass dev-authored root template attrs to plugins, got: {template}"
10565        );
10566        assert!(
10567            template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
10568            "parser must pass dev-authored root event attrs to plugins, got: {template}"
10569        );
10570        assert!(
10571            template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
10572            "parser must pass dev-authored root event attrs to plugins, got: {template}"
10573        );
10574        assert!(
10575            template.contains(r#"f-ref="{root}""#),
10576            "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
10577        );
10578        assert!(
10579            !template.contains("shadowrootadoptedstylesheets"),
10580            "link CSS must not add CSS module adoption attrs, got: {template}"
10581        );
10582    }
10583
10584    #[test]
10585    fn test_component_static_attrs_not_counted_as_bindings() {
10586        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10587        parser
10588            .component_registry
10589            .register_component(ComponentRegistration::new(
10590                "my-btn",
10591                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10592                None,
10593                true,
10594            ))
10595            .expect("register");
10596
10597        // All attributes are static — binding count should be 0
10598        let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
10599        let result = parser.parse("index.html", html);
10600        assert!(result.is_ok());
10601
10602        let records = parser.into_fragment_records();
10603
10604        // No Plugin fragment should appear (binding count = 0)
10605        let frags = &records["index.html"].fragments;
10606        let plugin_count = frags
10607            .iter()
10608            .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
10609            .count();
10610        assert_eq!(
10611            plugin_count, 0,
10612            "Static-only component attributes should not emit a Plugin fragment"
10613        );
10614    }
10615
10616    #[test]
10617    fn test_component_dynamic_attr_counted_as_binding() {
10618        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10619        parser
10620            .component_registry
10621            .register_component(ComponentRegistration::new(
10622                "my-btn",
10623                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10624                None,
10625                true,
10626            ))
10627            .expect("register");
10628
10629        // One dynamic attribute ({{...}}) — binding count should be 1
10630        let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
10631        let result = parser.parse("index.html", html);
10632        assert!(result.is_ok());
10633
10634        let records = parser.into_fragment_records();
10635        let frags = &records["index.html"].fragments;
10636
10637        // Exactly one Plugin fragment with count = 1
10638        let plugin_frags: Vec<_> = frags
10639            .iter()
10640            .filter_map(|f| match f.fragment.as_ref() {
10641                Some(Fragment::Plugin(p)) => Some(&p.data),
10642                _ => None,
10643            })
10644            .collect();
10645        assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
10646        let count = u32::from_le_bytes([
10647            plugin_frags[0][0],
10648            plugin_frags[0][1],
10649            plugin_frags[0][2],
10650            plugin_frags[0][3],
10651        ]);
10652        assert_eq!(count, 1, "Binding attribute count should be 1");
10653    }
10654
10655    #[test]
10656    fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
10657        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10658        parser
10659            .component_registry
10660            .register_component(ComponentRegistration::new(
10661                "my-btn",
10662                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10663                None,
10664                true,
10665            ))
10666            .expect("register");
10667
10668        // 2 static, 1 dynamic, 1 skipped-with-plugin (@click) — only dynamic + skipped counted
10669        let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
10670        let result = parser.parse("index.html", html);
10671        assert!(result.is_ok());
10672
10673        let records = parser.into_fragment_records();
10674        let frags = &records["index.html"].fragments;
10675
10676        let plugin_frags: Vec<_> = frags
10677            .iter()
10678            .filter_map(|f| match f.fragment.as_ref() {
10679                Some(Fragment::Plugin(p)) => Some(&p.data),
10680                _ => None,
10681            })
10682            .collect();
10683        assert_eq!(plugin_frags.len(), 1);
10684        let count = u32::from_le_bytes([
10685            plugin_frags[0][0],
10686            plugin_frags[0][1],
10687            plugin_frags[0][2],
10688            plugin_frags[0][3],
10689        ]);
10690        // 1 dynamic (appearance) + 1 skipped-but-counted (@click) = 2
10691        assert_eq!(
10692            count, 2,
10693            "Binding count should include dynamic + plugin-skipped attrs, not static"
10694        );
10695    }
10696
10697    #[test]
10698    fn test_component_only_skipped_plugin_attrs_counted() {
10699        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10700        parser
10701            .component_registry
10702            .register_component(ComponentRegistration::new(
10703                "my-btn",
10704                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10705                None,
10706                true,
10707            ))
10708            .expect("register");
10709
10710        // Only plugin-skipped attrs (@click, f-ref) plus static — only skipped counted
10711        let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
10712        let result = parser.parse("index.html", html);
10713        assert!(result.is_ok());
10714
10715        let records = parser.into_fragment_records();
10716        let frags = &records["index.html"].fragments;
10717
10718        let plugin_frags: Vec<_> = frags
10719            .iter()
10720            .filter_map(|f| match f.fragment.as_ref() {
10721                Some(Fragment::Plugin(p)) => Some(&p.data),
10722                _ => None,
10723            })
10724            .collect();
10725        assert_eq!(plugin_frags.len(), 1);
10726        let count = u32::from_le_bytes([
10727            plugin_frags[0][0],
10728            plugin_frags[0][1],
10729            plugin_frags[0][2],
10730            plugin_frags[0][3],
10731        ]);
10732        // @click + f-ref = 2, title is static = not counted
10733        assert_eq!(
10734            count, 2,
10735            "Only plugin-skipped attrs should be counted, not static title"
10736        );
10737    }
10738
10739    // ── Comment stripping tests ──────────────────────────────────────
10740
10741    #[test]
10742    fn test_comment_handlebars_signal_is_stripped() {
10743        let mut parser = HtmlParser::new();
10744        let html = "<!--{{tokens}}-->";
10745        parser.parse("test.html", html).expect("parse failed");
10746        let records = parser.into_fragment_records();
10747
10748        assert_stream!(records, "test.html", []);
10749    }
10750
10751    #[test]
10752    fn test_comment_triple_brace_raw_signal_is_stripped() {
10753        let mut parser = HtmlParser::new();
10754        let html = "<!--{{{tokens}}}-->";
10755        parser.parse("test.html", html).expect("parse failed");
10756        let records = parser.into_fragment_records();
10757
10758        assert_stream!(records, "test.html", []);
10759    }
10760
10761    #[test]
10762    fn test_comment_regular_stripped() {
10763        let mut parser = HtmlParser::new();
10764        let html = "<!-- regular comment -->";
10765        parser.parse("test.html", html).expect("parse failed");
10766        let records = parser.into_fragment_records();
10767
10768        assert_stream!(records, "test.html", []);
10769    }
10770
10771    #[test]
10772    fn test_comment_dotted_signal_is_stripped() {
10773        let mut parser = HtmlParser::new();
10774        let html = "<!--{{tokens.light}}-->";
10775        parser.parse("test.html", html).expect("parse failed");
10776        let records = parser.into_fragment_records();
10777
10778        assert_stream!(records, "test.html", []);
10779    }
10780
10781    #[test]
10782    fn test_comment_arbitrary_identifier_is_stripped() {
10783        let mut parser = HtmlParser::new();
10784        let html = "<!--{{someOtherBinding}}-->";
10785        parser.parse("test.html", html).expect("parse failed");
10786        let records = parser.into_fragment_records();
10787
10788        assert_stream!(records, "test.html", []);
10789    }
10790
10791    #[test]
10792    fn test_comment_with_surrounding_content_is_stripped() {
10793        let mut parser = HtmlParser::new();
10794        let html = "<div><!--{{tokens}}--></div>";
10795        parser.parse("test.html", html).expect("parse failed");
10796        let records = parser.into_fragment_records();
10797
10798        assert_stream!(records, "test.html", [raw("<div></div>")]);
10799    }
10800
10801    #[test]
10802    fn test_component_template_unterminated_comment_returns_error() {
10803        let mut parser = HtmlParser::new();
10804        parser
10805            .component_registry_mut()
10806            .register_component(ComponentRegistration::new(
10807                "x-bad",
10808                "<span>ok</span><!-- missing close",
10809                None,
10810                true,
10811            ))
10812            .expect("register failed");
10813
10814        let result = parser.parse("index.html", "<x-bad></x-bad>");
10815
10816        assert!(matches!(result, Err(ParserError::Template(diag)) if
10817            diag.to_string().contains("unterminated HTML comment")
10818        ));
10819    }
10820
10821    #[test]
10822    fn test_webui_plugin_strips_component_comments_before_metadata() {
10823        let mut parser =
10824            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10825        parser
10826            .component_registry_mut()
10827            .register_component(ComponentRegistration::new(
10828                "x-bleed",
10829                r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
10830                None,
10831                true,
10832            ))
10833            .expect("register failed");
10834
10835        parser
10836            .parse("index.html", "<x-bleed></x-bleed>")
10837            .expect("parse failed");
10838
10839        let artifacts = parser.take_plugin_artifacts().unwrap();
10840        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10841            panic!("expected component templates");
10842        };
10843        let template = &templates[0].template_json;
10844        assert!(template.contains("<div>hello</div>"));
10845        assert!(!template.contains("{{path}}"));
10846        assert!(!template.contains("@click"));
10847        assert!(!template.contains("?hidden"));
10848        assert!(!template.contains("-->"));
10849    }
10850
10851    #[test]
10852    fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
10853        let mut parser =
10854            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10855        parser
10856            .component_registry_mut()
10857            .register_component(ComponentRegistration::new(
10858                "x-style",
10859                "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
10860                None,
10861                true,
10862            ))
10863            .expect("register failed");
10864
10865        parser
10866            .parse("index.html", "<x-style></x-style>")
10867            .expect("parse failed");
10868
10869        let artifacts = parser.take_plugin_artifacts().unwrap();
10870        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10871            panic!("expected component templates");
10872        };
10873        let template = &templates[0].template_json;
10874        assert!(template.contains(".x { color: red; }"));
10875        assert!(!template.contains("ignored"));
10876        assert!(!template.contains("//"));
10877    }
10878
10879    // ── Token collection tests ───────────────────────────────────────
10880
10881    #[test]
10882    fn test_tokens_from_style_tag() {
10883        let mut parser = HtmlParser::new();
10884        let html = r#"<style>
10885            .btn { color: var(--colorPrimary); background: var(--bgColor); }
10886        </style>"#;
10887        parser.parse("test.html", html).expect("parse failed");
10888        let tokens = parser.take_tokens();
10889
10890        assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
10891    }
10892
10893    #[test]
10894    fn test_tokens_from_malformed_style_error_on_unclosed_var() {
10895        let mut parser = HtmlParser::new();
10896        let html = r#"<style>
10897            .bad { color: var(--dangling; }
10898            .ok { color: var(--valid); }
10899        </style>"#;
10900        let result = parser.parse("test.html", html);
10901
10902        assert!(matches!(result, Err(ParserError::Template(diag)) if
10903            diag.error_code() == Some(codes::INVALID_CSS)
10904                && diag.to_string().contains("Unterminated CSS var() call")
10905        ));
10906    }
10907
10908    #[test]
10909    fn test_tokens_from_component_css() {
10910        let mut parser = HtmlParser::new();
10911        parser
10912            .component_registry_mut()
10913            .register_component(ComponentRegistration::new(
10914                "my-button",
10915                "<button>Click</button>",
10916                Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
10917                true,
10918            ))
10919            .expect("register failed");
10920
10921        let html = "<my-button></my-button>";
10922        parser.parse("test.html", html).expect("parse failed");
10923        let tokens = parser.take_tokens();
10924
10925        assert_eq!(tokens, vec!["borderWidth", "textColor"]);
10926    }
10927
10928    #[test]
10929    fn test_token_requirements_preserve_fallback_chains() {
10930        let mut parser = HtmlParser::new();
10931        parser
10932            .component_registry_mut()
10933            .register_component(ComponentRegistration::new(
10934                "my-card",
10935                "<div>Card</div>",
10936                Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
10937                true,
10938            ))
10939            .expect("register failed");
10940
10941        parser
10942            .parse("test.html", "<my-card></my-card>")
10943            .expect("parse failed");
10944        let analysis = parser.token_analysis();
10945
10946        assert_eq!(analysis.fallback_chains.len(), 1);
10947        assert_eq!(
10948            analysis.fallback_chains[0].tokens,
10949            vec!["token-a", "token-b", "token-c"]
10950        );
10951        assert!(!analysis.fallback_chains[0].has_literal_fallback);
10952    }
10953
10954    #[test]
10955    fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
10956        let mut parser = HtmlParser::new();
10957        parser
10958            .component_registry_mut()
10959            .register_component(ComponentRegistration::new(
10960                "my-card",
10961                "<div>Card</div>",
10962                Some(
10963                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10964                ),
10965             true,))
10966            .expect("register failed");
10967
10968        parser
10969            .parse("test.html", "<my-card></my-card>")
10970            .expect("parse failed");
10971        let analysis = parser.token_analysis();
10972
10973        assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
10974        assert_eq!(analysis.fallback_chains.len(), 1);
10975        assert_eq!(
10976            analysis.fallback_chains[0].tokens,
10977            vec!["token-b", "token-c"]
10978        );
10979    }
10980
10981    #[test]
10982    fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
10983        let mut parser = HtmlParser::new();
10984        parser
10985            .component_registry_mut()
10986            .register_component(ComponentRegistration::new(
10987                "my-card",
10988                "<div>Card</div>",
10989                Some(
10990                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10991                ),
10992             true,))
10993            .expect("register failed");
10994        parser
10995            .parse("test.html", "<my-card></my-card>")
10996            .expect("parse failed");
10997
10998        let analysis = parser.token_analysis();
10999        let theme = webui_tokens::TokenFile {
11000            themes: HashMap::from([(
11001                "light".to_string(),
11002                HashMap::from([("token-b".to_string(), "green".to_string())]),
11003            )]),
11004        };
11005
11006        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
11007            panic!("missing --token-c in the theme must fail parser validation");
11008        };
11009        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
11010        // The help is concise: a typo suggestion plus the local-definition
11011        // escape hatch — it does not restate "add --token-c to theme".
11012        let help = diagnostic.help_text().expect("help text");
11013        assert!(help.contains("did you mean --token-b?"), "help: {help}");
11014        assert!(help.contains("define it locally"), "help: {help}");
11015        assert!(!help.contains("add --token"), "help: {help}");
11016    }
11017
11018    #[test]
11019    fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
11020        let mut parser = HtmlParser::new();
11021        parser
11022            .component_registry_mut()
11023            .register_component(ComponentRegistration::new(
11024                "my-card",
11025                "<div>Card</div>",
11026                Some(":host { color: var(--brand, #000); }"),
11027                true,
11028            ))
11029            .expect("register failed");
11030        parser
11031            .parse("test.html", "<my-card></my-card>")
11032            .expect("parse failed");
11033
11034        let analysis = parser.token_analysis();
11035        // The token is still hoisted so the runtime resolves it when a theme
11036        // does provide it.
11037        assert_eq!(analysis.protocol_tokens, vec!["brand"]);
11038
11039        // A theme without `--brand` must NOT fail the build: the CSS literal
11040        // fallback (`#000`) already provides a value.
11041        let theme = webui_tokens::TokenFile {
11042            themes: HashMap::from([("light".to_string(), HashMap::new())]),
11043        };
11044        analysis
11045            .validate_theme_tokens(&theme)
11046            .expect("literal fallback must exempt --brand from theme validation");
11047    }
11048
11049    #[test]
11050    fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
11051        let mut parser = HtmlParser::new();
11052        parser
11053            .component_registry_mut()
11054            .register_component(ComponentRegistration::new(
11055                "my-card",
11056                "<div>Card</div>",
11057                // One usage has a literal fallback, the other does not. The
11058                // bare `var(--brand)` makes the token genuinely required.
11059                Some(":host { color: var(--brand, #000); background: var(--brand); }"),
11060                true,
11061            ))
11062            .expect("register failed");
11063        parser
11064            .parse("test.html", "<my-card></my-card>")
11065            .expect("parse failed");
11066
11067        let analysis = parser.token_analysis();
11068        let theme = webui_tokens::TokenFile {
11069            themes: HashMap::from([("light".to_string(), HashMap::new())]),
11070        };
11071
11072        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
11073            panic!("a bare var(--brand) usage must require --brand in the theme");
11074        };
11075        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
11076        assert!(diagnostic.to_string().contains("--brand"));
11077    }
11078
11079    #[test]
11080    fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
11081        let mut parser = HtmlParser::new();
11082        parser
11083            .component_registry_mut()
11084            .register_component(ComponentRegistration::new(
11085                "my-card",
11086                "<div>Card</div>",
11087                Some(
11088                    ":host { \
11089                       color: var(--colr-brand, #000); \
11090                       border: var(--present, 1px); \
11091                       margin: var(--required); \
11092                     }",
11093                ),
11094                true,
11095            ))
11096            .expect("register failed");
11097        parser
11098            .parse("test.html", "<my-card></my-card>")
11099            .expect("parse failed");
11100
11101        let analysis = parser.token_analysis();
11102        let theme = webui_tokens::TokenFile {
11103            themes: HashMap::from([(
11104                "light".to_string(),
11105                HashMap::from([
11106                    ("present".to_string(), "2px".to_string()),
11107                    ("required".to_string(), "8px".to_string()),
11108                ]),
11109            )]),
11110        };
11111
11112        // `colr-brand`: literal-only and absent from every theme → warned.
11113        // `present`: literal-only but defined in the theme → not warned.
11114        // `required`: has no literal fallback → a validation concern, not a warning.
11115        assert_eq!(
11116            analysis.unthemed_literal_fallback_tokens(&theme),
11117            vec!["colr-brand".to_string()]
11118        );
11119    }
11120
11121    #[test]
11122    fn test_theme_token_error_reports_location_and_suggestion() {
11123        let mut parser = HtmlParser::new();
11124        parser
11125            .component_registry_mut()
11126            .register_component(ComponentRegistration::new(
11127                "my-card",
11128                "<div>Card</div>",
11129                ":host {\n  color: var(--color-neutral-2000);\n}".into(),
11130                true,
11131            ))
11132            .expect("register failed");
11133        parser
11134            .parse("test.html", "<my-card></my-card>")
11135            .expect("parse failed");
11136
11137        let analysis = parser.token_analysis();
11138        let theme = webui_tokens::TokenFile {
11139            themes: HashMap::from([(
11140                "dark".to_string(),
11141                HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
11142            )]),
11143        };
11144
11145        let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
11146            panic!("missing --color-neutral-2000 must fail validation");
11147        };
11148        assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
11149        // File + line:column, like other authoring diagnostics.
11150        let location = diag.location().expect("a source location");
11151        assert!(
11152            location.contains("my-card.css:2:14"),
11153            "location: {location}"
11154        );
11155        // Snippet shows the offending CSS line.
11156        assert!(
11157            diag.snippet_text()
11158                .is_some_and(|s| s.contains("--color-neutral-2000")),
11159            "snippet: {:?}",
11160            diag.snippet_text()
11161        );
11162        // Did-you-mean from the theme's own tokens.
11163        assert!(
11164            diag.help_text()
11165                .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
11166            "help: {:?}",
11167            diag.help_text()
11168        );
11169    }
11170
11171    #[test]
11172    fn test_theme_token_warning_reports_location_and_suggestion() {
11173        let mut parser = HtmlParser::new();
11174        parser
11175            .component_registry_mut()
11176            .register_component(ComponentRegistration::new(
11177                "my-card",
11178                "<div>Card</div>",
11179                ":host {\n  color: var(--colr-brand, #000);\n}".into(),
11180                true,
11181            ))
11182            .expect("register failed");
11183        parser
11184            .parse("test.html", "<my-card></my-card>")
11185            .expect("parse failed");
11186
11187        let analysis = parser.token_analysis();
11188        let theme = webui_tokens::TokenFile {
11189            themes: HashMap::from([(
11190                "light".to_string(),
11191                HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
11192            )]),
11193        };
11194
11195        let warnings = analysis.theme_token_warnings(&theme);
11196        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
11197        let body = warnings[0].body();
11198        assert!(body.contains("my-card.css:2:"), "warning: {body}");
11199        assert!(body.contains("--colr-brand"), "warning: {body}");
11200        assert!(
11201            body.contains("did you mean --color-brand?"),
11202            "warning: {body}"
11203        );
11204    }
11205
11206    #[test]
11207    fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
11208        let mut parser = HtmlParser::new();
11209        let result =
11210            parser
11211                .component_registry_mut()
11212                .register_component(ComponentRegistration::new(
11213                    "my-card",
11214                    "<div>Card</div>",
11215                    Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
11216                    true,
11217                ));
11218
11219        assert!(matches!(result, Err(ParserError::Css(message)) if
11220            message.contains("Unterminated CSS var() call")
11221        ));
11222    }
11223
11224    #[test]
11225    fn test_tokens_merged_from_style_and_components() {
11226        let mut parser = HtmlParser::new();
11227        parser
11228            .component_registry_mut()
11229            .register_component(ComponentRegistration::new(
11230                "my-widget",
11231                "<div>Widget</div>",
11232                Some(".w { padding: var(--spacingM); }"),
11233                true,
11234            ))
11235            .expect("register failed");
11236
11237        let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
11238        parser.parse("test.html", html).expect("parse failed");
11239        let tokens = parser.take_tokens();
11240
11241        assert_eq!(tokens, vec!["spacingM", "textColor"]);
11242    }
11243
11244    #[test]
11245    fn test_tokens_deduplicated() {
11246        let mut parser = HtmlParser::new();
11247        parser
11248            .component_registry_mut()
11249            .register_component(ComponentRegistration::new(
11250                "my-btn",
11251                "<button>B</button>",
11252                Some(".b { color: var(--shared); }"),
11253                true,
11254            ))
11255            .expect("register failed");
11256
11257        let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
11258        parser.parse("test.html", html).expect("parse failed");
11259        let tokens = parser.take_tokens();
11260
11261        assert_eq!(tokens, vec!["shared"]);
11262    }
11263
11264    #[test]
11265    fn test_tokens_empty_when_no_vars() {
11266        let mut parser = HtmlParser::new();
11267        let html = "<div>Hello</div>";
11268        parser.parse("test.html", html).expect("parse failed");
11269        let tokens = parser.take_tokens();
11270
11271        assert!(tokens.is_empty());
11272    }
11273
11274    #[test]
11275    fn test_tokens_exclude_locally_defined_in_component() {
11276        let mut parser = HtmlParser::new();
11277        parser
11278            .component_registry_mut()
11279            .register_component(ComponentRegistration::new(
11280                "my-card",
11281                "<div>Card</div>",
11282                Some(":host { --local: 5px; width: var(--external); }"),
11283                true,
11284            ))
11285            .expect("register failed");
11286
11287        let html = "<my-card></my-card>";
11288        parser.parse("test.html", html).expect("parse failed");
11289        let tokens = parser.take_tokens();
11290
11291        assert_eq!(tokens, vec!["external"]);
11292    }
11293
11294    #[test]
11295    fn test_tokens_exclude_entry_root_definitions() {
11296        let mut parser = HtmlParser::new();
11297        parser
11298            .component_registry_mut()
11299            .register_component(ComponentRegistration::new(
11300                "my-btn",
11301                "<button>B</button>",
11302                Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
11303                true,
11304            ))
11305            .expect("register failed");
11306
11307        // Entry HTML defines --color-primary and --radius-m in :root
11308        // Components use them — they should NOT appear in hoisted tokens
11309        let html = r#"<style>
11310            :root {
11311                --color-primary: #0078d4;
11312                --radius-m: 6px;
11313            }
11314            body { color: var(--color-primary); }
11315        </style>
11316        <my-btn></my-btn>"#;
11317        parser.parse("test.html", html).expect("parse failed");
11318        let tokens = parser.take_tokens();
11319
11320        // Both tokens are defined in entry :root, so neither should be hoisted
11321        assert!(
11322            tokens.is_empty(),
11323            "Tokens defined in entry :root should be excluded: {tokens:?}"
11324        );
11325    }
11326
11327    #[test]
11328    fn test_tokens_entry_defs_exclude_but_external_kept() {
11329        let mut parser = HtmlParser::new();
11330        parser
11331            .component_registry_mut()
11332            .register_component(ComponentRegistration::new(
11333                "my-card",
11334                "<div>Card</div>",
11335                Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
11336                true,
11337            ))
11338            .expect("register failed");
11339
11340        // Entry defines --color-primary but NOT --external-spacing
11341        let html = r#"<style>
11342            :root { --color-primary: #0078d4; }
11343        </style>
11344        <my-card></my-card>"#;
11345        parser.parse("test.html", html).expect("parse failed");
11346        let tokens = parser.take_tokens();
11347
11348        // Only --external-spacing should be hoisted (not defined in entry)
11349        assert_eq!(tokens, vec!["external-spacing"]);
11350    }
11351
11352    // ── Route parsing tests ─────────────────────────────────────────────
11353
11354    #[test]
11355    fn test_parse_simple_route() {
11356        let mut parser = HtmlParser::new();
11357        let html = r#"<route path="/profile" component="profile-page" exact />"#;
11358        parser.parse("test.html", html).expect("parse failed");
11359
11360        // Routes are emitted as Fragment::Route
11361        let records = parser.into_fragment_records();
11362        let frags = &records["test.html"].fragments;
11363        assert_eq!(frags.len(), 1);
11364        match frags[0].fragment.as_ref() {
11365            Some(web_ui_fragment::Fragment::Route(r)) => {
11366                assert_eq!(r.path, "/profile");
11367                assert_eq!(r.fragment_id, "profile-page");
11368                assert!(r.exact);
11369            }
11370            other => panic!("expected Fragment::Route, got {:?}", other),
11371        }
11372    }
11373
11374    #[test]
11375    fn test_parse_route_with_params() {
11376        let mut parser = HtmlParser::new();
11377        let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
11378        parser.parse("test.html", html).expect("parse failed");
11379
11380        // Route is emitted as Fragment::Route with correct path
11381        let records = parser.into_fragment_records();
11382        let frags = &records["test.html"].fragments;
11383        match frags[0].fragment.as_ref() {
11384            Some(web_ui_fragment::Fragment::Route(r)) => {
11385                assert_eq!(r.path, "/profile/:id/view/:section");
11386            }
11387            other => panic!("expected Fragment::Route, got {:?}", other),
11388        }
11389    }
11390
11391    #[test]
11392    fn test_parse_route_requires_component() {
11393        let mut parser = HtmlParser::new();
11394        let html = r#"<route path="/old" />"#;
11395        let result = parser.parse("test.html", html);
11396        assert!(result.is_err());
11397    }
11398
11399    #[test]
11400    fn test_parse_multiple_routes() {
11401        let mut parser = HtmlParser::new();
11402        let html = r#"<route path="/" component="app-layout" />
11403            <route path="/dashboard" component="dash-page" exact />
11404            <route path="/contacts" component="contacts-page" exact />"#;
11405        parser.parse("test.html", html).expect("parse failed");
11406
11407        let records = parser.into_fragment_records();
11408        let frags = &records["test.html"].fragments;
11409        assert_eq!(frags.len(), 3);
11410        // All should be Fragment::Route
11411        for frag in frags {
11412            assert!(matches!(
11413                frag.fragment.as_ref(),
11414                Some(web_ui_fragment::Fragment::Route(_))
11415            ));
11416        }
11417    }
11418
11419    #[test]
11420    fn test_parse_route_requires_component_with_body() {
11421        let mut parser = HtmlParser::new();
11422        let html = r#"<route path="/error">
11423            <div class="error"><h1>Not Found</h1></div>
11424        </route>"#;
11425        let result = parser.parse("test.html", html);
11426        assert!(
11427            result.is_err(),
11428            "Route without component attribute should fail"
11429        );
11430    }
11431
11432    #[test]
11433    fn test_parse_multiple_routes_with_fragments() {
11434        let mut parser = HtmlParser::new();
11435        let html = r#"<route path="/" component="home-page" exact />
11436            <route path="/about" component="about-page" exact />
11437            <route path="/contact/:id" component="contact-page" />"#;
11438        parser.parse("test.html", html).expect("parse failed");
11439
11440        let records = parser.into_fragment_records();
11441        let frags = &records["test.html"].fragments;
11442        assert_eq!(frags.len(), 3);
11443
11444        // Verify individual routes
11445        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
11446            assert_eq!(r.path, "/");
11447        }
11448        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
11449            assert_eq!(r.path, "/about");
11450        }
11451        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
11452            assert_eq!(r.path, "/contact/:id");
11453        }
11454    }
11455
11456    #[test]
11457    fn test_parse_route_with_query_allowlist() {
11458        let mut parser = HtmlParser::new();
11459        let html =
11460            r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
11461        parser.parse("test.html", html).expect("parse failed");
11462
11463        let records = parser.into_fragment_records();
11464        let frags = &records["test.html"].fragments;
11465        assert_eq!(frags.len(), 1);
11466        match frags[0].fragment.as_ref() {
11467            Some(web_ui_fragment::Fragment::Route(r)) => {
11468                assert_eq!(r.path, "/compose");
11469                assert_eq!(r.fragment_id, "compose-page");
11470                assert!(r.exact);
11471                assert_eq!(r.allowed_query, "action,to,subject");
11472            }
11473            other => panic!("expected Fragment::Route, got {:?}", other),
11474        }
11475    }
11476
11477    #[test]
11478    fn test_parse_route_without_query_has_empty_allowed_query() {
11479        let mut parser = HtmlParser::new();
11480        let html = r#"<route path="/profile" component="profile-page" exact />"#;
11481        parser.parse("test.html", html).expect("parse failed");
11482
11483        let records = parser.into_fragment_records();
11484        let frags = &records["test.html"].fragments;
11485        match frags[0].fragment.as_ref() {
11486            Some(web_ui_fragment::Fragment::Route(r)) => {
11487                assert!(r.allowed_query.is_empty());
11488            }
11489            other => panic!("expected Fragment::Route, got {:?}", other),
11490        }
11491    }
11492
11493    #[test]
11494    fn test_route_body_rejects_malformed_non_route_html() {
11495        let mut parser = HtmlParser::new();
11496        let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
11497        let result = parser.parse("test.html", html);
11498
11499        assert!(matches!(result, Err(ParserError::Template(diag)) if
11500            diag.to_string().contains("unclosed <span> tag")
11501        ));
11502    }
11503
11504    #[test]
11505    fn test_route_body_rejects_malformed_style_css() {
11506        let mut parser = HtmlParser::new();
11507        let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
11508        let result = parser.parse("test.html", html);
11509
11510        assert!(matches!(result, Err(ParserError::Template(diag)) if
11511            diag.error_code() == Some(codes::INVALID_CSS)
11512                && diag.to_string().contains("Unterminated CSS var() call")
11513        ));
11514    }
11515
11516    #[test]
11517    fn test_outlet_not_captured_by_for_loop() {
11518        let mut parser = HtmlParser::new();
11519        let html = r#"<template shadowrootmode="open">
11520  <ul>
11521    <for each="item in items">
11522      <li>{{item.name}}</li>
11523    </for>
11524  </ul>
11525  <main>
11526    <outlet />
11527  </main>
11528</template>"#;
11529        parser.parse("comp.html", html).expect("parse failed");
11530
11531        let records = parser.into_fragment_records();
11532        let frags = &records["comp.html"].fragments;
11533
11534        // The outlet should be a top-level fragment, NOT inside the for-loop's body
11535        let outlet_count = frags
11536            .iter()
11537            .filter(|f| {
11538                matches!(
11539                    f.fragment.as_ref(),
11540                    Some(web_ui_fragment::Fragment::Outlet(_))
11541                )
11542            })
11543            .count();
11544        assert_eq!(
11545            outlet_count, 1,
11546            "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
11547        );
11548
11549        // Verify outlet comes AFTER the raw "</ul>" text
11550        let outlet_idx = frags
11551            .iter()
11552            .position(|f| {
11553                matches!(
11554                    f.fragment.as_ref(),
11555                    Some(web_ui_fragment::Fragment::Outlet(_))
11556                )
11557            })
11558            .expect("no outlet found");
11559        let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
11560            Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
11561            _ => false,
11562        });
11563        if let Some(ul_idx) = close_ul_idx {
11564            assert!(
11565                outlet_idx > ul_idx,
11566                "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
11567            );
11568        }
11569    }
11570
11571    #[test]
11572    fn test_outlet_position_after_for_not_inside() {
11573        let mut parser = HtmlParser::new();
11574        let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
11575        parser.parse("test.html", html).expect("parse failed");
11576
11577        let records = parser.into_fragment_records();
11578        let frags = &records["test.html"].fragments;
11579
11580        // Outlet should be its own fragment at top level
11581        let has_outlet = frags.iter().any(|f| {
11582            matches!(
11583                f.fragment.as_ref(),
11584                Some(web_ui_fragment::Fragment::Outlet(_))
11585            )
11586        });
11587        assert!(
11588            has_outlet,
11589            "outlet should be in top-level fragments: {frags:?}"
11590        );
11591
11592        // The for-loop body should NOT contain the outlet
11593        let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
11594            Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
11595            _ => None,
11596        });
11597        if let Some(id) = for_id {
11598            let for_frags = &records[&id].fragments;
11599            let outlet_in_for = for_frags.iter().any(|f| {
11600                matches!(
11601                    f.fragment.as_ref(),
11602                    Some(web_ui_fragment::Fragment::Outlet(_))
11603                )
11604            });
11605            assert!(
11606                !outlet_in_for,
11607                "outlet should NOT be inside for-loop body: {for_frags:?}"
11608            );
11609        }
11610    }
11611
11612    #[test]
11613    fn multiple_outlets_in_one_fragment_warn() {
11614        let mut parser = HtmlParser::new();
11615        let html = r#"<main><outlet /></main><aside><outlet /></aside>"#;
11616        parser.parse("test.html", html).expect("parse failed");
11617
11618        let warnings = parser.take_warnings();
11619        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
11620        assert_eq!(warnings[0].severity(), Severity::Warning);
11621        assert_eq!(warnings[0].error_code(), Some(codes::MULTIPLE_OUTLETS));
11622        assert_eq!(warnings[0].component_name(), Some("test.html"));
11623        assert!(warnings[0].help_text().is_some());
11624    }
11625
11626    #[test]
11627    fn multiple_outlets_across_nested_directives_warn() {
11628        for html in [
11629            r#"<if condition="show"><outlet /></if><outlet />"#,
11630            r#"<for each="item in items"><outlet /></for><outlet />"#,
11631        ] {
11632            let mut parser = HtmlParser::new();
11633            parser.parse("test.html", html).expect("parse failed");
11634
11635            let warnings = parser.take_warnings();
11636            assert_eq!(warnings.len(), 1, "warnings for {html}: {warnings:?}");
11637            assert_eq!(warnings[0].error_code(), Some(codes::MULTIPLE_OUTLETS));
11638        }
11639    }
11640
11641    #[test]
11642    fn test_style_element_with_handlebars_signal_comment_emits_signal() {
11643        let mut parser = HtmlParser::new();
11644        let html = r#"<html><head><style>
11645:root {
11646    /*{{{tokens.light}}}*/
11647}
11648</style></head><body></body></html>"#;
11649
11650        let result = parser.parse("test.html", html);
11651        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11652        let fragment_records = parser.into_fragment_records();
11653
11654        assert_stream!(
11655            fragment_records,
11656            "test.html",
11657            [
11658                raw("<html><head>"),
11659                structural_matcher("head_start"),
11660                raw("<style>\n:root {\n    "),
11661                raw_text_signal("tokens.light", true),
11662                raw("\n}\n</style>"),
11663                structural_matcher("head_end"),
11664                raw("</head><body>"),
11665                structural_matcher("body_start"),
11666                structural_matcher("body_end"),
11667                raw("</body></html>"),
11668            ]
11669        );
11670    }
11671
11672    #[test]
11673    fn test_style_comment_signal_with_spaces_emits_signal() {
11674        let mut parser = HtmlParser::new();
11675        let html = r#"<html><head><style>
11676:root {
11677    /* {{{tokens.light}}} */
11678}
11679</style></head><body></body></html>"#;
11680
11681        let result = parser.parse("test.html", html);
11682        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11683        let fragment_records = parser.into_fragment_records();
11684
11685        assert_stream!(
11686            fragment_records,
11687            "test.html",
11688            [
11689                raw("<html><head>"),
11690                structural_matcher("head_start"),
11691                raw("<style>\n:root {\n    "),
11692                raw_text_signal("tokens.light", true),
11693                raw("\n}\n</style>"),
11694                structural_matcher("head_end"),
11695                raw("</head><body>"),
11696                structural_matcher("body_start"),
11697                structural_matcher("body_end"),
11698                raw("</body></html>"),
11699            ]
11700        );
11701    }
11702
11703    #[test]
11704    fn test_style_comment_signal_double_brace_emits_signal() {
11705        let mut parser = HtmlParser::new();
11706        let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
11707
11708        let result = parser.parse("test.html", html);
11709        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11710        let fragment_records = parser.into_fragment_records();
11711
11712        assert_stream!(
11713            fragment_records,
11714            "test.html",
11715            [
11716                raw("<html><head>"),
11717                structural_matcher("head_start"),
11718                raw("<style>"),
11719                raw_text_signal("themeCss", false),
11720                raw("</style>"),
11721                structural_matcher("head_end"),
11722                raw("</head><body>"),
11723                structural_matcher("body_start"),
11724                structural_matcher("body_end"),
11725                raw("</body></html>"),
11726            ]
11727        );
11728    }
11729
11730    #[test]
11731    fn test_style_comment_with_extra_text_is_stripped() {
11732        let mut parser = HtmlParser::new();
11733        let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
11734
11735        let result = parser.parse("test.html", html);
11736        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11737        let fragment_records = parser.into_fragment_records();
11738
11739        assert_stream!(
11740            fragment_records,
11741            "test.html",
11742            [
11743                raw("<html><head>"),
11744                structural_matcher("head_start"),
11745                raw("<style></style>"),
11746                structural_matcher("head_end"),
11747                raw("</head><body>"),
11748                structural_matcher("body_start"),
11749                structural_matcher("body_end"),
11750                raw("</body></html>"),
11751            ]
11752        );
11753    }
11754
11755    #[test]
11756    fn test_style_comment_with_multiple_signals_is_stripped() {
11757        let mut parser = HtmlParser::new();
11758        let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
11759
11760        let result = parser.parse("test.html", html);
11761        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11762        let fragment_records = parser.into_fragment_records();
11763
11764        assert_stream!(
11765            fragment_records,
11766            "test.html",
11767            [
11768                raw("<html><head>"),
11769                structural_matcher("head_start"),
11770                raw("<style></style>"),
11771                structural_matcher("head_end"),
11772                raw("</head><body>"),
11773                structural_matcher("body_start"),
11774                structural_matcher("body_end"),
11775                raw("</body></html>"),
11776            ]
11777        );
11778    }
11779
11780    #[test]
11781    fn test_style_bare_handlebars_stays_raw() {
11782        let mut parser = HtmlParser::new();
11783        // Handlebars outside a CSS comment must NOT be parsed as signals.
11784        let html =
11785            "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
11786
11787        let result = parser.parse("test.html", html);
11788        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11789        let fragment_records = parser.into_fragment_records();
11790
11791        assert_stream!(
11792            fragment_records,
11793            "test.html",
11794            [
11795                raw("<html><head>"),
11796                structural_matcher("head_start"),
11797                raw("<style>body { color: {{textColor}}; }</style>"),
11798                structural_matcher("head_end"),
11799                raw("</head><body>"),
11800                structural_matcher("body_start"),
11801                structural_matcher("body_end"),
11802                raw("</body></html>"),
11803            ]
11804        );
11805    }
11806
11807    #[test]
11808    fn test_style_mixed_css_and_comment_signal_emits_signal() {
11809        let mut parser = HtmlParser::new();
11810        let html = r#"<html><head><style>
11811  .a { color: red; }
11812  /*{{themeCss}}*/
11813  .b { color: blue; }
11814</style></head><body></body></html>"#;
11815
11816        let result = parser.parse("test.html", html);
11817        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11818        let fragment_records = parser.into_fragment_records();
11819
11820        assert_stream!(
11821            fragment_records,
11822            "test.html",
11823            [
11824                raw("<html><head>"),
11825                structural_matcher("head_start"),
11826                raw("<style>\n  .a { color: red; }\n  "),
11827                raw_text_signal("themeCss", false),
11828                raw("\n  .b { color: blue; }\n</style>"),
11829                structural_matcher("head_end"),
11830                raw("</head><body>"),
11831                structural_matcher("body_start"),
11832                structural_matcher("body_end"),
11833                raw("</body></html>"),
11834            ]
11835        );
11836    }
11837
11838    #[test]
11839    fn test_style_legal_comment_preserved_inline_by_default() {
11840        let mut parser = HtmlParser::new();
11841        let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
11842
11843        parser.parse("test.html", html).expect("parse failed");
11844        let fragment_records = parser.into_fragment_records();
11845
11846        assert_stream!(
11847            fragment_records,
11848            "test.html",
11849            [
11850                raw("<html><head>"),
11851                structural_matcher("head_start"),
11852                raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
11853                structural_matcher("head_end"),
11854                raw("</head><body>"),
11855                structural_matcher("body_start"),
11856                structural_matcher("body_end"),
11857                raw("</body></html>"),
11858            ]
11859        );
11860    }
11861
11862    #[test]
11863    fn test_style_legal_comments_none_strips_legal_comment() {
11864        let mut parser = HtmlParser::with_options(ParserOptions {
11865            legal_comments: LegalComments::None,
11866            ..ParserOptions::default()
11867        });
11868        let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
11869
11870        parser.parse("test.html", html).expect("parse failed");
11871        let fragment_records = parser.into_fragment_records();
11872
11873        assert_stream!(
11874            fragment_records,
11875            "test.html",
11876            [
11877                raw("<html><head>"),
11878                structural_matcher("head_start"),
11879                raw("<style> .x { color: red; }</style>"),
11880                structural_matcher("head_end"),
11881                raw("</head><body>"),
11882                structural_matcher("body_start"),
11883                structural_matcher("body_end"),
11884                raw("</body></html>"),
11885            ]
11886        );
11887    }
11888
11889    #[test]
11890    fn test_style_element_plain_css_unchanged() {
11891        let mut parser = HtmlParser::new();
11892        let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
11893
11894        let result = parser.parse("test.html", html);
11895        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11896        let fragment_records = parser.into_fragment_records();
11897
11898        // Plain CSS with no handlebars should remain as a single raw fragment.
11899        assert_stream!(
11900            fragment_records,
11901            "test.html",
11902            [
11903                raw("<html><head>"),
11904                structural_matcher("head_start"),
11905                raw("<style>body { margin: 0; }</style>"),
11906                structural_matcher("head_end"),
11907                raw("</head><body>"),
11908                structural_matcher("body_start"),
11909                structural_matcher("body_end"),
11910                raw("</body></html>"),
11911            ]
11912        );
11913    }
11914
11915    #[test]
11916    fn test_unclosed_style_element_returns_error() {
11917        let mut parser = HtmlParser::new();
11918        let result = parser.parse("index.html", "<style>.x { color: red; ");
11919        assert!(matches!(result, Err(ParserError::Template(diag)) if
11920            diag.to_string().contains("unclosed <style> tag")
11921        ));
11922    }
11923
11924    #[test]
11925    fn test_text_with_binding_after_void_element() {
11926        // Text nodes containing {{binding}} after void elements like <br>
11927        // must be preserved. Regression test for content being dropped.
11928        let (fragments, _) = parse_and_get_fragments(
11929            "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
11930        );
11931        assert_fragments!(
11932            fragments,
11933            [
11934                raw("Part A: "),
11935                signal("value1"),
11936                raw("<button>Click</button><br>Part B: "),
11937                signal("value2"),
11938                raw("<button>Click2</button>"),
11939            ]
11940        );
11941    }
11942}