Skip to main content

webui_parser/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Directive parser for WebUI template directives.
5//!
6//! This module handles parsing WebUI-specific directives like <for>, <if>, etc.
7mod asset_filename;
8mod comment_policy;
9mod component_policy;
10mod component_registry;
11mod condition_parser;
12mod css_light;
13mod css_link;
14mod css_parser;
15mod css_scan;
16mod diagnostic;
17mod error;
18mod handlebars_parser;
19mod html_parser;
20mod outlet_warnings;
21pub mod plugin;
22mod route_parser;
23mod scoped_visits;
24mod suggest;
25
26pub use asset_filename::{
27    AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
28};
29pub use component_registry::{Component, ComponentRegistration, ComponentRegistry};
30pub use condition_parser::ConditionParser;
31pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
32pub use css_parser::CssParser;
33pub use diagnostic::{codes, Diagnostic, Severity};
34pub use error::{ParserError, Result};
35pub use handlebars_parser::HandlebarsParser;
36pub use webui_tokens::CssFallbackChain;
37
38use crate::component_policy::{
39    parse_component_render_policy, ComponentRenderPolicy, HYDRATE_ATTR as COMPONENT_HYDRATE_ATTR,
40    RENDER_ATTR as COMPONENT_RENDER_ATTR,
41    RESERVE_BLOCK_SIZE_ATTR as COMPONENT_RESERVE_BLOCK_SIZE_ATTR,
42};
43use crate::html_parser::{self as html, Attrs, Element, Event, Walker};
44use crate::plugin::{
45    AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
46    ElementStartContext, FragmentContext, ParserPlugin, ParserPluginArtifacts,
47};
48use std::collections::{HashMap, HashSet};
49use std::ops::Range;
50use webui_protocol::{
51    web_ui_fragment, web_ui_fragment::Fragment, BoundaryPhase, ConditionExpr, FragmentList,
52    WebUIFragment, WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentBoundary,
53    WebUiFragmentRoute,
54};
55
56/// Maximum template size accepted by the parser.
57///
58/// Build-time parsing should never need to ingest arbitrarily large single
59/// templates. This cap prevents accidental or malicious OOM-scale inputs while
60/// staying far above normal component/page sizes.
61const MAX_TEMPLATE_BYTES: usize = 16 * 1024 * 1024;
62
63/// Maximum nested HTML element depth accepted by semantic parsing.
64///
65/// The scanner itself is iterative, but semantic fragment construction still
66/// enters child ranges. This limit keeps pathological nesting from exhausting
67/// the Rust call stack while preserving generous headroom for real templates.
68const MAX_TEMPLATE_DEPTH: usize = 512;
69
70/// Prefix for compiler-owned structural signals.
71///
72/// `}}}` cannot occur in a parsed double- or triple-brace expression: it closes
73/// the binding first. This keeps transport structure distinct from every
74/// authored state key without changing the protobuf schema.
75const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
76
77fn structural_signal(value: impl AsRef<str>) -> WebUIFragment {
78    WebUIFragment::signal(
79        format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
80        true,
81    )
82}
83
84/// How an element affects the HTML insertion mode that decides whether an
85/// unknown element (the generated `<webui-hydrate>` sentinel) is
86/// foster-parented away from its payload script.
87#[derive(Clone, Copy, PartialEq, Eq)]
88enum FosterContext {
89    /// Does not change the current insertion mode.
90    Transparent,
91    /// "in table"/"in table body"/"in row"/"in column group"/"in select":
92    /// unknown elements are foster-parented out of this subtree.
93    Hostile,
94    /// `<td>`/`<th>`/`<caption>` return to "in body" rules, so a boundary
95    /// inside them is safe even though an ancestor is hostile.
96    Barrier,
97}
98
99/// Classify `name` for foster-parenting purposes.
100///
101/// Dispatching on length first means the common non-table element (`div`,
102/// `span`, `p`, `section`, …) costs one integer compare and a fallthrough,
103/// keeping this off the parser's measured hot path.
104#[inline]
105fn foster_context_of(name: &str) -> FosterContext {
106    match name.len() {
107        2 => {
108            if name.eq_ignore_ascii_case("tr") {
109                FosterContext::Hostile
110            } else if name.eq_ignore_ascii_case("td") || name.eq_ignore_ascii_case("th") {
111                FosterContext::Barrier
112            } else {
113                FosterContext::Transparent
114            }
115        }
116        5 => {
117            if name.eq_ignore_ascii_case("table")
118                || name.eq_ignore_ascii_case("tbody")
119                || name.eq_ignore_ascii_case("thead")
120                || name.eq_ignore_ascii_case("tfoot")
121            {
122                FosterContext::Hostile
123            } else {
124                FosterContext::Transparent
125            }
126        }
127        6 => {
128            if name.eq_ignore_ascii_case("select") {
129                FosterContext::Hostile
130            } else {
131                FosterContext::Transparent
132            }
133        }
134        7 => {
135            if name.eq_ignore_ascii_case("caption") {
136                FosterContext::Barrier
137            } else {
138                FosterContext::Transparent
139            }
140        }
141        8 => {
142            if name.eq_ignore_ascii_case("optgroup") || name.eq_ignore_ascii_case("colgroup") {
143                FosterContext::Hostile
144            } else {
145                FosterContext::Transparent
146            }
147        }
148        _ => FosterContext::Transparent,
149    }
150}
151
152/// Return the browser parsing context that prevents a nested boundary sentinel
153/// from becoming an active custom element.
154#[inline]
155fn boundary_parent_scope(name: &str) -> Option<&'static str> {
156    match name.len() {
157        3 if name.eq_ignore_ascii_case("xmp") => Some("<xmp> raw-text content"),
158        5 if name.eq_ignore_ascii_case("title") => Some("<title> text content"),
159        6 if name.eq_ignore_ascii_case("iframe") => Some("<iframe> raw-text content"),
160        6 if name.eq_ignore_ascii_case("script") => Some("<script> raw-text content"),
161        7 if name.eq_ignore_ascii_case("noembed") => Some("<noembed> raw-text content"),
162        8 if name.eq_ignore_ascii_case("template") => Some("<template> inert content"),
163        8 if name.eq_ignore_ascii_case("textarea") => Some("<textarea> text content"),
164        8 if name.eq_ignore_ascii_case("noframes") => Some("<noframes> raw-text content"),
165        8 if name.eq_ignore_ascii_case("noscript") => Some("<noscript> inert content"),
166        9 if name.eq_ignore_ascii_case("plaintext") => Some("<plaintext> text content"),
167        _ => None,
168    }
169}
170
171#[inline]
172fn is_html_text_only_scope(scope: Option<&str>) -> bool {
173    // Explicit allowlist of the raw-text/RCDATA scopes returned by
174    // `boundary_parent_scope`, so a new scope value must be deliberately
175    // added here to become text-only rather than silently defaulting to it.
176    // `<template> inert content` and "component host content" are
177    // intentionally excluded: both parse normal (escaped) child content.
178    matches!(
179        scope,
180        Some(
181            "<xmp> raw-text content"
182                | "<title> text content"
183                | "<iframe> raw-text content"
184                | "<script> raw-text content"
185                | "<noembed> raw-text content"
186                | "<textarea> text content"
187                | "<noframes> raw-text content"
188                | "<noscript> inert content"
189                | "<plaintext> text content"
190        )
191    )
192}
193
194/// Strategy for how component CSS is delivered in rendered output.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
197pub enum CssStrategy {
198    /// Emit `<link rel="stylesheet" href="component.css">` tags (default).
199    #[default]
200    Link,
201    /// Embed CSS content in `<style>` tags within the component.
202    Style,
203    /// Register each component's CSS module as a `<script type="importmap">`
204    /// data-URI entry. The client runtime applies the registered sheet via
205    /// the document's adopted stylesheets.
206    Module,
207}
208
209impl std::fmt::Display for CssStrategy {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        match self {
212            CssStrategy::Link => write!(f, "link"),
213            CssStrategy::Style => write!(f, "style"),
214            CssStrategy::Module => write!(f, "module"),
215        }
216    }
217}
218
219impl std::str::FromStr for CssStrategy {
220    type Err = String;
221
222    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
223        match s {
224            "link" => Ok(CssStrategy::Link),
225            "style" => Ok(CssStrategy::Style),
226            "module" => Ok(CssStrategy::Module),
227            other => Err(format!(
228                "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
229            )),
230        }
231    }
232}
233
234/// Default DOM strategy for components without an authored declarative Shadow root.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
237pub enum DomStrategy {
238    /// Wrap unwrapped component content in an open declarative Shadow root.
239    #[default]
240    Shadow,
241    /// Render unwrapped component content directly in Light DOM.
242    Light,
243}
244
245impl std::fmt::Display for DomStrategy {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        match self {
248            Self::Shadow => write!(f, "shadow"),
249            Self::Light => write!(f, "light"),
250        }
251    }
252}
253
254impl std::str::FromStr for DomStrategy {
255    type Err = String;
256
257    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
258        match value {
259            "shadow" => Ok(Self::Shadow),
260            "light" => Ok(Self::Light),
261            other => Err(format!(
262                "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
263            )),
264        }
265    }
266}
267
268/// Strategy for preserving legal comments in generated output.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
270#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
271pub enum LegalComments {
272    /// Strip every HTML and CSS comment.
273    None,
274    /// Preserve legal CSS comments inline and strip all other comments.
275    #[default]
276    Inline,
277}
278
279impl std::fmt::Display for LegalComments {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        match self {
282            LegalComments::None => write!(f, "none"),
283            LegalComments::Inline => write!(f, "inline"),
284        }
285    }
286}
287
288impl std::str::FromStr for LegalComments {
289    type Err = String;
290
291    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
292        match s {
293            "none" => Ok(LegalComments::None),
294            "inline" => Ok(LegalComments::Inline),
295            other => Err(format!(
296                "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
297            )),
298        }
299    }
300}
301
302/// Build/output options that affect parser-generated component templates.
303#[derive(Debug, Clone, Default)]
304pub struct ParserOptions {
305    /// Strategy for how component CSS is delivered.
306    pub css_strategy: CssStrategy,
307    /// Fallback DOM strategy for components without an authored Shadow root.
308    pub dom_strategy: DomStrategy,
309    /// Link-mode CSS filename/href options.
310    pub css_link_options: CssLinkOptions,
311    /// Legal comment preservation strategy.
312    pub legal_comments: LegalComments,
313}
314
315impl ParserOptions {
316    /// Create parser output options.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`ParserError::Css`] when Link-mode CSS link options are invalid.
321    pub fn try_new(
322        css_strategy: CssStrategy,
323        dom_strategy: DomStrategy,
324        css_file_name_template: &str,
325        css_public_base: Option<&str>,
326        legal_comments: LegalComments,
327    ) -> Result<Self> {
328        let css_link_options = if css_strategy == CssStrategy::Link {
329            CssLinkOptions::try_new(
330                css_file_name_template.to_string(),
331                css_public_base.map(std::string::ToString::to_string),
332            )?
333        } else {
334            CssLinkOptions::default()
335        };
336
337        Ok(Self {
338            css_strategy,
339            dom_strategy,
340            css_link_options,
341            legal_comments,
342        })
343    }
344}
345
346impl From<DomStrategy> for ParserOptions {
347    fn from(dom_strategy: DomStrategy) -> Self {
348        Self {
349            dom_strategy,
350            ..Self::default()
351        }
352    }
353}
354
355impl From<CssStrategy> for ParserOptions {
356    fn from(css_strategy: CssStrategy) -> Self {
357        Self {
358            css_strategy,
359            ..Self::default()
360        }
361    }
362}
363
364impl From<(CssStrategy, DomStrategy)> for ParserOptions {
365    fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
366        Self {
367            css_strategy,
368            dom_strategy,
369            ..Self::default()
370        }
371    }
372}
373
374/// Framework plugin to load for build-time and render-time processing.
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
377pub enum Plugin {
378    /// Deprecated compatibility alias for the FAST 2 hydration plugin.
379    #[cfg_attr(feature = "cli", value(name = "fast"))]
380    Fast,
381    /// Deprecated FAST 2 hydration plugin with legacy marker output.
382    #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
383    FastV2,
384    /// FAST 3 hydration plugin with compact marker output.
385    #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
386    FastV3,
387    /// WebUI plugin — full component model with shadow DOM support.
388    #[cfg_attr(feature = "cli", value(name = "webui"))]
389    WebUI,
390}
391
392impl std::fmt::Display for Plugin {
393    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394        match self {
395            Plugin::Fast => write!(f, "fast"),
396            Plugin::FastV2 => write!(f, "fast-v2"),
397            Plugin::FastV3 => write!(f, "fast-v3"),
398            Plugin::WebUI => write!(f, "webui"),
399        }
400    }
401}
402
403impl std::str::FromStr for Plugin {
404    type Err = String;
405
406    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
407        match s {
408            "fast" => Ok(Plugin::Fast),
409            "fast-v2" => Ok(Plugin::FastV2),
410            "fast-v3" => Ok(Plugin::FastV3),
411            "webui" => Ok(Plugin::WebUI),
412            other => Err(format!(
413                "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
414            )),
415        }
416    }
417}
418
419/// Counter for generating unique fragment IDs.
420struct FragmentIdCounter {
421    /// Map of counter types to their current values.
422    counters: HashMap<String, usize>,
423}
424
425impl FragmentIdCounter {
426    /// Create a new fragment ID counter.
427    fn new() -> Self {
428        Self {
429            counters: HashMap::new(),
430        }
431    }
432
433    /// Generate a unique fragment ID.
434    fn next_id(&mut self, prefix: &str) -> String {
435        let count = self.counters.entry(prefix.to_string()).or_insert(0);
436        *count += 1;
437        format!("{}-{}", prefix, count)
438    }
439
440    fn has_generated(&self, id: &str) -> bool {
441        let Some((prefix, number)) = id.rsplit_once('-') else {
442            return false;
443        };
444        self.counters.get(prefix).is_some_and(|count| {
445            number
446                .parse::<usize>()
447                .is_ok_and(|number| number > 0 && number <= *count)
448        })
449    }
450}
451
452#[derive(Default)]
453struct NamedForLoops {
454    entries: HashMap<String, NamedFor>,
455}
456
457struct NamedFor {
458    item: String,
459    offset: usize,
460    defined: bool,
461}
462
463impl NamedForLoops {
464    fn register(&mut self, owner: &str, id: &str, item: &str, site: (bool, usize)) -> Result<()> {
465        let (definition, offset) = site;
466        if let Some(entry) = self.entries.get_mut(id) {
467            if definition && entry.defined {
468                return Err(named_for_error(
469                    owner,
470                    codes::DUPLICATE_FOR_ID,
471                    "duplicate named <for> definition",
472                    id,
473                    "define the body once per file and use a self-closing <for id=\"...\" each=\"...\" /> to reuse it",
474                )
475                .into());
476            }
477            if entry.item != item {
478                return Err(named_for_error(
479                    owner,
480                    codes::INCOMPATIBLE_FOR_ITEM,
481                    "inconsistent item variable for named <for>",
482                    id,
483                    &format!(
484                        "use each=\"{} in collection\" at every reference to this id",
485                        entry.item
486                    ),
487                )
488                .into());
489            }
490            entry.defined |= definition;
491        } else {
492            self.entries.insert(
493                id.to_string(),
494                NamedFor {
495                    item: item.to_string(),
496                    offset,
497                    defined: definition,
498                },
499            );
500        }
501        Ok(())
502    }
503
504    fn validate(&self, owner: &str, source: &str) -> Result<()> {
505        let missing = self
506            .entries
507            .iter()
508            .filter(|(_, entry)| !entry.defined)
509            .min_by_key(|(_, entry)| entry.offset);
510        if let Some((id, entry)) = missing {
511            return Err(named_for_error(
512                owner,
513                codes::UNKNOWN_FOR_ID,
514                "unknown named <for> definition",
515                id,
516                "add a <for> with this id and a body in the same file; definitions in other files are not visible",
517            )
518            .at_offset(source, entry.offset)
519            .into());
520        }
521        Ok(())
522    }
523}
524
525fn parse_for_each<'a>(owner: &str, each: &'a str) -> Result<(&'a str, &'a str)> {
526    let mut parts = each.split_whitespace();
527    let (Some(item), Some("in"), Some(collection), None) =
528        (parts.next(), parts.next(), parts.next(), parts.next())
529    else {
530        return Err(invalid_for_each(owner, each, codes::INVALID_FOR_EACH));
531    };
532    let allowed = |value: &str| {
533        value.bytes().all(|byte| {
534            byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-' || byte == b'.'
535        })
536    };
537    if !allowed(item) || !allowed(collection) {
538        return Err(invalid_for_each(owner, each, codes::INVALID_FOR_IDENTIFIER));
539    }
540    Ok((item, collection))
541}
542
543fn validate_for_id<'a>(owner: &str, tag: &html::Tag<'a>) -> Result<Option<&'a str>> {
544    let mut id = None;
545    let mut id_count = 0;
546    for attr in tag.attrs() {
547        match attr.name {
548            "template" => return Err(unsupported_for_template(owner)),
549            "id" => {
550                id = Some(attr.value.unwrap_or_default());
551                id_count += 1;
552            }
553            _ => {}
554        }
555    }
556    let Some(id) = id else {
557        return Ok(None);
558    };
559    if id.is_empty()
560        || !id
561            .bytes()
562            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
563        || id_count != 1
564    {
565        return Err(named_for_error(
566            owner,
567            codes::INVALID_FOR_ID,
568            "invalid <for> id",
569            id,
570            "use one non-empty static id containing letters, digits, '_' or '-'",
571        )
572        .into());
573    }
574    Ok(Some(id))
575}
576
577fn strip_condition_braces(value: &str) -> &str {
578    let value = value.trim();
579    value
580        .strip_prefix("{{")
581        .and_then(|value| value.strip_suffix("}}"))
582        .map_or(value, str::trim)
583}
584
585#[cold]
586#[inline(never)]
587fn locate_for_error(error: ParserError, element: &Element<'_>) -> ParserError {
588    match error {
589        ParserError::Template(diagnostic) => (*diagnostic)
590            .at_offset(element.source(), element.start)
591            .into(),
592        other => other,
593    }
594}
595
596#[cold]
597#[inline(never)]
598fn invalid_for_each(owner: &str, each: &str, code: &'static str) -> ParserError {
599    Diagnostic::error("invalid <for> each expression")
600        .code(code)
601        .component(owner)
602        .element("for")
603        .snippet(format!("each=\"{each}\""))
604        .help("use each=\"item in collection\" without braces; item and collection names may use only letters, digits, '_', '-', and '.'")
605        .into()
606}
607
608#[cold]
609#[inline(never)]
610fn unsupported_for_template(owner: &str) -> ParserError {
611    Diagnostic::error("unsupported <for> template attribute")
612        .code(codes::INVALID_FOR_ID)
613        .component(owner)
614        .element("for")
615        .snippet("template")
616        .help("replace template with id on the loop definition and every reference; named loops are local to their file")
617        .into()
618}
619
620#[cold]
621#[inline(never)]
622fn named_for_error(
623    owner: &str,
624    code: &'static str,
625    title: &str,
626    id: &str,
627    help: &str,
628) -> Diagnostic {
629    Diagnostic::error(title)
630        .code(code)
631        .component(owner)
632        .element("for")
633        .snippet(format!("id=\"{id}\""))
634        .help(help)
635}
636
637struct ParseContext {
638    fragments: Vec<WebUIFragment>,
639    raw_buffer: String,
640}
641
642struct PendingBoundary {
643    declaration_id: u32,
644}
645
646struct BoundaryGraphEdge {
647    target: String,
648}
649
650/// A `<for>` callsite, used to name the offending repeat in a diagnostic.
651struct RepeatSite<'a> {
652    owner: &'a str,
653    item: &'a str,
654    collection: &'a str,
655}
656
657/// A boundary declaration a `<for>` repeat body can reach.
658struct RepeatBoundaryViolation {
659    repeat_owner: String,
660    each: String,
661    boundary_owner: String,
662    boundary_name: String,
663}
664
665/// Push every record a route subtree renders into `targets`.
666fn push_route_records<'a>(targets: &mut Vec<&'a str>, root: &'a WebUiFragmentRoute) {
667    let mut pending = vec![root];
668    while let Some(route) = pending.pop() {
669        targets.push(route.fragment_id.as_str());
670        targets.push(route.content_fragment_id.as_str());
671        targets.push(route.pending_component.as_str());
672        targets.push(route.error_component.as_str());
673        pending.extend(route.children.iter());
674    }
675}
676
677/// Push every record `list` renders through a lexical child edge.
678fn push_child_records<'a>(list: &'a FragmentList, targets: &mut Vec<&'a str>) {
679    for fragment in &list.fragments {
680        match fragment.fragment.as_ref() {
681            Some(Fragment::Component(component)) => targets.push(&component.fragment_id),
682            Some(Fragment::IfCond(if_cond)) => targets.push(&if_cond.fragment_id),
683            Some(Fragment::ForLoop(for_loop)) => targets.push(&for_loop.fragment_id),
684            Some(Fragment::Route(route)) => push_route_records(targets, route),
685            _ => {}
686        }
687    }
688}
689
690#[derive(Default)]
691struct FragmentCssTokens {
692    definitions: Vec<String>,
693    fallback_chains: Vec<CssFallbackChain>,
694}
695
696/// CSS token analysis produced from the parsed template/component graph.
697#[derive(Debug, Clone, PartialEq, Eq)]
698pub struct CssTokenAnalysis {
699    /// Sorted, deduplicated token candidates stored in the protocol.
700    pub protocol_tokens: Vec<String>,
701    /// Fallback chains that still need theme/literal coverage after local and
702    /// ancestor custom-property definitions are considered.
703    pub fallback_chains: Vec<CssFallbackChain>,
704    /// Source location of each unresolved token's first `var()` usage, keyed by
705    /// token name. Used to point theme-validation diagnostics at the offending
706    /// CSS. Build-time only; never serialized.
707    pub(crate) token_sites: HashMap<String, TokenSite>,
708}
709
710/// Where an unresolved CSS token is first referenced, for diagnostics.
711///
712/// `owner` is a file-like label (`my-card.css`, or the entry file for inline
713/// `<style>`). `position`/`snippet` are populated for component CSS where the
714/// source is a standalone file; inline styles record the owner only.
715#[derive(Debug, Clone, PartialEq, Eq)]
716pub(crate) struct TokenSite {
717    owner: String,
718    position: Option<(usize, usize)>,
719    snippet: Option<String>,
720}
721
722enum ParseOp<'a> {
723    Parse {
724        range: Range<usize>,
725        depth: usize,
726    },
727    EmitClose(&'a str),
728    EndComponent(&'a str),
729    EndHead(&'a str),
730    EndBody(&'a str),
731    CompleteBoundary {
732        boundary: PendingBoundary,
733    },
734    /// Restore [`HtmlParser::foster_context_depth`] to the saved value when
735    /// leaving an element that changed it.
736    ///
737    /// Save/restore of the single overwritten value keeps ancestor tracking
738    /// allocation-free: no ancestor stack is retained.
739    RestoreFosterDepth(u32),
740    /// Restore the enclosing component/raw/inert context after its child range
741    /// has been parsed.
742    RestoreBoundaryParentScope(Option<&'static str>),
743    CompleteFor {
744        parent: ParseContext,
745        item: String,
746        collection: String,
747        fragment_id: String,
748        named_definition: bool,
749        previous_for_depth: usize,
750    },
751    CompleteIf {
752        parent: ParseContext,
753        condition: ConditionExpr,
754        fragment_id: String,
755    },
756}
757
758enum TokenGraphOp<'a> {
759    EnterFragment(&'a str),
760    ExitFragment(&'a str),
761    EnterComponent(&'a str),
762    EnterRoute(&'a WebUiFragmentRoute),
763    ExitDefinitions(&'a [String]),
764}
765
766impl Default for HtmlParser {
767    fn default() -> Self {
768        Self::new()
769    }
770}
771
772/// Parser for WebUI directives.
773pub struct HtmlParser {
774    /// CSS parser.
775    css_parser: CssParser,
776
777    /// Fragment ID counter.
778    id_counter: FragmentIdCounter,
779
780    /// Condition parser for parsing conditions in directives.
781    condition_parser: ConditionParser,
782
783    /// Handlebars parser for parsing handlebars expressions.
784    handlebars_parser: HandlebarsParser,
785
786    /// Component registry for WebUI components.
787    component_registry: ComponentRegistry,
788
789    /// Map of fragment IDs to their fragments
790    fragment_records: WebUIFragmentRecords,
791
792    /// Non-fatal parser advisories collected while parsing templates.
793    warnings: Vec<Diagnostic>,
794
795    /// Buffer for accumulating raw content
796    raw_buffer: String,
797
798    /// Parser output options for generated component templates.
799    options: ParserOptions,
800
801    /// Optional parser plugin for framework-specific behavior.
802    plugin: Option<Box<dyn ParserPlugin>>,
803
804    /// Static component behavior copied from the plugin at construction.
805    component_processing: ComponentProcessing,
806
807    /// Declarative Shadow DOM analysis resolved once per component tag.
808    component_dom_analyses: HashMap<String, ComponentDomAnalysis>,
809
810    /// Top-level fragments parsed by callers. Token graph traversal starts
811    /// from these roots after parsing completes.
812    token_roots: Vec<String>,
813
814    /// CSS custom property definitions and fallback chains from inline
815    /// `<style>` tags, keyed by owning fragment id.
816    fragment_css_tokens: HashMap<String, FragmentCssTokens>,
817
818    /// Fragment IDs currently being parsed, used to reject recursive component
819    /// references before they can recurse through template parsing.
820    in_progress_fragments: HashSet<String>,
821
822    /// The fragment ID (entry file or component tag) currently being parsed.
823    /// Used to name the owning template in authoring [`Diagnostic`]s.
824    current_fragment_id: String,
825
826    loop_owner_indices: HashMap<String, usize>,
827    named_for_loops: NamedForLoops,
828    named_for_fragment_ids: HashSet<String>,
829
830    /// Next protocol-wide compile-time boundary declaration identity.
831    next_boundary_declaration_id: u32,
832
833    /// Authored boundary names keyed by their owning entry/component template.
834    boundary_names_by_owner: HashMap<String, HashSet<String>>,
835
836    /// `true` while parsing is inside an open `<boundary>`, used to
837    /// reject a nested boundary.
838    in_boundary: bool,
839
840    /// Number of boundary scopes surrounding the current reusable-template
841    /// callsite. Kept separate from lexical `in_boundary` so component syntax
842    /// is analyzed independently while island-owned module entries stay
843    /// excluded from critical preloads.
844    boundary_ancestor_depth: usize,
845
846    /// Number of currently open native `<body>` elements. A depth rather than
847    /// a boolean preserves the parent state while recursively parsing a
848    /// component template.
849    body_depth: usize,
850
851    /// Number of lexical `<for>` scopes in the current template. Boundaries in
852    /// such scopes can occur repeatedly and therefore require an authored key.
853    for_depth: usize,
854
855    /// Number of runtime route-content scopes currently being parsed.
856    route_depth: usize,
857
858    /// Number of enclosing elements whose HTML insertion mode would
859    /// foster-parent an unknown element such as the generated
860    /// `<webui-hydrate>` sentinel.
861    ///
862    /// Non-zero means a `<boundary>` here would be split from its
863    /// payload script by the browser's parser, so it must be rejected at build
864    /// time. `<td>`/`<th>`/`<caption>` reset it to `0` because those switch
865    /// back to "in body" insertion rules.
866    foster_context_depth: u32,
867
868    /// Enclosing component host content or native raw/inert HTML context.
869    ///
870    /// A generated sentinel in any of these contexts is either text or lives
871    /// in an inert fragment, so it cannot commit a streaming boundary.
872    boundary_parent_scope: Option<&'static str>,
873
874    /// `src` of every authored `<script type="module">` outside any
875    /// `<boundary>`, in document order, from the current top-level parse.
876    ///
877    /// These are the page's critical module entries. The build resolves each
878    /// against the projection manifest to emit `<link rel="modulepreload">`
879    /// for the shared chunks they statically import, which the browser's
880    /// preload scanner cannot discover on its own.
881    ///
882    /// Scripts *inside* a boundary are deliberately excluded: an island loader
883    /// is meant to be requested only when the parser reaches its chunk, so
884    /// preloading it would undo the deferral the author asked for.
885    /// Explicit `fetchpriority="low"` modules are excluded for the same reason.
886    module_entry_srcs: Vec<String>,
887}
888
889struct BuiltComponentTemplate {
890    ssr: String,
891    artifact: Option<String>,
892    uses_shadow_dom: bool,
893    style: Option<OwnedComponentStyle>,
894}
895
896/// Owned form of [`plugin::ComponentStyleDelivery`], held for the lifetime of a
897/// built component template so plugin context can borrow from it.
898enum OwnedComponentStyle {
899    Link(String),
900    Inline(String),
901    Adopted(String),
902}
903
904#[derive(Clone, Copy)]
905struct ComponentStyleInjection<'a> {
906    css_snippet: Option<&'a str>,
907    adopted_specifier: Option<&'a str>,
908}
909
910#[derive(Clone, Copy)]
911struct ComponentTemplateMode {
912    preserve_runtime_attrs: bool,
913    policy_wrapper: bool,
914    // Emit inline CSS after bindings for clients that scan braces.
915    styles_at_end: bool,
916}
917
918impl BuiltComponentTemplate {
919    fn artifact(&self) -> &str {
920        self.artifact.as_deref().unwrap_or(&self.ssr)
921    }
922
923    fn plugin_context<'a>(&'a self, component: &'a Component) -> ComponentBuildContext<'a> {
924        ComponentBuildContext {
925            component,
926            template: self.artifact(),
927            uses_shadow_dom: self.uses_shadow_dom,
928            style: self.style.as_ref().map(|style| match style {
929                OwnedComponentStyle::Link(href) => plugin::ComponentStyleDelivery::Link { href },
930                OwnedComponentStyle::Inline(css) => plugin::ComponentStyleDelivery::Inline { css },
931                OwnedComponentStyle::Adopted(specifier) => {
932                    plugin::ComponentStyleDelivery::Adopted { specifier }
933                }
934            }),
935        }
936    }
937}
938
939#[derive(Debug, Clone, Copy)]
940struct ComponentDomAnalysis {
941    uses_shadow_dom: bool,
942    /// Byte range of the authored declarative Shadow DOM root, when present.
943    authored_shadow_root: Option<(usize, usize)>,
944    /// Content range of a sole bare `<template>` that explicitly selects Light
945    /// DOM.
946    authored_light_root: Option<(usize, usize)>,
947}
948
949struct ShadowRootModeOccurrence<'a> {
950    element_start: usize,
951    element_end: usize,
952    element_name: &'a str,
953    has_closing_tag: bool,
954    dynamically_bound: bool,
955    attr_offset: usize,
956    value: Option<&'a str>,
957}
958
959#[cfg(test)]
960fn analyze_component_dom(
961    tag_name: &str,
962    source: &str,
963    fallback: DomStrategy,
964) -> Result<ComponentDomAnalysis> {
965    analyze_component_dom_with_diagnostic_source(tag_name, source, fallback, None)
966}
967
968fn analyze_component_dom_with_diagnostic_source(
969    tag_name: &str,
970    source: &str,
971    fallback: DomStrategy,
972    diagnostic_source: Option<&str>,
973) -> Result<ComponentDomAnalysis> {
974    let mut ranges = Vec::with_capacity(8);
975    ranges.push((0..source.len(), true));
976    let mut top_level_element_count = 0usize;
977    let mut top_level_root_start = 0usize;
978    let mut has_top_level_content = false;
979    let mut bare_template_root = None;
980    let mut shadow_mode = None;
981    let mut slot_offset = None;
982
983    while let Some((range, top_level)) = ranges.pop() {
984        for event in Walker::new_range(source, range.start, range.end) {
985            match event {
986                Event::Text(text) if top_level && !text.trim().is_empty() => {
987                    has_top_level_content = true;
988                }
989                Event::Declaration(_) if top_level => {
990                    has_top_level_content = true;
991                }
992                Event::Element(element) => {
993                    if top_level {
994                        top_level_element_count += 1;
995                        top_level_root_start = element.start;
996                        bare_template_root = (element.name().eq_ignore_ascii_case("template")
997                            && !element.self_closing()
998                            && element.attrs().next().is_none())
999                        .then_some((element.content_start, element.content_end));
1000                    }
1001
1002                    for attr in element.attrs() {
1003                        let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1004                        if attr_name.eq_ignore_ascii_case("shadowrootmode") {
1005                            if shadow_mode.is_some() {
1006                                return Err(invalid_shadow_root_mode_error(
1007                                    tag_name,
1008                                    DiagnosticSite::new(
1009                                        source,
1010                                        diagnostic_source,
1011                                        element.start + attr.raw_range.start,
1012                                    ),
1013                                    "a component may declare only one `shadowrootmode`",
1014                                    "keep one `shadowrootmode=\"open\"` attribute on the sole top-level `<template>`",
1015                                ));
1016                            }
1017                            shadow_mode = Some(ShadowRootModeOccurrence {
1018                                element_start: element.start,
1019                                element_end: element.close_end(),
1020                                element_name: element.name(),
1021                                has_closing_tag: !element.self_closing()
1022                                    && element.close_end() > element.content_end(),
1023                                dynamically_bound: attr.name != attr_name,
1024                                attr_offset: element.start + attr.raw_range.start,
1025                                value: attr.value,
1026                            });
1027                        }
1028                    }
1029
1030                    if slot_offset.is_none() && element.name().eq_ignore_ascii_case("slot") {
1031                        slot_offset = Some(element.start);
1032                    }
1033
1034                    let name = element.name();
1035                    let raw_text = name.eq_ignore_ascii_case("script")
1036                        || name.eq_ignore_ascii_case("style")
1037                        || name.eq_ignore_ascii_case("textarea")
1038                        || name.eq_ignore_ascii_case("title");
1039                    if element.content_end() > element.inner().start && !raw_text {
1040                        ranges.push((element.inner(), false));
1041                    }
1042                }
1043                Event::Text(_)
1044                | Event::Comment(_)
1045                | Event::Declaration(_)
1046                | Event::ClosingTag(_) => {}
1047            }
1048        }
1049    }
1050
1051    let authored_shadow_root = match shadow_mode {
1052        Some(mode) => {
1053            let value = mode.value;
1054            if mode.dynamically_bound {
1055                return Err(invalid_shadow_root_mode_error(
1056                    tag_name,
1057                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1058                    "`shadowrootmode` must be a static attribute",
1059                    "replace the binding with `shadowrootmode=\"open\"` on the sole top-level `<template>`",
1060                ));
1061            }
1062            if value.is_some_and(|value| value.eq_ignore_ascii_case("closed")) {
1063                return Err(invalid_shadow_root_mode_error(
1064                    tag_name,
1065                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1066                    "`shadowrootmode=\"closed\"` is not supported",
1067                    "use `shadowrootmode=\"open\"`; WebUI requires an open root for hydration and style delivery",
1068                ));
1069            }
1070            if !value.is_some_and(|value| value.eq_ignore_ascii_case("open")) {
1071                return Err(invalid_shadow_root_mode_error(
1072                    tag_name,
1073                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1074                    "`shadowrootmode` must be `open`",
1075                    "set the attribute to `shadowrootmode=\"open\"`",
1076                ));
1077            }
1078            if top_level_element_count != 1
1079                || has_top_level_content
1080                || mode.element_start != top_level_root_start
1081                || !mode.element_name.eq_ignore_ascii_case("template")
1082                || !mode.has_closing_tag
1083            {
1084                return Err(invalid_shadow_root_mode_error(
1085                    tag_name,
1086                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1087                    "the declarative Shadow DOM wrapper must be the sole top-level element",
1088                    "wrap the complete component in one top-level `<template shadowrootmode=\"open\">`",
1089                ));
1090            }
1091            Some((mode.element_start, mode.element_end))
1092        }
1093        None => None,
1094    };
1095
1096    let authored_light_root =
1097        if authored_shadow_root.is_none() && top_level_element_count == 1 && !has_top_level_content
1098        {
1099            bare_template_root
1100        } else {
1101            None
1102        };
1103    let uses_shadow_dom = authored_shadow_root.is_some()
1104        || (authored_light_root.is_none() && fallback == DomStrategy::Shadow);
1105    if !uses_shadow_dom {
1106        if let Some(offset) = slot_offset {
1107            return Err(light_dom_slot_error(
1108                tag_name,
1109                DiagnosticSite::new(source, diagnostic_source, offset),
1110            ));
1111        }
1112    }
1113
1114    Ok(ComponentDomAnalysis {
1115        uses_shadow_dom,
1116        authored_shadow_root,
1117        authored_light_root,
1118    })
1119}
1120
1121#[cold]
1122#[inline(never)]
1123fn invalid_shadow_root_mode_error(
1124    tag_name: &str,
1125    site: DiagnosticSite<'_>,
1126    title: &str,
1127    help: &str,
1128) -> ParserError {
1129    let (source, offset) = site.resolve(DiagnosticTarget::Attribute("shadowrootmode"));
1130    Diagnostic::error(title)
1131        .code(codes::INVALID_SHADOW_ROOT_MODE)
1132        .component(tag_name)
1133        .at_offset(source, offset)
1134        .snippet(source_line_snippet(source, offset))
1135        .help(help)
1136        .into()
1137}
1138
1139#[cold]
1140#[inline(never)]
1141fn light_dom_slot_error(tag_name: &str, site: DiagnosticSite<'_>) -> ParserError {
1142    let (source, offset) = site.resolve(DiagnosticTarget::Element("slot"));
1143    Diagnostic::error("native `<slot>` projection requires Shadow DOM")
1144        .code(codes::LIGHT_DOM_SLOT)
1145        .component(tag_name)
1146        .element("slot")
1147        .at_offset(source, offset)
1148        .snippet(source_line_snippet(source, offset))
1149        .help(
1150            "wrap the complete component template in `<template shadowrootmode=\"open\">` to use `<slot>`",
1151        )
1152        .into()
1153}
1154
1155#[derive(Clone, Copy)]
1156struct DiagnosticSite<'a> {
1157    source: &'a str,
1158    authored_source: Option<&'a str>,
1159    offset: usize,
1160}
1161
1162impl<'a> DiagnosticSite<'a> {
1163    fn new(source: &'a str, authored_source: Option<&'a str>, offset: usize) -> Self {
1164        Self {
1165            source,
1166            authored_source,
1167            offset,
1168        }
1169    }
1170
1171    fn resolve(self, target: DiagnosticTarget) -> (&'a str, usize) {
1172        let Some(authored_source) = self.authored_source else {
1173            return (self.source, self.offset);
1174        };
1175        let transformed_offsets = diagnostic_target_offsets(self.source, target);
1176        let occurrence = transformed_offsets
1177            .iter()
1178            .position(|offset| *offset == self.offset)
1179            .unwrap_or(0);
1180        let authored_offsets = diagnostic_target_offsets(authored_source, target);
1181        authored_offsets
1182            .get(occurrence)
1183            .copied()
1184            .map_or((self.source, self.offset), |offset| {
1185                (authored_source, offset)
1186            })
1187    }
1188}
1189
1190#[derive(Clone, Copy)]
1191enum DiagnosticTarget {
1192    Element(&'static str),
1193    Attribute(&'static str),
1194}
1195
1196#[cold]
1197#[inline(never)]
1198fn diagnostic_target_offsets(source: &str, target: DiagnosticTarget) -> Vec<usize> {
1199    let mut offsets = Vec::new();
1200    let mut ranges = Vec::with_capacity(4);
1201    ranges.push(0..source.len());
1202
1203    while let Some(range) = ranges.pop() {
1204        for event in Walker::new_range(source, range.start, range.end) {
1205            let Event::Element(element) = event else {
1206                continue;
1207            };
1208            match target {
1209                DiagnosticTarget::Element(name) if element.name().eq_ignore_ascii_case(name) => {
1210                    offsets.push(element.start);
1211                }
1212                DiagnosticTarget::Attribute(name) => {
1213                    for attr in element.attrs() {
1214                        let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1215                        if attr_name.eq_ignore_ascii_case(name) {
1216                            offsets.push(element.start + attr.raw_range.start);
1217                        }
1218                    }
1219                }
1220                DiagnosticTarget::Element(_) => {}
1221            }
1222
1223            let name = element.name();
1224            let raw_text = name.eq_ignore_ascii_case("script")
1225                || name.eq_ignore_ascii_case("style")
1226                || name.eq_ignore_ascii_case("textarea")
1227                || name.eq_ignore_ascii_case("title");
1228            if element.content_end() > element.inner().start && !raw_text {
1229                ranges.push(element.inner());
1230            }
1231        }
1232    }
1233    offsets.sort_unstable();
1234    offsets
1235}
1236
1237struct ComponentTemplateInput<'a> {
1238    tag_name: &'a str,
1239    html: &'a str,
1240    artifact_html: Option<&'a str>,
1241    authored_html: Option<&'a str>,
1242    css_content: Option<&'a str>,
1243    artifact_needed: bool,
1244}
1245
1246fn add_token_definitions<'a>(
1247    definitions: &'a [String],
1248    available_counts: &mut HashMap<&'a str, usize>,
1249) {
1250    for definition in definitions {
1251        let count = available_counts.entry(definition.as_str()).or_insert(0);
1252        *count += 1;
1253    }
1254}
1255
1256fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<&str, usize>) {
1257    for definition in definitions {
1258        if let Some(count) = available_counts.get_mut(definition.as_str()) {
1259            if *count == 1 {
1260                available_counts.remove(definition.as_str());
1261            } else {
1262                *count -= 1;
1263            }
1264        }
1265    }
1266}
1267
1268/// Accumulators for the token-graph walk: the unresolved fallback chains and
1269/// the per-token source locations, grouped so walk helpers stay within the
1270/// argument-count budget.
1271#[derive(Default)]
1272struct UnresolvedTokens {
1273    chains: Vec<CssFallbackChain>,
1274    sites: HashMap<String, TokenSite>,
1275}
1276
1277fn record_unresolved_requirements(
1278    source: &[CssFallbackChain],
1279    available_counts: &HashMap<&str, usize>,
1280    owner: &str,
1281    css_source: Option<&str>,
1282    out: &mut UnresolvedTokens,
1283) {
1284    for requirement in source {
1285        let tokens: Vec<String> = requirement
1286            .tokens
1287            .iter()
1288            .filter(|token| !available_counts.contains_key(token.as_str()))
1289            .cloned()
1290            .collect();
1291        if tokens.is_empty() {
1292            continue;
1293        }
1294        for token in &tokens {
1295            if !out.sites.contains_key(token) {
1296                out.sites
1297                    .insert(token.clone(), token_site(owner, css_source, token));
1298            }
1299        }
1300        out.chains.push(CssFallbackChain {
1301            tokens,
1302            has_literal_fallback: requirement.has_literal_fallback,
1303        });
1304    }
1305}
1306
1307/// Build the [`TokenSite`] for `token`. When `css_source` is available (a
1308/// component's standalone CSS), the token's `var()` usage is located for a
1309/// precise `line:column` and snippet; otherwise only the `owner` is recorded.
1310fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
1311    let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
1312        Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
1313        None => (None, None),
1314    };
1315    TokenSite {
1316        owner: owner.to_string(),
1317        position,
1318        snippet,
1319    }
1320}
1321
1322/// Locate the first `var(--token)` **usage** (not a `--token:` definition) in
1323/// `css`, returning its 1-based `(line, column)` and a one-line snippet.
1324///
1325/// Iterative byte scan — no regex, no recursion. Cold-ish (build time, only for
1326/// unresolved tokens).
1327fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
1328    let bytes = css.as_bytes();
1329    let name = token.as_bytes();
1330    let mut index = 0;
1331    while index + 2 + name.len() <= bytes.len() {
1332        let is_prefixed = bytes[index] == b'-'
1333            && bytes[index + 1] == b'-'
1334            && bytes.get(index + 2..index + 2 + name.len()) == Some(name);
1335        if is_prefixed {
1336            let after = index + 2 + name.len();
1337            let is_exact_name = bytes
1338                .get(after)
1339                .is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
1340            if is_exact_name {
1341                let mut cursor = after;
1342                while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
1343                    cursor += 1;
1344                }
1345                // `--token:` is a definition, not the usage we want to point at.
1346                if bytes.get(cursor) != Some(&b':') {
1347                    let (line, column) = diagnostic::line_column(css, index);
1348                    return Some((line, column, source_line_snippet(css, index)));
1349                }
1350            }
1351        }
1352        index += 1;
1353    }
1354    None
1355}
1356
1357/// Extract the trimmed line containing byte `offset`, capped so minified input
1358/// does not produce a wall-of-text snippet.
1359fn source_line_snippet(source: &str, offset: usize) -> String {
1360    const MAX_SNIPPET: usize = 80;
1361    let start = source[..offset].rfind('\n').map_or(0, |n| n + 1);
1362    let end = source[offset..]
1363        .find('\n')
1364        .map_or(source.len(), |n| offset + n);
1365    let line = source[start..end].trim();
1366    if line.chars().count() > MAX_SNIPPET {
1367        let truncated: String = line.chars().take(MAX_SNIPPET).collect();
1368        format!("{truncated}…")
1369    } else {
1370        line.to_string()
1371    }
1372}
1373
1374/// File-like owner label for a component's standalone CSS
1375/// (`my-card` → `my-card.css`), used as the `--> owner:line:column` prefix.
1376fn css_owner_label(tag_name: &str) -> String {
1377    let mut label = String::with_capacity(tag_name.len() + 4);
1378    label.push_str(tag_name);
1379    label.push_str(".css");
1380    label
1381}
1382
1383impl CssTokenAnalysis {
1384    /// Validate the required CSS tokens against a loaded design-token theme.
1385    ///
1386    /// A token is *required* in every theme only when it appears in at least one
1387    /// unresolved `var()` chain with **no** literal CSS fallback. A chain such as
1388    /// `var(--x, 16px)` provides its own value, so `--x` stays in
1389    /// [`protocol_tokens`](Self::protocol_tokens) for runtime resolution (the
1390    /// theme value still wins when present) but does not fail the build when the
1391    /// theme omits it. The chain/theme policy lives in
1392    /// [`webui_tokens::validate_chain_tokens`]; this only adapts its
1393    /// [`webui_tokens::TokenError`] into a structured [`Diagnostic`].
1394    ///
1395    /// # Errors
1396    ///
1397    /// Returns [`ParserError::Template`] with a stable diagnostic code when a
1398    /// required token is missing from a theme. Theme token values are trusted and
1399    /// their transitive references are left to browser CSS semantics.
1400    pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
1401        webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
1402            .map_err(|source| self.theme_token_error(source, theme))
1403    }
1404
1405    /// CSS tokens used **only** with a literal `var()` fallback and defined in no
1406    /// theme — likely typos that callers may surface as non-fatal advisories.
1407    ///
1408    /// Thin wrapper over [`webui_tokens::unthemed_literal_fallback_tokens`].
1409    #[must_use]
1410    pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
1411        webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
1412    }
1413
1414    /// Structured, color-free advisories for CSS tokens used only with a
1415    /// literal `var()` fallback and defined in no theme — almost always typos.
1416    ///
1417    /// Returned as warning-severity [`Diagnostic`]s so hosts render them with
1418    /// the same location/snippet/`help:` layout as errors (the CLI colorizes;
1419    /// other hosts use the plain [`Diagnostic::body`]). Each carries the token's
1420    /// source location and a `did you mean --…?` suggestion.
1421    #[must_use]
1422    pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
1423        let mut warnings = Vec::new();
1424        for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
1425            let help = match closest_theme_token(&token, theme.themes.values()) {
1426                Some(suggestion) => {
1427                    format!("did you mean --{suggestion}? otherwise the literal fallback is used")
1428                }
1429                None => format!(
1430                    "define --{token} in the theme, or keep its literal fallback if intentional"
1431                ),
1432            };
1433            let diag = self.locate_diagnostic(
1434                Diagnostic::warning(format!("unthemed CSS token --{token}"))
1435                    .code(codes::UNTHEMED_TOKEN)
1436                    .help(help),
1437                &token,
1438            );
1439            warnings.push(diag);
1440        }
1441        warnings
1442    }
1443
1444    /// Adapt a [`webui_tokens::TokenError`] into a structured [`Diagnostic`],
1445    /// enriching it with the token's source location and a `did you mean …?`
1446    /// suggestion drawn from the theme's own tokens.
1447    #[cold]
1448    #[inline(never)]
1449    fn theme_token_error(
1450        &self,
1451        source: webui_tokens::TokenError,
1452        theme: &webui_tokens::TokenFile,
1453    ) -> ParserError {
1454        match source {
1455            webui_tokens::TokenError::MissingToken {
1456                theme: theme_name,
1457                token,
1458            } => {
1459                // The error title + snippet already say the token is missing
1460                // from the theme, so the help only adds what isn't obvious: the
1461                // likely-typo suggestion and the local-definition escape hatch.
1462                let help =
1463                    match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
1464                        Some(suggestion) => {
1465                            format!("did you mean --{suggestion}? otherwise define it locally")
1466                        }
1467                        None => {
1468                            "define it locally if it should not come from the theme".to_string()
1469                        }
1470                    };
1471                self.locate_diagnostic(
1472                    Diagnostic::error("missing theme token")
1473                        .code(codes::MISSING_THEME_TOKEN)
1474                        .help(help),
1475                    &token,
1476                )
1477                .into()
1478            }
1479            other => ParserError::Generic(format!("Theme token validation failed: {other}")),
1480        }
1481    }
1482
1483    /// Attach `token`'s recorded source location (owner, `line:column`, snippet)
1484    /// to `diag`. Falls back to a `--token` snippet when no location is known.
1485    fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
1486        let Some(site) = self.token_sites.get(token) else {
1487            return diag.snippet(format!("--{token}"));
1488        };
1489        let mut diag = diag.component(site.owner.clone());
1490        if let Some((line, column)) = site.position {
1491            diag = diag.position(line, column);
1492        }
1493        match &site.snippet {
1494            Some(snippet) => diag.snippet(snippet.clone()),
1495            None => diag.snippet(format!("--{token}")),
1496        }
1497    }
1498}
1499
1500/// Closest theme token to `target` by Levenshtein distance across the given
1501/// theme token maps, considering each theme's keys. Sorted for determinism.
1502#[cold]
1503#[inline(never)]
1504fn closest_theme_token<'a>(
1505    target: &str,
1506    themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
1507) -> Option<String> {
1508    let mut names: Vec<&str> = themes
1509        .flat_map(|theme| theme.keys().map(String::as_str))
1510        .collect();
1511    names.sort_unstable();
1512    names.dedup();
1513    crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
1514}
1515
1516impl HtmlParser {
1517    /// Create a new directive parser with default parser options.
1518    #[must_use]
1519    pub fn new() -> Self {
1520        Self::with_options(ParserOptions::default())
1521    }
1522
1523    /// Create a new directive parser with explicit parser options.
1524    #[must_use]
1525    pub fn with_options(options: impl Into<ParserOptions>) -> Self {
1526        let options = options.into();
1527
1528        Self {
1529            component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
1530            css_parser: CssParser::new(),
1531            id_counter: FragmentIdCounter::new(),
1532            condition_parser: ConditionParser::new(),
1533            handlebars_parser: HandlebarsParser::new(),
1534            raw_buffer: String::new(),
1535            fragment_records: WebUIFragmentRecords::new(),
1536            warnings: Vec::new(),
1537            options,
1538            plugin: None,
1539            component_processing: ComponentProcessing::default(),
1540            component_dom_analyses: HashMap::new(),
1541            token_roots: Vec::new(),
1542            fragment_css_tokens: HashMap::new(),
1543            in_progress_fragments: HashSet::new(),
1544            current_fragment_id: String::new(),
1545            loop_owner_indices: HashMap::new(),
1546            named_for_loops: NamedForLoops::default(),
1547            named_for_fragment_ids: HashSet::new(),
1548            next_boundary_declaration_id: 0,
1549            boundary_names_by_owner: HashMap::new(),
1550            in_boundary: false,
1551            boundary_ancestor_depth: 0,
1552            body_depth: 0,
1553            for_depth: 0,
1554            route_depth: 0,
1555            foster_context_depth: 0,
1556            boundary_parent_scope: None,
1557            module_entry_srcs: Vec::new(),
1558        }
1559    }
1560
1561    /// Create a new parser with a plugin and default parser options.
1562    #[must_use]
1563    pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
1564        Self::with_plugin_options(plugin, ParserOptions::default())
1565    }
1566
1567    /// Create a new parser with a plugin and explicit parser options.
1568    #[must_use]
1569    pub fn with_plugin_options(
1570        mut plugin: Box<dyn ParserPlugin>,
1571        options: impl Into<ParserOptions>,
1572    ) -> Self {
1573        let mut p = Self::with_options(options);
1574        plugin.configure_parser(&p.options);
1575        let component_processing = plugin.component_processing();
1576        p.component_registry
1577            .set_component_source_transform(component_processing.source_transform);
1578        p.component_processing = component_processing;
1579        p.plugin = Some(plugin);
1580        p
1581    }
1582
1583    /// Get a mutable reference to the component registry.
1584    pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
1585        &mut self.component_registry
1586    }
1587
1588    /// Get a shared reference to the component registry.
1589    #[must_use]
1590    pub fn component_registry(&self) -> &ComponentRegistry {
1591        &self.component_registry
1592    }
1593
1594    pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
1595        std::mem::take(&mut self.fragment_records)
1596    }
1597
1598    /// Take non-fatal parser warnings collected so far.
1599    #[must_use]
1600    pub fn take_warnings(&mut self) -> Vec<Diagnostic> {
1601        std::mem::take(&mut self.warnings)
1602    }
1603
1604    /// Check if a fragment ID has been parsed (exists in the fragment records).
1605    pub fn has_fragment(&self, fragment_id: &str) -> bool {
1606        self.fragment_records.contains_key(fragment_id)
1607    }
1608
1609    /// Compute transitive boundary presence and conservative declaration
1610    /// repeatability for the complete parsed fragment graph, and reject every
1611    /// boundary a `<for>` repeat body can reach.
1612    ///
1613    /// This runs only after a top-level parse succeeds. Every analysis is
1614    /// iterative: presence propagates through reverse edges, repeat
1615    /// reachability is a multi-source forward walk, and occurrence counts use a
1616    /// two-value (once/many) fixed point. Cycles therefore converge without
1617    /// recursion and are conservatively classified as repeatable.
1618    #[cold]
1619    #[inline(never)]
1620    fn finalize_boundary_metadata(&mut self) -> Result<()> {
1621        if self.next_boundary_declaration_id != 0 {
1622            let (outgoing, direct_boundary_records) = self.boundary_graph();
1623            self.mark_boundary_records(&outgoing, direct_boundary_records);
1624            if let Some(violation) = self.find_repeat_reachable_boundary() {
1625                return Err(Self::repeat_reachable_boundary_error(&violation));
1626            }
1627            let occurrence_counts = self.boundary_occurrence_counts(&outgoing);
1628
1629            let mut unkeyed_repeat: Option<(u32, String, String)> = None;
1630            for (fragment_id, list) in &mut self.fragment_records {
1631                let repeated = occurrence_counts
1632                    .get(fragment_id)
1633                    .is_some_and(|count| *count > 1);
1634                if !repeated {
1635                    continue;
1636                }
1637                for fragment in &mut list.fragments {
1638                    if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_mut() {
1639                        if boundary.phase() == BoundaryPhase::Start {
1640                            boundary.may_repeat = true;
1641                            if boundary.key.is_none()
1642                                && unkeyed_repeat
1643                                    .as_ref()
1644                                    .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1645                            {
1646                                unkeyed_repeat = Some((
1647                                    boundary.declaration_id,
1648                                    boundary.owner_fragment_id.clone(),
1649                                    boundary.name.clone(),
1650                                ));
1651                            }
1652                        }
1653                    }
1654                }
1655            }
1656            if let Some((_, owner, name)) = unkeyed_repeat {
1657                return Err(Self::missing_boundary_key_error(&owner, &name));
1658            }
1659
1660            if let Some((owner, name)) = self.find_transitively_nested_boundary() {
1661                return Err(Self::transitively_nested_boundary_error(&owner, &name));
1662            }
1663        }
1664        self.finalize_component_span_signals();
1665        Ok(())
1666    }
1667
1668    fn finalize_component_span_signals(&mut self) {
1669        const START: &str = "streaming_span_start:";
1670        const END: &str = "streaming_span_end:";
1671
1672        let spanning: HashSet<String> = self
1673            .fragment_records
1674            .iter()
1675            .filter_map(|(id, list)| list.contains_boundary.then_some(id.clone()))
1676            .collect();
1677        for list in self.fragment_records.values_mut() {
1678            let mut normalized = Vec::with_capacity(list.fragments.len());
1679            for fragment in list.fragments.drain(..) {
1680                let remove = match fragment.fragment.as_ref() {
1681                    Some(Fragment::Signal(signal)) => signal
1682                        .value
1683                        .strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1684                        .and_then(|value| {
1685                            value
1686                                .strip_prefix(START)
1687                                .or_else(|| value.strip_prefix(END))
1688                        })
1689                        .is_some_and(|tag| !spanning.contains(tag)),
1690                    _ => false,
1691                };
1692                if remove {
1693                    continue;
1694                }
1695                if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
1696                    if let Some(WebUIFragment {
1697                        fragment: Some(Fragment::Raw(previous)),
1698                    }) = normalized.last_mut()
1699                    {
1700                        previous.value.push_str(&raw.value);
1701                        continue;
1702                    }
1703                }
1704                normalized.push(fragment);
1705            }
1706            list.fragments = normalized;
1707        }
1708    }
1709
1710    fn boundary_graph(&self) -> (HashMap<String, Vec<BoundaryGraphEdge>>, Vec<String>) {
1711        let mut outgoing: HashMap<String, Vec<BoundaryGraphEdge>> =
1712            HashMap::with_capacity(self.fragment_records.len());
1713        let mut direct_boundary_records = Vec::new();
1714
1715        for (fragment_id, list) in &self.fragment_records {
1716            let edges = outgoing.entry(fragment_id.clone()).or_default();
1717            for fragment in &list.fragments {
1718                match fragment.fragment.as_ref() {
1719                    Some(Fragment::Component(component)) => {
1720                        Self::push_boundary_edge(edges, &component.fragment_id);
1721                    }
1722                    Some(Fragment::ForLoop(for_loop)) => {
1723                        Self::push_boundary_edge(edges, &for_loop.fragment_id);
1724                    }
1725                    Some(Fragment::IfCond(if_cond)) => {
1726                        Self::push_boundary_edge(edges, &if_cond.fragment_id);
1727                    }
1728                    Some(Fragment::Boundary(boundary)) => {
1729                        if boundary.phase() == BoundaryPhase::Start {
1730                            direct_boundary_records.push(fragment_id.clone());
1731                        }
1732                    }
1733                    Some(Fragment::Route(route)) => {
1734                        Self::push_route_boundary_edges(edges, route);
1735                    }
1736                    _ => {}
1737                }
1738            }
1739        }
1740        (outgoing, direct_boundary_records)
1741    }
1742
1743    fn push_boundary_edge(edges: &mut Vec<BoundaryGraphEdge>, target: &str) {
1744        if !target.is_empty() {
1745            edges.push(BoundaryGraphEdge {
1746                target: target.to_string(),
1747            });
1748        }
1749    }
1750
1751    fn push_route_boundary_edges(edges: &mut Vec<BoundaryGraphEdge>, root: &WebUiFragmentRoute) {
1752        let mut pending = vec![root];
1753        while let Some(route) = pending.pop() {
1754            Self::push_boundary_edge(edges, &route.fragment_id);
1755            Self::push_boundary_edge(edges, &route.content_fragment_id);
1756            Self::push_boundary_edge(edges, &route.pending_component);
1757            Self::push_boundary_edge(edges, &route.error_component);
1758            pending.extend(route.children.iter());
1759        }
1760    }
1761
1762    fn mark_boundary_records(
1763        &mut self,
1764        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1765        direct_boundary_records: Vec<String>,
1766    ) {
1767        for list in self.fragment_records.values_mut() {
1768            list.contains_boundary = false;
1769        }
1770
1771        let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
1772        for (parent, edges) in outgoing {
1773            for edge in edges {
1774                reverse
1775                    .entry(edge.target.as_str())
1776                    .or_default()
1777                    .push(parent.as_str());
1778            }
1779        }
1780
1781        let mut marked = HashSet::with_capacity(self.fragment_records.len());
1782        let mut pending = direct_boundary_records;
1783        while let Some(fragment_id) = pending.pop() {
1784            if !marked.insert(fragment_id.clone()) {
1785                continue;
1786            }
1787            if let Some(list) = self.fragment_records.get_mut(&fragment_id) {
1788                list.contains_boundary = true;
1789            }
1790            if let Some(parents) = reverse.get(fragment_id.as_str()) {
1791                pending.extend(parents.iter().map(|parent| (*parent).to_string()));
1792            }
1793        }
1794    }
1795
1796    fn boundary_occurrence_counts(
1797        &self,
1798        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1799    ) -> HashMap<String, u8> {
1800        let mut incoming: HashMap<&str, Vec<&str>> = HashMap::new();
1801        for (parent, edges) in outgoing {
1802            for edge in edges {
1803                incoming
1804                    .entry(edge.target.as_str())
1805                    .or_default()
1806                    .push(parent.as_str());
1807            }
1808        }
1809
1810        let mut aggregate = HashMap::with_capacity(self.fragment_records.len());
1811        for root in &self.token_roots {
1812            if !self.fragment_records.contains_key(root) {
1813                continue;
1814            }
1815            let counts = self.boundary_occurrences_from_root(root.as_str(), outgoing, &incoming);
1816            for (fragment_id, count) in counts {
1817                let current = aggregate.entry(fragment_id).or_insert(0);
1818                *current = (*current).max(count);
1819            }
1820        }
1821        aggregate
1822    }
1823
1824    fn boundary_occurrences_from_root(
1825        &self,
1826        root: &str,
1827        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1828        incoming: &HashMap<&str, Vec<&str>>,
1829    ) -> HashMap<String, u8> {
1830        let mut counts: HashMap<String, u8> = HashMap::with_capacity(self.fragment_records.len());
1831        counts.insert(root.to_string(), 1);
1832        let mut pending = vec![root.to_string()];
1833        while let Some(parent) = pending.pop() {
1834            let Some(edges) = outgoing.get(&parent) else {
1835                continue;
1836            };
1837            for edge in edges {
1838                let next = Self::recompute_boundary_occurrences(
1839                    edge.target.as_str(),
1840                    root,
1841                    incoming,
1842                    &counts,
1843                );
1844                let current = counts.get(&edge.target).copied().unwrap_or(0);
1845                if next > current {
1846                    counts.insert(edge.target.clone(), next);
1847                    pending.push(edge.target.clone());
1848                }
1849            }
1850        }
1851        counts
1852    }
1853
1854    fn recompute_boundary_occurrences(
1855        fragment_id: &str,
1856        root: &str,
1857        incoming: &HashMap<&str, Vec<&str>>,
1858        counts: &HashMap<String, u8>,
1859    ) -> u8 {
1860        let mut count = u8::from(fragment_id == root);
1861        let Some(parents) = incoming.get(fragment_id) else {
1862            return count;
1863        };
1864        for parent in parents {
1865            let parent_count = counts.get(*parent).copied().unwrap_or(0);
1866            count = count.saturating_add(parent_count).min(2);
1867            if count == 2 {
1868                break;
1869            }
1870        }
1871        count
1872    }
1873
1874    /// Locate the lowest-numbered declaration a `<for>` repeat body reaches.
1875    ///
1876    /// Reachability is a single multi-source forward walk seeded with every
1877    /// repeat body record, so a graph with many loops still costs one pass. It
1878    /// follows the same component/condition/loop/route edges the presence
1879    /// analysis uses, plus the route-child records an enclosing route mounts at
1880    /// its component's `<outlet />` — the one runtime edge that has no
1881    /// lexical counterpart.
1882    fn find_repeat_reachable_boundary(&self) -> Option<RepeatBoundaryViolation> {
1883        let mut origin: HashMap<&str, &str> = HashMap::new();
1884        let mut pending: Vec<&str> = Vec::new();
1885        let mut repeats: HashMap<&str, RepeatSite<'_>> = HashMap::new();
1886        let mut seeds: Vec<(&str, RepeatSite<'_>)> = Vec::new();
1887
1888        for (owner, list) in &self.fragment_records {
1889            for fragment in &list.fragments {
1890                let Some(Fragment::ForLoop(for_loop)) = fragment.fragment.as_ref() else {
1891                    continue;
1892                };
1893                if for_loop.fragment_id.is_empty() {
1894                    continue;
1895                }
1896                let body = for_loop.fragment_id.as_str();
1897                seeds.push((
1898                    body,
1899                    RepeatSite {
1900                        owner: owner.as_str(),
1901                        item: for_loop.item.as_str(),
1902                        collection: for_loop.collection.as_str(),
1903                    },
1904                ));
1905            }
1906        }
1907        seeds.sort_unstable_by(|(left_body, left), (right_body, right)| {
1908            (left.owner, left.item, left.collection, *left_body).cmp(&(
1909                right.owner,
1910                right.item,
1911                right.collection,
1912                *right_body,
1913            ))
1914        });
1915        for (body, site) in seeds {
1916            if !origin.contains_key(body) {
1917                repeats.insert(body, site);
1918                origin.insert(body, body);
1919                pending.push(body);
1920            }
1921        }
1922        // `pending` is a LIFO stack. Reverse the sorted seeds so the lowest
1923        // repeat site owns every shared reachable record deterministically.
1924        pending.reverse();
1925        if pending.is_empty() {
1926            return None;
1927        }
1928
1929        let mounts = self.route_mount_targets();
1930        let mut found: Option<(u32, &str, &WebUiFragmentBoundary)> = None;
1931        let mut targets: Vec<&str> = Vec::new();
1932        while let Some(id) = pending.pop() {
1933            let Some(seed) = origin.get(id).copied() else {
1934                continue;
1935            };
1936            let Some(list) = self.fragment_records.get(id) else {
1937                continue;
1938            };
1939            for fragment in &list.fragments {
1940                if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
1941                    if boundary.phase() == BoundaryPhase::Start
1942                        && found
1943                            .as_ref()
1944                            .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1945                    {
1946                        found = Some((boundary.declaration_id, seed, boundary));
1947                    }
1948                }
1949            }
1950            targets.clear();
1951            push_child_records(list, &mut targets);
1952            if let Some(mounted) = mounts.get(id) {
1953                targets.extend(mounted.iter().copied());
1954            }
1955            for target in targets.drain(..) {
1956                if target.is_empty() {
1957                    continue;
1958                }
1959                if !origin.contains_key(target) {
1960                    origin.insert(target, seed);
1961                    pending.push(target);
1962                }
1963            }
1964        }
1965
1966        found.map(|(_, seed, boundary)| {
1967            let site = repeats.get(seed);
1968            RepeatBoundaryViolation {
1969                repeat_owner: site.map_or_else(String::new, |site| site.owner.to_string()),
1970                each: site.map_or_else(String::new, |site| {
1971                    format!("{} in {}", site.item, site.collection)
1972                }),
1973                boundary_owner: boundary.owner_fragment_id.clone(),
1974                boundary_name: boundary.name.clone(),
1975            }
1976        })
1977    }
1978
1979    /// Map every `<outlet />`-hosting record to the child-route records a
1980    /// route can mount there.
1981    ///
1982    /// `<outlet />` is the only render edge with no lexical counterpart: the
1983    /// mounted record is chosen from the enclosing route's children at request
1984    /// time. Resolving it needs the parent route's render closure, so the map
1985    /// is built once and reused by the repeat walk instead of being recomputed
1986    /// per outlet.
1987    fn route_mount_targets(&self) -> HashMap<&str, Vec<&str>> {
1988        let outlet_hosts = self.outlet_host_records();
1989        if outlet_hosts.is_empty() {
1990            return HashMap::new();
1991        }
1992
1993        let mut mounts: HashMap<&str, Vec<&str>> = HashMap::new();
1994        let mut children: Vec<&str> = Vec::new();
1995        let mut closure: HashSet<&str> = HashSet::new();
1996        let mut pending: Vec<&str> = Vec::new();
1997        let mut targets: Vec<&str> = Vec::new();
1998        for list in self.fragment_records.values() {
1999            for fragment in &list.fragments {
2000                let Some(Fragment::Route(root)) = fragment.fragment.as_ref() else {
2001                    continue;
2002                };
2003                let mut routes = vec![root];
2004                while let Some(route) = routes.pop() {
2005                    routes.extend(route.children.iter());
2006                    if route.children.is_empty() {
2007                        continue;
2008                    }
2009                    children.clear();
2010                    for child in &route.children {
2011                        push_route_records(&mut children, child);
2012                    }
2013                    closure.clear();
2014                    pending.clear();
2015                    for host in [
2016                        route.fragment_id.as_str(),
2017                        route.content_fragment_id.as_str(),
2018                    ] {
2019                        if !host.is_empty() && closure.insert(host) {
2020                            pending.push(host);
2021                        }
2022                    }
2023                    while let Some(id) = pending.pop() {
2024                        if outlet_hosts.contains(id) {
2025                            mounts
2026                                .entry(id)
2027                                .or_default()
2028                                .extend(children.iter().copied());
2029                        }
2030                        let Some(list) = self.fragment_records.get(id) else {
2031                            continue;
2032                        };
2033                        targets.clear();
2034                        push_child_records(list, &mut targets);
2035                        for target in targets.drain(..) {
2036                            if !target.is_empty() && closure.insert(target) {
2037                                pending.push(target);
2038                            }
2039                        }
2040                    }
2041                }
2042            }
2043        }
2044        mounts
2045    }
2046
2047    /// Records that render an `<outlet />`.
2048    fn outlet_host_records(&self) -> HashSet<&str> {
2049        self.fragment_records
2050            .iter()
2051            .filter(|(_, list)| {
2052                list.fragments
2053                    .iter()
2054                    .any(|fragment| matches!(fragment.fragment.as_ref(), Some(Fragment::Outlet(_))))
2055            })
2056            .map(|(id, _)| id.as_str())
2057            .collect()
2058    }
2059
2060    /// Locate the lowest-numbered declaration whose inline body reaches another
2061    /// boundary through a component, condition, loop, or route record.
2062    ///
2063    /// Lexical nesting is already rejected while parsing, so this only has to
2064    /// look at the child records referenced between a start and end marker.
2065    fn find_transitively_nested_boundary(&self) -> Option<(String, String)> {
2066        let mut nested: Option<(u32, &str, &str)> = None;
2067        for list in self.fragment_records.values() {
2068            let mut active: Option<&WebUiFragmentBoundary> = None;
2069            for fragment in &list.fragments {
2070                let Some(boundary) = active else {
2071                    if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
2072                        if boundary.phase() == BoundaryPhase::Start {
2073                            active = Some(boundary);
2074                        }
2075                    }
2076                    continue;
2077                };
2078                if let Some(Fragment::Boundary(marker)) = fragment.fragment.as_ref() {
2079                    if marker.phase() == BoundaryPhase::End {
2080                        active = None;
2081                    }
2082                    continue;
2083                }
2084                if !self.fragment_reaches_boundary(fragment) {
2085                    continue;
2086                }
2087                if nested
2088                    .as_ref()
2089                    .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
2090                {
2091                    nested = Some((
2092                        boundary.declaration_id,
2093                        boundary.owner_fragment_id.as_str(),
2094                        boundary.name.as_str(),
2095                    ));
2096                }
2097            }
2098        }
2099        nested.map(|(_, owner, name)| (owner.to_string(), name.to_string()))
2100    }
2101
2102    /// Whether a fragment inside a boundary body reaches a nested declaration
2103    /// through one of its child records.
2104    fn fragment_reaches_boundary(&self, fragment: &WebUIFragment) -> bool {
2105        let reaches = |id: &str| {
2106            !id.is_empty()
2107                && self
2108                    .fragment_records
2109                    .get(id)
2110                    .is_some_and(|list| list.contains_boundary)
2111        };
2112        match fragment.fragment.as_ref() {
2113            Some(Fragment::Component(component)) => reaches(&component.fragment_id),
2114            Some(Fragment::ForLoop(for_loop)) => reaches(&for_loop.fragment_id),
2115            Some(Fragment::IfCond(if_cond)) => reaches(&if_cond.fragment_id),
2116            Some(Fragment::Route(route)) => {
2117                let mut pending = vec![route];
2118                while let Some(current) = pending.pop() {
2119                    if reaches(&current.fragment_id)
2120                        || reaches(&current.content_fragment_id)
2121                        || reaches(&current.pending_component)
2122                        || reaches(&current.error_component)
2123                    {
2124                        return true;
2125                    }
2126                    pending.extend(current.children.iter());
2127                }
2128                false
2129            }
2130            _ => false,
2131        }
2132    }
2133
2134    #[cold]
2135    #[inline(never)]
2136    fn transitively_nested_boundary_error(owner: &str, name: &str) -> ParserError {
2137        Diagnostic::error(format!(
2138            "boundary \"{name}\" transitively contains another boundary"
2139        ))
2140        .code(codes::NESTED_BOUNDARY)
2141        .component(owner)
2142        .element("boundary")
2143        .snippet(format!("name=\"{name}\""))
2144        .help("remove the inner declaration or move it outside the enclosing boundary, including declarations reached through components and runtime branches")
2145        .into()
2146    }
2147
2148    /// Build the error for a boundary a `<for>` repeat body reaches through a
2149    /// component, condition, route, or outlet.
2150    #[cold]
2151    #[inline(never)]
2152    fn repeat_reachable_boundary_error(violation: &RepeatBoundaryViolation) -> ParserError {
2153        let RepeatBoundaryViolation {
2154            repeat_owner,
2155            each,
2156            boundary_owner,
2157            boundary_name,
2158        } = violation;
2159        Diagnostic::error(format!(
2160            "boundary \"{boundary_name}\" in {boundary_owner} is reachable from a <for> repeat body"
2161        ))
2162        .code(codes::BOUNDARY_IN_REPEAT)
2163        .component(repeat_owner)
2164        .element("for")
2165        .snippet(format!("each=\"{each}\""))
2166        .help(
2167            "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
2168             whole <for> in one <boundary> to render the list as a single independently paced \
2169             region — this includes declarations reached through components, runtime branches, \
2170             routes, and outlets",
2171        )
2172        .into()
2173    }
2174
2175    /// `src` of every authored `<script type="module">` outside any
2176    /// `<boundary>`, in document order, from the most recent top-level
2177    /// [`HtmlParser::parse`] call.
2178    ///
2179    /// These are the page's critical module entries — the ones whose shared
2180    /// chunks belong in `<link rel="modulepreload">`. Island loaders placed
2181    /// inside a boundary are excluded, because deferring them is the point.
2182    ///
2183    /// A `src` containing `{{` is skipped: it is a binding resolved per
2184    /// request, so no build-time artifact can be matched to it.
2185    #[must_use]
2186    pub fn module_entry_srcs(&self) -> &[String] {
2187        &self.module_entry_srcs
2188    }
2189
2190    /// Iterate component tags and whether each effectively uses Shadow DOM.
2191    ///
2192    /// This remains available independently of parser plugins so protocol
2193    /// builders can persist effective per-component ownership in plugin-free builds.
2194    pub fn component_shadow_dom_usage(&self) -> impl Iterator<Item = (&str, bool)> {
2195        self.component_dom_analyses
2196            .iter()
2197            .map(|(tag_name, analysis)| (tag_name.as_str(), analysis.uses_shadow_dom))
2198    }
2199
2200    /// Iterate non-eager component work-policy codes.
2201    pub fn component_work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
2202        self.component_registry.work_policies()
2203    }
2204
2205    /// Take any post-parse artifacts captured by the parser plugin.
2206    ///
2207    /// # Errors
2208    ///
2209    /// Returns [`ParserError::Template`] if a tracked component contains an
2210    /// invalid `@event` handler or a non-braced `w-ref` binding.
2211    pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
2212        self.plugin
2213            .take()
2214            .map_or(Ok(ParserPluginArtifacts::None), |plugin| plugin.finish())
2215    }
2216
2217    /// Take the accumulated CSS tokens as a sorted, deduplicated `Vec`.
2218    ///
2219    /// Convenience wrapper around [`HtmlParser::token_analysis`].
2220    #[must_use]
2221    pub fn take_tokens(&mut self) -> Vec<String> {
2222        self.token_analysis().protocol_tokens
2223    }
2224
2225    /// Analyze CSS token requirements from the parsed fragment/component graph.
2226    ///
2227    /// Each token candidate in a `var()` fallback chain is removed when covered
2228    /// by a same-block definition or an unconditional root/host default in the
2229    /// current fragment or an ancestor component/root. The returned list is
2230    /// sorted and deduplicated from the remaining unresolved candidates.
2231    #[must_use]
2232    pub fn token_analysis(&self) -> CssTokenAnalysis {
2233        let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
2234        let mut protocol_token_set = HashSet::new();
2235        for chain in &fallback_chains {
2236            for token in &chain.tokens {
2237                protocol_token_set.insert(token.as_str());
2238            }
2239        }
2240        let mut protocol_tokens: Vec<String> =
2241            protocol_token_set.into_iter().map(str::to_owned).collect();
2242        protocol_tokens.sort();
2243        CssTokenAnalysis {
2244            protocol_tokens,
2245            fallback_chains,
2246            token_sites,
2247        }
2248    }
2249
2250    fn collect_unresolved_fallback_chains(
2251        &self,
2252    ) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
2253        let mut out = UnresolvedTokens::default();
2254        let mut available_counts: HashMap<&str, usize> = HashMap::new();
2255        let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
2256        let mut active_fragments = HashSet::new();
2257        let mut visits =
2258            (!self.named_for_fragment_ids.is_empty()).then(scoped_visits::ScopedVisits::default);
2259        for root in self.token_roots.iter().rev() {
2260            ops.push(TokenGraphOp::EnterFragment(root.as_str()));
2261        }
2262
2263        while let Some(op) = ops.pop() {
2264            match op {
2265                TokenGraphOp::EnterFragment(fragment_id) => {
2266                    if let Some(visits) = &mut visits {
2267                        if active_fragments.contains(fragment_id)
2268                            || !visits.insert(fragment_id, available_counts.keys().copied())
2269                        {
2270                            continue;
2271                        }
2272                        active_fragments.insert(fragment_id);
2273                        ops.push(TokenGraphOp::ExitFragment(fragment_id));
2274                    }
2275                    self.enter_token_fragment(
2276                        fragment_id,
2277                        &mut available_counts,
2278                        &mut out,
2279                        &mut ops,
2280                    );
2281                }
2282                TokenGraphOp::ExitFragment(fragment_id) => {
2283                    active_fragments.remove(fragment_id);
2284                }
2285                TokenGraphOp::EnterComponent(tag_name) => {
2286                    self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
2287                }
2288                TokenGraphOp::EnterRoute(route) => {
2289                    self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
2290                }
2291                TokenGraphOp::ExitDefinitions(definitions) => {
2292                    remove_token_definitions(definitions, &mut available_counts);
2293                }
2294            }
2295        }
2296
2297        (out.chains, out.sites)
2298    }
2299
2300    fn enter_token_fragment<'a>(
2301        &'a self,
2302        fragment_id: &'a str,
2303        available_counts: &mut HashMap<&'a str, usize>,
2304        out: &mut UnresolvedTokens,
2305        ops: &mut Vec<TokenGraphOp<'a>>,
2306    ) {
2307        if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
2308            add_token_definitions(&css.definitions, available_counts);
2309            // Inline `<style>` tokens record their owning fragment (the entry
2310            // file). A fragment may carry several `<style>` blocks, so an
2311            // offset into one body is ambiguous — record the owner only.
2312            record_unresolved_requirements(
2313                &css.fallback_chains,
2314                available_counts,
2315                fragment_id,
2316                None,
2317                out,
2318            );
2319            ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
2320        }
2321
2322        let Some(fragments) = self.fragment_records.get(fragment_id) else {
2323            return;
2324        };
2325        for fragment in fragments.fragments.iter().rev() {
2326            match fragment.fragment.as_ref() {
2327                Some(web_ui_fragment::Fragment::Component(component)) => {
2328                    ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
2329                }
2330                Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
2331                    ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
2332                }
2333                Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
2334                    ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
2335                }
2336                Some(web_ui_fragment::Fragment::Route(route)) => {
2337                    ops.push(TokenGraphOp::EnterRoute(route));
2338                }
2339                _ => {}
2340            }
2341        }
2342    }
2343
2344    fn enter_token_component<'a>(
2345        &'a self,
2346        tag_name: &'a str,
2347        available_counts: &mut HashMap<&'a str, usize>,
2348        out: &mut UnresolvedTokens,
2349        ops: &mut Vec<TokenGraphOp<'a>>,
2350    ) {
2351        let Some(component) = self.component_registry.get(tag_name) else {
2352            return;
2353        };
2354        add_token_definitions(&component.css_definitions, available_counts);
2355        record_unresolved_requirements(
2356            &component.css_fallback_chains,
2357            available_counts,
2358            &css_owner_label(tag_name),
2359            self.component_registry.diagnostic_css_content(tag_name),
2360            out,
2361        );
2362        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2363        ops.push(TokenGraphOp::EnterFragment(tag_name));
2364    }
2365
2366    fn enter_token_route<'a>(
2367        &'a self,
2368        route: &'a WebUiFragmentRoute,
2369        available_counts: &mut HashMap<&'a str, usize>,
2370        out: &mut UnresolvedTokens,
2371        ops: &mut Vec<TokenGraphOp<'a>>,
2372    ) {
2373        if !route.content_fragment_id.is_empty() {
2374            ops.push(TokenGraphOp::EnterFragment(
2375                route.content_fragment_id.as_str(),
2376            ));
2377        }
2378        let Some(component) = self.component_registry.get(&route.fragment_id) else {
2379            return;
2380        };
2381        add_token_definitions(&component.css_definitions, available_counts);
2382        record_unresolved_requirements(
2383            &component.css_fallback_chains,
2384            available_counts,
2385            &css_owner_label(&route.fragment_id),
2386            self.component_registry
2387                .diagnostic_css_content(&route.fragment_id),
2388            out,
2389        );
2390        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2391        if !route.error_component.is_empty() {
2392            ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
2393        }
2394        if !route.pending_component.is_empty() {
2395            ops.push(TokenGraphOp::EnterComponent(
2396                route.pending_component.as_str(),
2397            ));
2398        }
2399        for child in route.children.iter().rev() {
2400            ops.push(TokenGraphOp::EnterRoute(child));
2401        }
2402        ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
2403    }
2404
2405    fn record_fragment_css_tokens(
2406        &mut self,
2407        definitions: HashSet<String>,
2408        fallback_chains: Vec<CssFallbackChain>,
2409    ) {
2410        let css = self
2411            .fragment_css_tokens
2412            .entry(self.current_fragment_id.clone())
2413            .or_default();
2414        css.definitions.extend(definitions);
2415        css.definitions.sort();
2416        css.definitions.dedup();
2417        css.fallback_chains.extend(fallback_chains);
2418    }
2419
2420    /// Parse HTML content to generate WebUI fragments.
2421    pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2422        let fragment_key = fragment_id.to_string();
2423        let is_token_root = self.in_progress_fragments.is_empty();
2424        if is_token_root {
2425            self.module_entry_srcs.clear();
2426        }
2427        self.boundary_names_by_owner.remove(&fragment_key);
2428        // Save the caller's fragment id and restore it before returning. A
2429        // component parse recurses through `enter_component_directive`
2430        // (`self.parse(child, …)`); without restoring, the parent would keep
2431        // parsing with `current_fragment_id` pointing at the child, so a later
2432        // diagnostic in the parent would be attributed to the wrong owner.
2433        let previous_fragment_id =
2434            std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
2435        if !self.in_progress_fragments.insert(fragment_key.clone()) {
2436            let err = self
2437                .authoring_error(
2438                    codes::RECURSIVE_TEMPLATE,
2439                    format!("recursive template reference while parsing <{fragment_id}>"),
2440                )
2441                .help(
2442                    "move the recursive usage behind runtime data, or split the component graph \
2443                     so templates do not reference themselves at build time",
2444                )
2445                .into();
2446            self.current_fragment_id = previous_fragment_id;
2447            return Err(err);
2448        }
2449        if is_token_root && !self.token_roots.contains(&fragment_key) {
2450            self.token_roots.push(fragment_key.clone());
2451        }
2452
2453        // Lexical directive/HTML context belongs to one authored template.
2454        // Component parsing may recurse while its callsite is inside a loop or
2455        // boundary, but those callsite properties are computed later by the
2456        // fragment graph analysis rather than leaking into the component's
2457        // authored syntax.
2458        let previous_in_boundary = std::mem::replace(&mut self.in_boundary, false);
2459        let previous_boundary_ancestor_depth = self.boundary_ancestor_depth;
2460        if previous_in_boundary {
2461            self.boundary_ancestor_depth += 1;
2462        }
2463        let previous_body_depth = std::mem::replace(&mut self.body_depth, 0);
2464        let previous_for_depth = std::mem::replace(&mut self.for_depth, 0);
2465        let previous_route_depth = std::mem::replace(&mut self.route_depth, 0);
2466        let previous_foster_depth = std::mem::replace(&mut self.foster_context_depth, 0);
2467        let previous_parent_scope = self.boundary_parent_scope.take();
2468        let previous_named_for_loops = std::mem::take(&mut self.named_for_loops);
2469        let owner_index = self.loop_owner_indices.len() + 1;
2470        self.loop_owner_indices
2471            .entry(fragment_key.clone())
2472            .or_insert(owner_index);
2473
2474        let mut result = self.parse_inner(fragment_id, html_content);
2475        if is_token_root && result.is_ok() {
2476            result = self.finalize_boundary_metadata();
2477            if result.is_ok() {
2478                self.finalize_outlet_warnings();
2479            }
2480        }
2481
2482        self.in_boundary = previous_in_boundary;
2483        self.boundary_ancestor_depth = previous_boundary_ancestor_depth;
2484        self.body_depth = previous_body_depth;
2485        self.for_depth = previous_for_depth;
2486        self.route_depth = previous_route_depth;
2487        self.foster_context_depth = previous_foster_depth;
2488        self.boundary_parent_scope = previous_parent_scope;
2489        self.named_for_loops = previous_named_for_loops;
2490        self.in_progress_fragments.remove(&fragment_key);
2491        self.current_fragment_id = previous_fragment_id;
2492        result
2493    }
2494
2495    fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2496        if html_content.len() > MAX_TEMPLATE_BYTES {
2497            return Err(ParserError::Html(format!(
2498                "Template '{fragment_id}' is {} bytes, which exceeds the {MAX_TEMPLATE_BYTES} byte parser limit. Split very large templates into components or reduce generated markup before build.",
2499                html_content.len()
2500            )));
2501        }
2502
2503        // Reset sub-fragments for new parse
2504        self.raw_buffer.clear();
2505        if let Some(ref mut plugin) = self.plugin {
2506            plugin.begin_fragment(FragmentContext { id: fragment_id });
2507        }
2508
2509        let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
2510        self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
2511        self.named_for_loops.validate(fragment_id, html_content)?;
2512
2513        self.flush_raw_buffer(&mut entry_fragment);
2514
2515        self.fragment_records.insert(
2516            fragment_id.to_string(),
2517            FragmentList {
2518                fragments: entry_fragment,
2519                contains_boundary: false,
2520            },
2521        );
2522
2523        Ok(())
2524    }
2525
2526    /// Add raw content to the buffer
2527    fn add_raw_fragment(&mut self, content: &str) {
2528        if !content.is_empty() {
2529            self.raw_buffer.push_str(content);
2530        }
2531    }
2532
2533    /// Add a for fragment, flushing raw buffer first
2534    fn add_for_fragment(
2535        &mut self,
2536        item: String,
2537        collection: String,
2538        fragment_id: String,
2539        fragments: &mut Vec<WebUIFragment>,
2540    ) {
2541        self.flush_raw_buffer(fragments);
2542        fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
2543    }
2544
2545    /// Add an if fragment, flushing raw buffer first
2546    fn add_if_fragment(
2547        &mut self,
2548        condition: ConditionExpr,
2549        fragment_id: String,
2550        fragments: &mut Vec<WebUIFragment>,
2551    ) {
2552        self.flush_raw_buffer(fragments);
2553        fragments.push(WebUIFragment::if_cond(condition, fragment_id));
2554    }
2555
2556    /// Add a non-raw fragment, flushing the raw buffer first if needed
2557    fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
2558        self.flush_raw_buffer(fragments);
2559        fragments.push(fragment);
2560    }
2561
2562    /// Flush the raw buffer into fragments if not empty
2563    fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
2564        if !self.raw_buffer.is_empty() {
2565            fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
2566        }
2567    }
2568
2569    /// Returns true when a text node should be emitted into the fragment stream.
2570    ///
2571    /// Pure formatting runs that contain line breaks are still dropped, but
2572    /// inline whitespace-only separators such as the spaces around `&gt;` in
2573    /// `{{sectionName}} &gt; {{topicName}}` must be preserved.
2574    fn should_emit_text_content(content: &str) -> bool {
2575        if content.is_empty() {
2576            return false;
2577        }
2578
2579        if !content.trim().is_empty() {
2580            return true;
2581        }
2582
2583        content.chars().all(char::is_whitespace)
2584            && !content.contains('\n')
2585            && !content.contains('\r')
2586    }
2587
2588    fn parse_range(
2589        &mut self,
2590        source: &str,
2591        range: Range<usize>,
2592        fragments: &mut Vec<WebUIFragment>,
2593        depth: usize,
2594    ) -> Result<()> {
2595        let mut ops = vec![ParseOp::Parse { range, depth }];
2596        let mut outlet_seen = false;
2597
2598        while let Some(op) = ops.pop() {
2599            match op {
2600                ParseOp::Parse { range, depth } => {
2601                    if depth > MAX_TEMPLATE_DEPTH {
2602                        return Err(self
2603                            .html_error(
2604                                codes::EXCESSIVE_NESTING,
2605                                format!(
2606                                    "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
2607                                ),
2608                                source,
2609                                range.start,
2610                            )
2611                            .help(
2612                                "split deeply nested markup into components, or reduce generated nesting",
2613                            )
2614                            .into());
2615                    }
2616
2617                    let end = range.end;
2618                    let mut index = range.start;
2619                    while index < end {
2620                        let remaining = &source[index..end];
2621                        if remaining.starts_with("<!--") {
2622                            let Some(close) = html::find_comment_close(remaining) else {
2623                                return Err(self
2624                                    .html_error(
2625                                        codes::UNTERMINATED_HTML_COMMENT,
2626                                        "unterminated HTML comment",
2627                                        source,
2628                                        index,
2629                                    )
2630                                    .help("close the comment with `-->`")
2631                                    .into());
2632                            };
2633                            index += close;
2634                            continue;
2635                        }
2636
2637                        if remaining.starts_with("<!") {
2638                            let Some(close) = html::find_declaration_close(remaining) else {
2639                                return Err(self
2640                                    .html_error(
2641                                        codes::UNTERMINATED_HTML_DECLARATION,
2642                                        "unterminated HTML declaration",
2643                                        source,
2644                                        index,
2645                                    )
2646                                    .help("close the declaration with `>`")
2647                                    .into());
2648                            };
2649                            self.add_raw_fragment(&remaining[..close]);
2650                            index += close;
2651                            continue;
2652                        }
2653
2654                        if remaining.starts_with('<') {
2655                            let Some(tag) = html::parse_tag(remaining) else {
2656                                return Err(self
2657                                    .html_error(
2658                                        codes::MALFORMED_HTML_TAG,
2659                                        "malformed HTML tag",
2660                                        source,
2661                                        index,
2662                                    )
2663                                    .help(
2664                                        "close the tag with `>`, or escape a literal `<` as `&lt;`",
2665                                    )
2666                                    .into());
2667                            };
2668                            if tag.closing {
2669                                return Err(self
2670                                    .html_error(
2671                                        codes::UNEXPECTED_CLOSING_TAG,
2672                                        format!("unexpected closing tag </{}>", tag.name),
2673                                        source,
2674                                        index,
2675                                    )
2676                                    .help("remove it, or add the matching opening tag before it")
2677                                    .into());
2678                            }
2679
2680                            let content_start = index + tag.close + 1;
2681                            let (content_end, close_end) = if tag.self_closing
2682                                || html::is_void_element(tag.name)
2683                            {
2684                                (content_start, content_start)
2685                            } else if let Some((close_start, close_end)) =
2686                                html::find_matching_end(remaining, tag.name, tag.close + 1)
2687                            {
2688                                (index + close_start, index + close_end)
2689                            } else {
2690                                return Err(self
2691                                    .html_error(
2692                                        codes::UNCLOSED_HTML_TAG,
2693                                        format!("unclosed <{}> tag", tag.name),
2694                                        source,
2695                                        index,
2696                                    )
2697                                    .help(format!(
2698                                        "add the matching </{}> closing tag, or make the element self-closing",
2699                                        tag.name
2700                                    ))
2701                                    .into());
2702                            };
2703
2704                            let element = Element {
2705                                source,
2706                                start: index,
2707                                tag,
2708                                content_start,
2709                                content_end,
2710                                close_end,
2711                            };
2712                            if close_end < end {
2713                                ops.push(ParseOp::Parse {
2714                                    range: close_end..end,
2715                                    depth,
2716                                });
2717                            }
2718
2719                            match element.name() {
2720                                "for" => {
2721                                    self.enter_for_directive(&element, fragments, depth, &mut ops)?;
2722                                }
2723                                "if" => {
2724                                    self.enter_if_directive(&element, fragments, depth, &mut ops)?;
2725                                }
2726                                "route" => {
2727                                    self.process_route_directive(&element, fragments, depth)?;
2728                                }
2729                                "outlet" => {
2730                                    self.flush_raw_buffer(fragments);
2731                                    if outlet_seen {
2732                                        self.warnings.push(self.multiple_outlets_warning(&element));
2733                                    }
2734                                    outlet_seen = true;
2735                                    fragments.push(WebUIFragment::outlet());
2736                                }
2737                                "boundary" => {
2738                                    self.enter_boundary_directive(
2739                                        &element, fragments, depth, &mut ops,
2740                                    )?;
2741                                }
2742                                name if name.eq_ignore_ascii_case("body") => {
2743                                    self.enter_body_element(&element, fragments, depth, &mut ops)?;
2744                                }
2745                                name if name.eq_ignore_ascii_case("head") => {
2746                                    self.enter_head_element(&element, fragments, depth, &mut ops)?;
2747                                }
2748                                name if name.eq_ignore_ascii_case("webui-hydrate") => {
2749                                    return Err(self.authored_webui_hydrate_error(&element));
2750                                }
2751                                name if name.eq_ignore_ascii_case("style") => {
2752                                    self.process_style_element(&element, fragments)?;
2753                                }
2754                                _ if self.component_registry.contains(element.name()) => {
2755                                    self.enter_component_directive(
2756                                        &element, fragments, depth, &mut ops,
2757                                    )?;
2758                                }
2759                                _ => {
2760                                    self.check_component_typo(&element)?;
2761                                    self.enter_regular_element(
2762                                        &element, fragments, depth, &mut ops,
2763                                    )?;
2764                                }
2765                            }
2766
2767                            break;
2768                        }
2769
2770                        let next = remaining.find('<').unwrap_or(remaining.len());
2771                        let text_end = if next == 0 {
2772                            remaining
2773                                .chars()
2774                                .next()
2775                                .map_or(remaining.len(), char::len_utf8)
2776                        } else {
2777                            next
2778                        };
2779                        self.process_text(&remaining[..text_end], fragments)?;
2780                        index += text_end;
2781                    }
2782                }
2783                ParseOp::EmitClose(name) => {
2784                    self.add_raw_fragment("</");
2785                    self.add_raw_fragment(name);
2786                    self.add_raw_fragment(">");
2787                }
2788                ParseOp::EndComponent(name) => {
2789                    self.add_raw_fragment("</");
2790                    self.add_raw_fragment(name);
2791                    self.add_raw_fragment(">");
2792                    self.flush_raw_buffer(fragments);
2793                    fragments.push(structural_signal(format!("streaming_span_end:{name}")));
2794                }
2795                ParseOp::EndHead(name) => {
2796                    self.flush_raw_buffer(fragments);
2797                    fragments.push(structural_signal("head_end"));
2798                    self.add_raw_fragment("</");
2799                    self.add_raw_fragment(name);
2800                    self.add_raw_fragment(">");
2801                }
2802                ParseOp::EndBody(name) => {
2803                    self.flush_raw_buffer(fragments);
2804                    fragments.push(structural_signal("body_end"));
2805                    self.add_raw_fragment("</");
2806                    self.add_raw_fragment(name);
2807                    self.add_raw_fragment(">");
2808                    self.body_depth = self.body_depth.saturating_sub(1);
2809                }
2810                ParseOp::CompleteBoundary { boundary } => {
2811                    self.flush_raw_buffer(fragments);
2812                    fragments.push(WebUIFragment::boundary_end(boundary.declaration_id));
2813                    self.in_boundary = false;
2814                }
2815                ParseOp::RestoreFosterDepth(previous) => {
2816                    self.foster_context_depth = previous;
2817                }
2818                ParseOp::RestoreBoundaryParentScope(previous) => {
2819                    self.boundary_parent_scope = previous;
2820                }
2821                ParseOp::CompleteFor {
2822                    parent,
2823                    item,
2824                    collection,
2825                    fragment_id,
2826                    named_definition,
2827                    previous_for_depth,
2828                } => {
2829                    self.for_depth = previous_for_depth;
2830                    self.flush_raw_buffer(fragments);
2831                    let for_fragment = std::mem::take(fragments);
2832                    *fragments = parent.fragments;
2833                    self.raw_buffer = parent.raw_buffer;
2834
2835                    if !for_fragment.is_empty() || named_definition {
2836                        self.fragment_records.insert(
2837                            fragment_id.clone(),
2838                            FragmentList {
2839                                fragments: for_fragment,
2840                                contains_boundary: false,
2841                            },
2842                        );
2843                    } else {
2844                        continue;
2845                    }
2846
2847                    self.add_for_fragment(item, collection, fragment_id, fragments);
2848                }
2849                ParseOp::CompleteIf {
2850                    parent,
2851                    condition,
2852                    fragment_id,
2853                } => {
2854                    self.flush_raw_buffer(fragments);
2855                    let if_fragment = std::mem::take(fragments);
2856                    *fragments = parent.fragments;
2857                    self.raw_buffer = parent.raw_buffer;
2858
2859                    self.fragment_records.insert(
2860                        fragment_id.clone(),
2861                        FragmentList {
2862                            fragments: if_fragment,
2863                            contains_boundary: false,
2864                        },
2865                    );
2866                    self.add_if_fragment(condition, fragment_id, fragments);
2867                }
2868            }
2869        }
2870
2871        Ok(())
2872    }
2873
2874    fn enter_regular_element<'a>(
2875        &mut self,
2876        element: &Element<'a>,
2877        fragments: &mut Vec<WebUIFragment>,
2878        depth: usize,
2879        ops: &mut Vec<ParseOp<'a>>,
2880    ) -> Result<()> {
2881        if !self.in_boundary
2882            && self.boundary_ancestor_depth == 0
2883            && element.name().eq_ignore_ascii_case("script")
2884        {
2885            self.record_module_entry(element);
2886        }
2887        self.add_raw_fragment("<");
2888        self.add_raw_fragment(element.name());
2889
2890        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2891        if let Some(ref mut p) = self.plugin {
2892            if let Some(data) = p.finish_opening_tag(ElementStartContext {
2893                tag_name: element.name(),
2894                binding_count,
2895            }) {
2896                self.add_fragment(WebUIFragment::plugin(data), fragments);
2897            }
2898        }
2899
2900        if element.self_closing() {
2901            self.add_raw_fragment("/>");
2902            return Ok(());
2903        }
2904
2905        self.add_raw_fragment(">");
2906        if element.name().eq_ignore_ascii_case("template")
2907            && element
2908                .attrs()
2909                .find(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
2910                .and_then(|attr| attr.value)
2911                .is_some_and(|mode| mode.eq_ignore_ascii_case("open"))
2912            && self
2913                .component_dom_analyses
2914                .get(&self.current_fragment_id)
2915                .is_some_and(|analysis| analysis.uses_shadow_dom)
2916        {
2917            // This compiler-owned no-DOM hook lets the server install the
2918            // complete tree-local style closure before hydratable children.
2919            // It is a protocol fragment rather than an HTML marker, so client
2920            // ordinal paths are unchanged.
2921            self.flush_raw_buffer(fragments);
2922            fragments.push(structural_signal(format!(
2923                "shadow_styles:{}",
2924                self.current_fragment_id
2925            )));
2926        }
2927        if !element.is_void() {
2928            if element.close_end() > element.content_end() {
2929                ops.push(ParseOp::EmitClose(element.name()));
2930            }
2931            self.enter_foster_context(element.name(), ops);
2932            self.enter_boundary_parent_scope(element, ops);
2933            ops.push(ParseOp::Parse {
2934                range: element.inner(),
2935                depth: depth + 1,
2936            });
2937        }
2938        Ok(())
2939    }
2940
2941    /// Record an authored `<script type="module" src="...">` as a critical
2942    /// module entry, so the build can preload the chunks it statically imports.
2943    ///
2944    /// Only bare, static `src` values qualify. A `{{binding}}` resolves per
2945    /// request and cannot be matched to a build artifact, and a script without
2946    /// `type="module"` has no ES module graph to preload. Both are skipped
2947    /// silently rather than diagnosed, because either is a perfectly valid
2948    /// thing to author — they just carry no preload information.
2949    ///
2950    /// The caller has already excluded scripts inside a `<boundary>`.
2951    fn record_module_entry(&mut self, element: &Element<'_>) {
2952        let Some(src) = element.attr("src") else {
2953            return;
2954        };
2955        if src.is_empty() || src.contains("{{") {
2956            return;
2957        }
2958        if element
2959            .attrs()
2960            .find(|attr| attr.name.eq_ignore_ascii_case("fetchpriority"))
2961            .and_then(|attr| attr.value)
2962            .is_some_and(|priority| priority.eq_ignore_ascii_case("low"))
2963        {
2964            return;
2965        }
2966        if !element
2967            .attr("type")
2968            .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module"))
2969        {
2970            return;
2971        }
2972        // Document order is meaningful and duplicates are near-impossible, so
2973        // this stays a `Vec` scan rather than paying for a set.
2974        if self.module_entry_srcs.iter().any(|seen| seen == src) {
2975            return;
2976        }
2977        self.module_entry_srcs.push(src.to_string());
2978    }
2979
2980    fn enter_head_element<'a>(
2981        &mut self,
2982        element: &Element<'a>,
2983        fragments: &mut Vec<WebUIFragment>,
2984        depth: usize,
2985        ops: &mut Vec<ParseOp<'a>>,
2986    ) -> Result<()> {
2987        if element.name() == "head" {
2988            // Preserve the lowercase legacy protocol shape exactly: authored
2989            // attributes on `<head>` were ignored and the opening was emitted
2990            // as the literal `<head>`.
2991            self.add_raw_fragment("<head>");
2992        } else {
2993            // origin/main treated mixed-case native tags as ordinary elements.
2994            // Keep their authored name and attributes while adding only the
2995            // separate case-insensitive streaming structure.
2996            self.add_raw_fragment("<");
2997            self.add_raw_fragment(element.name());
2998            let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2999            if let Some(ref mut p) = self.plugin {
3000                if let Some(data) = p.finish_opening_tag(ElementStartContext {
3001                    tag_name: element.name(),
3002                    binding_count,
3003                }) {
3004                    self.add_fragment(WebUIFragment::plugin(data), fragments);
3005                }
3006            }
3007            self.add_raw_fragment(">");
3008        }
3009        self.flush_raw_buffer(fragments);
3010        // Mirrors `body_start`: emitted immediately after the raw opening
3011        // `<head>` fragment and before any child content (including
3012        // authored async scripts), so `render_streaming` can preflight on it.
3013        // Raw signals unknown to the legacy (non-streaming) handler are
3014        // ignored there, so this is a no-op for existing output.
3015        fragments.push(structural_signal("head_start"));
3016        ops.push(ParseOp::EndHead(element.name()));
3017        ops.push(ParseOp::Parse {
3018            range: element.inner(),
3019            depth: depth + 1,
3020        });
3021        Ok(())
3022    }
3023
3024    fn enter_body_element<'a>(
3025        &mut self,
3026        element: &Element<'a>,
3027        fragments: &mut Vec<WebUIFragment>,
3028        depth: usize,
3029        ops: &mut Vec<ParseOp<'a>>,
3030    ) -> Result<()> {
3031        self.add_raw_fragment("<");
3032        self.add_raw_fragment(element.name());
3033        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
3034        if let Some(ref mut p) = self.plugin {
3035            if let Some(data) = p.finish_opening_tag(ElementStartContext {
3036                tag_name: element.name(),
3037                binding_count,
3038            }) {
3039                self.add_fragment(WebUIFragment::plugin(data), fragments);
3040            }
3041        }
3042        self.add_raw_fragment(">");
3043        self.flush_raw_buffer(fragments);
3044        fragments.push(structural_signal("body_start"));
3045        self.body_depth += 1;
3046        ops.push(ParseOp::EndBody(element.name()));
3047        ops.push(ParseOp::Parse {
3048            range: element.inner(),
3049            depth: depth + 1,
3050        });
3051        Ok(())
3052    }
3053
3054    /// Start an authoring [`Diagnostic`] for the template currently being
3055    /// parsed, naming the owning fragment (entry file or component tag) when
3056    /// known. `code` is the stable machine-readable [`code`](crate::diagnostic::codes).
3057    ///
3058    /// Marked `#[cold]`/`#[inline(never)]`: this only runs while *building* a
3059    /// build error, so keeping it out-of-line preserves hot parse-path layout.
3060    #[cold]
3061    #[inline(never)]
3062    fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
3063        let diag = Diagnostic::error(title).code(code);
3064        if self.current_fragment_id.is_empty() {
3065            diag
3066        } else {
3067            diag.component(self.current_fragment_id.clone())
3068        }
3069    }
3070
3071    /// Like [`HtmlParser::authoring_error`], but also records the source
3072    /// position (line/column) of `element` so the diagnostic can point at the
3073    /// exact spot in the template.
3074    #[cold]
3075    #[inline(never)]
3076    fn authoring_error_at(
3077        &self,
3078        code: &'static str,
3079        title: impl Into<String>,
3080        element: &Element<'_>,
3081    ) -> Diagnostic {
3082        self.authoring_error(code, title)
3083            .at_offset(element.source(), element.start)
3084    }
3085
3086    /// Build a positioned [`Diagnostic`] for a structural HTML well-formedness
3087    /// error (unclosed/malformed tag, unterminated comment, …), naming the
3088    /// owning fragment and the source position from a byte `offset`.
3089    #[cold]
3090    #[inline(never)]
3091    fn html_error(
3092        &self,
3093        code: &'static str,
3094        title: impl Into<String>,
3095        source: &str,
3096        offset: usize,
3097    ) -> Diagnostic {
3098        self.authoring_error(code, title).at_offset(source, offset)
3099    }
3100
3101    /// Build the warning for a second `<outlet>` at one parsed route level.
3102    #[cold]
3103    #[inline(never)]
3104    fn multiple_outlets_warning(&self, element: &Element<'_>) -> Diagnostic {
3105        outlet_warnings::multiple_outlets_warning(&self.current_fragment_id)
3106            .at_offset(element.source(), element.start)
3107    }
3108
3109    /// Build the `help:` line for an unknown component `<name>`.
3110    ///
3111    /// Offers the closest registered component as a "did you mean …?" fix when
3112    /// one is a near typo; otherwise falls back to the generic registration
3113    /// hint.
3114    #[cold]
3115    #[inline(never)]
3116    fn unknown_component_help(&self, name: &str) -> String {
3117        match suggest::closest_match(name, self.component_registry.names()) {
3118            Some(suggestion) => format!(
3119                "did you mean <{suggestion}>? otherwise register the component by adding a \
3120                 matching .html file"
3121            ),
3122            None => "register the component (add a matching .html file) or check the tag name \
3123                     for a typo"
3124                .to_string(),
3125        }
3126    }
3127
3128    /// Suggest a registered component that an unregistered custom-element `tag`
3129    /// likely mistypes.
3130    ///
3131    /// Only same-namespace candidates are considered — the text before the
3132    /// first `-` must match exactly — so a genuine third-party custom element
3133    /// (`<md-button>` when `<mp-button>` is registered) is never flagged, while
3134    /// an in-namespace slip (`<mp-buton>` → `<mp-button>`) is. Returns `None`
3135    /// for non-hyphenated (native) tags and when no near match exists.
3136    fn suggest_component(&self, tag: &str) -> Option<&str> {
3137        let (prefix, _) = tag.split_once('-')?;
3138        let same_namespace = self
3139            .component_registry
3140            .names()
3141            .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
3142        suggest::closest_match(tag, same_namespace)
3143    }
3144
3145    /// Error with a "did you mean …?" hint when `element` is an unregistered
3146    /// custom-element tag that closely matches a registered component in the
3147    /// same namespace. Genuine external custom elements pass through as raw
3148    /// HTML (returns `Ok`).
3149    fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
3150        if let Some(suggestion) = self.suggest_component(element.name()) {
3151            return Err(self
3152                .authoring_error_at(
3153                    codes::UNKNOWN_COMPONENT,
3154                    format!("unknown component <{}>", element.name()),
3155                    element,
3156                )
3157                .help(format!(
3158                    "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
3159                     .html file",
3160                    element.name()
3161                ))
3162                .into());
3163        }
3164        Ok(())
3165    }
3166
3167    /// Name of an attribute on `element` that looks like a typo of `expected`
3168    /// (close edit distance, not an exact match), if any. Used to suggest the
3169    /// intended directive attribute (e.g. `eahc` → `each`).
3170    #[cold]
3171    #[inline(never)]
3172    fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
3173        suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
3174            .filter(|&name| name != expected)
3175    }
3176
3177    /// Promote a standalone [`ParserError::Css`] into a structured authoring
3178    /// [`Diagnostic`] (code [`codes::INVALID_CSS`], owning fragment), so CSS
3179    /// mistakes render and serialize like every other authoring error. The
3180    /// message already carries the in-`<style>` line/column. Non-CSS errors
3181    /// pass through unchanged.
3182    #[cold]
3183    #[inline(never)]
3184    fn css_diagnostic(&self, err: ParserError) -> ParserError {
3185        match err {
3186            ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
3187            other => other,
3188        }
3189    }
3190
3191    /// Build the error for a `<for>` missing its `each` attribute (cold path).
3192    #[cold]
3193    #[inline(never)]
3194    fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
3195        let diag = self
3196            .authoring_error_at(
3197                codes::MISSING_FOR_EACH,
3198                "missing each attribute on <for>",
3199                element,
3200            )
3201            .element("for");
3202        match Self::attr_typo_suggestion(element, "each") {
3203            Some(typo) => diag.help(format!(
3204                "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
3205            )),
3206            None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
3207        }
3208        .into()
3209    }
3210
3211    fn enter_for_directive<'a>(
3212        &mut self,
3213        element: &Element<'a>,
3214        fragments: &mut Vec<WebUIFragment>,
3215        depth: usize,
3216        ops: &mut Vec<ParseOp<'a>>,
3217    ) -> Result<()> {
3218        let each = element
3219            .attr("each")
3220            .ok_or_else(|| self.for_each_missing_error(element))?;
3221        let (item, collection) = parse_for_each(&self.current_fragment_id, each)
3222            .map_err(|error| locate_for_error(error, element))?;
3223
3224        let named_id = validate_for_id(&self.current_fragment_id, &element.tag)
3225            .map_err(|error| locate_for_error(error, element))?;
3226        let fragment_id = if let Some(id) = named_id {
3227            self.named_for_loops
3228                .register(
3229                    &self.current_fragment_id,
3230                    id,
3231                    item,
3232                    (!element.self_closing(), element.start),
3233                )
3234                .map_err(|error| locate_for_error(error, element))?;
3235            let scoped_id = format!(
3236                "{id}-{}",
3237                self.loop_owner_indices[&self.current_fragment_id]
3238            );
3239            if !self.named_for_fragment_ids.contains(&scoped_id)
3240                && (self.id_counter.has_generated(&scoped_id)
3241                    || self.fragment_records.contains_key(&scoped_id)
3242                    || self.component_registry.contains(&scoped_id)
3243                    || self.in_progress_fragments.contains(&scoped_id))
3244            {
3245                return Err(self.for_id_collision_error(element, &scoped_id));
3246            }
3247            self.named_for_fragment_ids.insert(scoped_id.clone());
3248            scoped_id
3249        } else {
3250            self.next_fragment_id("for")
3251        };
3252        if named_id.is_some() && element.self_closing() {
3253            self.add_for_fragment(
3254                item.to_string(),
3255                collection.to_string(),
3256                fragment_id,
3257                fragments,
3258            );
3259            return Ok(());
3260        }
3261        let parent = ParseContext {
3262            fragments: std::mem::take(fragments),
3263            raw_buffer: std::mem::take(&mut self.raw_buffer),
3264        };
3265
3266        let previous_for_depth = self.for_depth;
3267        self.for_depth += 1;
3268        ops.push(ParseOp::CompleteFor {
3269            parent,
3270            item: item.to_string(),
3271            collection: collection.to_string(),
3272            fragment_id,
3273            named_definition: named_id.is_some(),
3274            previous_for_depth,
3275        });
3276        ops.push(ParseOp::Parse {
3277            range: element.inner(),
3278            depth: depth + 1,
3279        });
3280        Ok(())
3281    }
3282
3283    fn next_fragment_id(&mut self, prefix: &str) -> String {
3284        loop {
3285            let id = self.id_counter.next_id(prefix);
3286            if !self.named_for_fragment_ids.contains(&id) {
3287                return id;
3288            }
3289        }
3290    }
3291
3292    #[cold]
3293    #[inline(never)]
3294    fn for_id_collision_error(&self, element: &Element<'_>, id: &str) -> ParserError {
3295        self.authoring_error_at(
3296            codes::INVALID_FOR_ID,
3297            "named <for> id conflicts with an existing fragment",
3298            element,
3299        )
3300        .element("for")
3301        .help(format!(
3302            "choose a different id; the fragment name '{id}' is already in use"
3303        ))
3304        .into()
3305    }
3306
3307    /// Build the error for an `<if>` missing its `condition` attribute (cold
3308    /// path).
3309    #[cold]
3310    #[inline(never)]
3311    fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
3312        let diag = self
3313            .authoring_error_at(
3314                codes::MISSING_IF_CONDITION,
3315                "missing condition attribute on <if>",
3316                element,
3317            )
3318            .element("if");
3319        match Self::attr_typo_suggestion(element, "condition") {
3320            Some(typo) => diag.help(format!(
3321                "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
3322            )),
3323            None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
3324        }
3325        .into()
3326    }
3327
3328    /// Build the error for a malformed `<if condition>` expression (cold path).
3329    #[cold]
3330    #[inline(never)]
3331    fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
3332        self.authoring_error_at(
3333            codes::INVALID_IF_CONDITION,
3334            "invalid <if> condition expression",
3335            element,
3336        )
3337        .element("if")
3338        .snippet(format!("condition=\"{condition}\""))
3339        .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
3340        .into()
3341    }
3342
3343    fn enter_if_directive<'a>(
3344        &mut self,
3345        element: &Element<'a>,
3346        fragments: &mut Vec<WebUIFragment>,
3347        depth: usize,
3348        ops: &mut Vec<ParseOp<'a>>,
3349    ) -> Result<()> {
3350        let condition_str = element
3351            .attr("condition")
3352            .ok_or_else(|| self.if_condition_missing_error(element))?;
3353
3354        let condition = self
3355            .condition_parser
3356            .parse(strip_condition_braces(condition_str))
3357            .map_err(|_| self.if_condition_invalid_error(element, condition_str))?;
3358
3359        self.flush_raw_buffer(fragments);
3360        let parent = ParseContext {
3361            fragments: std::mem::take(fragments),
3362            raw_buffer: std::mem::take(&mut self.raw_buffer),
3363        };
3364        let fragment_id = self.next_fragment_id("if");
3365
3366        ops.push(ParseOp::CompleteIf {
3367            parent,
3368            condition,
3369            fragment_id,
3370        });
3371        ops.push(ParseOp::Parse {
3372            range: element.inner(),
3373            depth: depth + 1,
3374        });
3375        Ok(())
3376    }
3377
3378    /// Track whether `name` opens (or shields from) an HTML insertion mode
3379    /// that foster-parents unknown elements, pushing a restore op when the
3380    /// depth changes.
3381    ///
3382    /// Called for every regular element, so the common `Transparent` case
3383    /// does no work beyond the classification compare — no op is pushed and
3384    /// no field is written.
3385    #[inline]
3386    fn enter_foster_context<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
3387        match foster_context_of(name) {
3388            FosterContext::Transparent => {}
3389            FosterContext::Hostile => {
3390                ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3391                self.foster_context_depth += 1;
3392            }
3393            FosterContext::Barrier => {
3394                if self.foster_context_depth != 0 {
3395                    ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3396                    self.foster_context_depth = 0;
3397                }
3398            }
3399        }
3400    }
3401
3402    #[inline]
3403    fn enter_boundary_parent_scope<'a>(
3404        &mut self,
3405        element: &Element<'a>,
3406        ops: &mut Vec<ParseOp<'a>>,
3407    ) {
3408        if element.name().eq_ignore_ascii_case("template") && element.has_attr("shadowrootmode") {
3409            return;
3410        }
3411        let Some(scope) = boundary_parent_scope(element.name()) else {
3412            return;
3413        };
3414        ops.push(ParseOp::RestoreBoundaryParentScope(
3415            self.boundary_parent_scope,
3416        ));
3417        self.boundary_parent_scope = Some(scope);
3418    }
3419
3420    #[inline]
3421    fn enter_component_content_scope<'a>(&mut self, ops: &mut Vec<ParseOp<'a>>) {
3422        ops.push(ParseOp::RestoreBoundaryParentScope(
3423            self.boundary_parent_scope,
3424        ));
3425        self.boundary_parent_scope = Some("component host content");
3426    }
3427
3428    /// Enter a `<boundary name="…">` directive.
3429    ///
3430    /// `<boundary>` is a reserved, compile-time-only directive (see
3431    /// "Progressive Streaming Hydration" in `DESIGN.md`): it emits no wrapper
3432    /// element. Its children stay inline in the owner's record, bracketed by a
3433    /// [`BoundaryPhase::Start`]/[`BoundaryPhase::End`] fragment pair.
3434    ///
3435    /// # Errors
3436    ///
3437    /// Returns [`ParserError::Template`] when:
3438    /// - the boundary is nested inside another open boundary
3439    ///   ([`codes::NESTED_BOUNDARY`]);
3440    /// - the boundary is outside an open `<body>`
3441    ///   ([`codes::BOUNDARY_OUTSIDE_BODY`]);
3442    /// - the boundary sits in an HTML foster-parenting context such as
3443    ///   `<table>`/`<tbody>`/`<tr>`/`<select>`
3444    ///   ([`codes::BOUNDARY_IN_FOSTER_CONTEXT`]);
3445    /// - the boundary would cut through component host content or native
3446    ///   raw/inert content ([`codes::BOUNDARY_CROSSES_SCOPE`]);
3447    /// - `name` is missing/empty ([`codes::MISSING_BOUNDARY_NAME`]), dynamic
3448    ///   ([`codes::INVALID_BOUNDARY_NAME`]), or a duplicate within its owner
3449    ///   ([`codes::DUPLICATE_BOUNDARY_NAME`]).
3450    /// - the boundary sits inside a `<for>` repeat body
3451    ///   ([`codes::BOUNDARY_IN_REPEAT`]), or an authored key is malformed
3452    ///   ([`codes::INVALID_BOUNDARY_KEY`]).
3453    fn enter_boundary_directive<'a>(
3454        &mut self,
3455        element: &Element<'a>,
3456        fragments: &mut Vec<WebUIFragment>,
3457        depth: usize,
3458        ops: &mut Vec<ParseOp<'a>>,
3459    ) -> Result<()> {
3460        if let Some(scope) = self.boundary_parent_scope {
3461            return Err(self.boundary_scope_error(element, scope));
3462        }
3463        if self.in_boundary {
3464            return Err(self.nested_boundary_error(element));
3465        }
3466        if self.for_depth != 0 {
3467            return Err(self.boundary_in_repeat_error(element));
3468        }
3469        let owner_fragment_id = self.current_fragment_id.clone();
3470        let owner_is_component = self.component_registry.contains(&owner_fragment_id);
3471        if !owner_is_component && self.body_depth == 0 && self.route_depth == 0 {
3472            return Err(self.boundary_outside_body_error(element));
3473        }
3474        if self.foster_context_depth != 0 {
3475            return Err(self.boundary_in_foster_context_error(element));
3476        }
3477
3478        let name = self.validate_boundary_name(element)?;
3479        let inserted = self
3480            .boundary_names_by_owner
3481            .entry(owner_fragment_id.clone())
3482            .or_default()
3483            .insert(name.clone());
3484        if !inserted {
3485            return Err(self.duplicate_boundary_name_error(element, &name));
3486        }
3487        let key = self.validate_boundary_key(element)?;
3488        let declaration_id = self.allocate_boundary_declaration_id(element)?;
3489        self.in_boundary = true;
3490        self.add_fragment(
3491            WebUIFragment {
3492                fragment: Some(Fragment::Boundary(WebUiFragmentBoundary {
3493                    declaration_id,
3494                    owner_fragment_id,
3495                    name,
3496                    key,
3497                    may_repeat: false,
3498                    phase: BoundaryPhase::Start as i32,
3499                })),
3500            },
3501            fragments,
3502        );
3503
3504        ops.push(ParseOp::CompleteBoundary {
3505            boundary: PendingBoundary { declaration_id },
3506        });
3507        ops.push(ParseOp::Parse {
3508            range: element.inner(),
3509            depth: depth + 1,
3510        });
3511        Ok(())
3512    }
3513
3514    /// Validate `<boundary name>`: required, non-empty, and static (no
3515    /// `{{binding}}`).
3516    fn validate_boundary_name(&self, element: &Element<'_>) -> Result<String> {
3517        let name = element.attr("name").unwrap_or_default();
3518        if name.trim().is_empty() {
3519            return Err(self.missing_boundary_name_error(element));
3520        }
3521        if name.contains("{{") {
3522            return Err(self.invalid_boundary_name_error(element, name));
3523        }
3524        Ok(name.to_string())
3525    }
3526
3527    fn validate_boundary_key(&self, element: &Element<'_>) -> Result<Option<String>> {
3528        if !element.has_attr("key") {
3529            return Ok(None);
3530        }
3531        let Some(raw) = element.attr("key") else {
3532            return Err(self.invalid_boundary_key_error(element, ""));
3533        };
3534        let trimmed = raw.trim();
3535        let braced = trimmed.starts_with("{{") || trimmed.ends_with("}}");
3536        let valid = if braced {
3537            trimmed
3538                .strip_prefix("{{")
3539                .and_then(|value| value.strip_suffix("}}"))
3540                .filter(|value| !value.starts_with('{') && !value.ends_with('}'))
3541                .is_some_and(|value| !value.trim().is_empty())
3542        } else {
3543            !trimmed.is_empty()
3544        };
3545        if !valid {
3546            return Err(self.invalid_boundary_key_error(element, raw));
3547        }
3548        Ok(Some(raw.to_string()))
3549    }
3550
3551    fn allocate_boundary_declaration_id(&mut self, element: &Element<'_>) -> Result<u32> {
3552        let declaration_id = self.next_boundary_declaration_id;
3553        let Some(next) = declaration_id.checked_add(1) else {
3554            return Err(self.too_many_boundaries_error(element));
3555        };
3556        self.next_boundary_declaration_id = next;
3557        Ok(declaration_id)
3558    }
3559
3560    /// Build the error for a `<boundary>` missing its `name` attribute
3561    /// (cold path).
3562    #[cold]
3563    #[inline(never)]
3564    fn missing_boundary_name_error(&self, element: &Element<'_>) -> ParserError {
3565        self.authoring_error_at(
3566            codes::MISSING_BOUNDARY_NAME,
3567            "missing name attribute on <boundary>",
3568            element,
3569        )
3570        .element("boundary")
3571        .help("add a unique static name, e.g. <boundary name=\"counter-ready\">")
3572        .into()
3573    }
3574
3575    /// Build the error for a `<boundary name>` that is not a static
3576    /// string literal (cold path).
3577    #[cold]
3578    #[inline(never)]
3579    fn invalid_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3580        self.authoring_error_at(
3581            codes::INVALID_BOUNDARY_NAME,
3582            "invalid name attribute on <boundary>",
3583            element,
3584        )
3585        .element("boundary")
3586        .snippet(format!("name=\"{name}\""))
3587        .help("boundary names must be a static string literal, e.g. name=\"counter-ready\"")
3588        .into()
3589    }
3590
3591    /// Build the error for a duplicate `<boundary name>` within one owner.
3592    #[cold]
3593    #[inline(never)]
3594    fn duplicate_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3595        self.authoring_error_at(
3596            codes::DUPLICATE_BOUNDARY_NAME,
3597            format!("duplicate <boundary> name \"{name}\""),
3598            element,
3599        )
3600        .element("boundary")
3601        .help("boundary names must be unique within their entry or component template; rename one of the declarations")
3602        .into()
3603    }
3604
3605    #[cold]
3606    #[inline(never)]
3607    fn missing_boundary_key_error(owner: &str, name: &str) -> ParserError {
3608        Diagnostic::error(format!(
3609            "boundary \"{name}\" renders more than once and has no key"
3610        ))
3611        .code(codes::MISSING_BOUNDARY_KEY)
3612        .component(owner)
3613        .element("boundary")
3614        .snippet(format!("name=\"{name}\""))
3615        .help(
3616            "the owning template is rendered from more than one callsite, so every occurrence \
3617             needs a distinct key, e.g. key=\"{{sectionId}}\" — or render the declaration from a \
3618             single callsite",
3619        )
3620        .into()
3621    }
3622
3623    /// Build the error for a `<boundary>` authored directly inside `<for>`.
3624    #[cold]
3625    #[inline(never)]
3626    fn boundary_in_repeat_error(&self, element: &Element<'_>) -> ParserError {
3627        let name = element.attr("name").unwrap_or_default();
3628        self.authoring_error_at(
3629            codes::BOUNDARY_IN_REPEAT,
3630            "<boundary> is not valid inside a <for> repeat",
3631            element,
3632        )
3633        .element("boundary")
3634        .snippet(format!("name=\"{name}\""))
3635        .help(
3636            "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
3637             whole <for> in one <boundary> to render the list as a single independently paced \
3638             region",
3639        )
3640        .into()
3641    }
3642
3643    #[cold]
3644    #[inline(never)]
3645    fn invalid_boundary_key_error(&self, element: &Element<'_>, key: &str) -> ParserError {
3646        self.authoring_error_at(
3647            codes::INVALID_BOUNDARY_KEY,
3648            "invalid key expression on <boundary>",
3649            element,
3650        )
3651        .element("boundary")
3652        .snippet(format!("key=\"{key}\""))
3653        .help("use a non-empty expression such as key=\"item.id\" or key=\"{{item.id}}\"")
3654        .into()
3655    }
3656
3657    #[cold]
3658    #[inline(never)]
3659    fn too_many_boundaries_error(&self, element: &Element<'_>) -> ParserError {
3660        self.authoring_error_at(
3661            codes::TOO_MANY_BOUNDARIES,
3662            "too many boundary declarations",
3663            element,
3664        )
3665        .element("boundary")
3666        .help("split this build into smaller protocols so each declaration has a unique u32 ID")
3667        .into()
3668    }
3669
3670    /// Build the error for a `<boundary>` nested inside another open
3671    /// boundary (cold path).
3672    #[cold]
3673    #[inline(never)]
3674    fn nested_boundary_error(&self, element: &Element<'_>) -> ParserError {
3675        self.authoring_error_at(
3676            codes::NESTED_BOUNDARY,
3677            "nested <boundary> is not allowed",
3678            element,
3679        )
3680        .element("boundary")
3681        .help("boundaries cannot be nested; close the enclosing <boundary> before opening another")
3682        .into()
3683    }
3684
3685    /// Build the error for a `<boundary>` that would cut through a native
3686    /// raw/inert scope or component host content.
3687    #[cold]
3688    #[inline(never)]
3689    fn boundary_scope_error(&self, element: &Element<'_>, scope: &str) -> ParserError {
3690        self.authoring_error_at(
3691            codes::BOUNDARY_CROSSES_SCOPE,
3692            format!("<boundary> cannot appear inside {scope}"),
3693            element,
3694        )
3695        .element("boundary")
3696        .help("move the <boundary> outside this raw, inert, or component-host content scope")
3697        .into()
3698    }
3699
3700    /// Build the error for a `<boundary>` outside an open body.
3701    #[cold]
3702    #[inline(never)]
3703    fn boundary_outside_body_error(&self, element: &Element<'_>) -> ParserError {
3704        self.authoring_error_at(
3705            codes::BOUNDARY_OUTSIDE_BODY,
3706            "<boundary> must appear inside <body>",
3707            element,
3708        )
3709        .element("boundary")
3710        .help("move the <boundary> between the opening <body> and its matching </body>")
3711        .into()
3712    }
3713
3714    /// Build the error for a `<boundary>` inside an HTML
3715    /// foster-parenting insertion mode (cold path).
3716    ///
3717    /// The browser would move the generated `<webui-hydrate>` sentinel out of
3718    /// the table while leaving its payload `<script>` behind, so the
3719    /// coordinator could not pair them and would halt hydration for the whole
3720    /// page. Rejecting at build time keeps that failure impossible.
3721    #[cold]
3722    #[inline(never)]
3723    fn boundary_in_foster_context_error(&self, element: &Element<'_>) -> ParserError {
3724        self.authoring_error_at(
3725            codes::BOUNDARY_IN_FOSTER_CONTEXT,
3726            "<boundary> cannot appear inside a table or select context",
3727            element,
3728        )
3729        .element("boundary")
3730        .help(
3731            "the HTML parser moves unknown elements out of <table>/<tbody>/<tr>/<select>, which would split the hydration sentinel from its payload; wrap the whole <table> in the boundary, or move it inside a <td>, <th>, or <caption>",
3732        )
3733        .into()
3734    }
3735
3736    #[cold]
3737    #[inline(never)]
3738    fn route_boundary_placement_error(&self, element: &Element<'_>) -> ParserError {
3739        self.authoring_error_at(
3740            codes::INVALID_ROUTE_BOUNDARY_PLACEMENT,
3741            "route boundary must be a direct <route> child",
3742            element,
3743        )
3744        .element("boundary")
3745        .help("move the <boundary> directly under <route>, or place it in the route component template")
3746        .into()
3747    }
3748
3749    /// Build the error for an authored `<webui-hydrate>` (cold path).
3750    /// `<webui-hydrate>` is reserved for the compiler/handler-generated
3751    /// runtime hydration sentinel and must never appear in authored markup.
3752    #[cold]
3753    #[inline(never)]
3754    fn authored_webui_hydrate_error(&self, element: &Element<'_>) -> ParserError {
3755        self.authoring_error_at(
3756            codes::AUTHORED_WEBUI_HYDRATE,
3757            "<webui-hydrate> is reserved for the generated runtime sentinel",
3758            element,
3759        )
3760        .element("webui-hydrate")
3761        .help("remove <webui-hydrate>; it is emitted only by the compiler/handler, never authored")
3762        .into()
3763    }
3764
3765    fn enter_component_directive<'a>(
3766        &mut self,
3767        element: &Element<'a>,
3768        fragments: &mut Vec<WebUIFragment>,
3769        depth: usize,
3770        ops: &mut Vec<ParseOp<'a>>,
3771    ) -> Result<()> {
3772        let needs_template_build = !self.fragment_records.contains_key(element.name());
3773        let cached_dom_analysis = self.component_dom_analyses.get(element.name()).copied();
3774        let has_plugin_artifact = self
3775            .component_registry
3776            .component_artifact_source(element.name())
3777            .is_some();
3778        let authored_html = if needs_template_build || cached_dom_analysis.is_none() {
3779            self.component_registry
3780                .component_authored_source(element.name())
3781        } else {
3782            None
3783        };
3784        let (dom_analysis, template_source) = {
3785            let component = self.component_registry.get(element.name()).ok_or_else(|| {
3786                self.authoring_error_at(
3787                    codes::UNKNOWN_COMPONENT,
3788                    format!("unknown component <{}>", element.name()),
3789                    element,
3790                )
3791                .help(self.unknown_component_help(element.name()))
3792            })?;
3793            (
3794                cached_dom_analysis.map_or_else(
3795                    || {
3796                        let fast_html = (has_plugin_artifact
3797                            && self.options.dom_strategy == DomStrategy::Shadow)
3798                            .then(|| {
3799                                Self::append_shadow_mode_if_missing(component.html_content.clone())
3800                            });
3801                        analyze_component_dom_with_diagnostic_source(
3802                            element.name(),
3803                            fast_html.as_deref().unwrap_or(&component.html_content),
3804                            self.options.dom_strategy,
3805                            authored_html.as_deref(),
3806                        )
3807                    },
3808                    Ok,
3809                )?,
3810                needs_template_build.then(|| {
3811                    (
3812                        component.html_content.clone(),
3813                        component.css_content.clone(),
3814                    )
3815                }),
3816            )
3817        };
3818        if cached_dom_analysis.is_none() {
3819            self.component_dom_analyses
3820                .insert(element.name().to_string(), dom_analysis);
3821        }
3822
3823        self.flush_raw_buffer(fragments);
3824        fragments.push(structural_signal(format!(
3825            "streaming_span_start:{}",
3826            element.name()
3827        )));
3828        self.add_raw_fragment("<");
3829        self.add_raw_fragment(element.name());
3830        if self
3831            .component_registry
3832            .render_policy(element.name())
3833            .is_some_and(ComponentRenderPolicy::is_interaction_hydration)
3834        {
3835            self.add_raw_fragment(" data-webui-interaction");
3836        }
3837
3838        let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
3839        if let Some(ref mut p) = self.plugin {
3840            if let Some(data) = p.finish_opening_tag(ElementStartContext {
3841                tag_name: element.name(),
3842                binding_count,
3843            }) {
3844                self.add_fragment(WebUIFragment::plugin(data), fragments);
3845            }
3846        }
3847
3848        // Emit a compiler-owned streamed SSR root signal immediately before the
3849        // component opening tag closes. Ordinary rendering ignores it byte-for-byte;
3850        // streaming rendering consumes it to inject ` data-ws` inside the opening tag
3851        // (so the marker exists before custom-element upgrade). Placing the signal
3852        // between the attribute run and the closing `>` is required because an
3853        // attribute cannot be injected once `>` has already been flushed.
3854        self.flush_raw_buffer(fragments);
3855        fragments.push(structural_signal(format!(
3856            "streaming_root:{}",
3857            element.name()
3858        )));
3859
3860        if element.self_closing() {
3861            self.add_raw_fragment("/>");
3862        } else {
3863            self.add_raw_fragment(">");
3864        }
3865
3866        self.flush_raw_buffer(fragments);
3867
3868        if let Some((html_content, css_content)) = template_source {
3869            let artifact_html = self
3870                .component_registry
3871                .component_artifact_source(element.name())
3872                .map(str::to_string);
3873            let built = self.build_component_templates(ComponentTemplateInput {
3874                tag_name: element.name(),
3875                html: &html_content,
3876                artifact_html: artifact_html.as_deref(),
3877                authored_html: authored_html.as_deref(),
3878                css_content: css_content.as_deref(),
3879                artifact_needed: self.plugin.is_some(),
3880            })?;
3881
3882            if let Some(ref mut p) = self.plugin {
3883                let component_data = self
3884                    .component_registry
3885                    .get(element.name())
3886                    .ok_or_else(|| {
3887                        ParserError::NotFound(format!(
3888                            "component <{}> disappeared during CSS compilation",
3889                            element.name()
3890                        ))
3891                    })?
3892                    .clone();
3893                p.component_built(built.plugin_context(&component_data))?;
3894            }
3895
3896            self.parse(element.name(), &built.ssr)?;
3897        }
3898
3899        fragments.push(WebUIFragment::component(element.name().to_string()));
3900
3901        if element.self_closing() {
3902            fragments.push(structural_signal(format!(
3903                "streaming_span_end:{}",
3904                element.name()
3905            )));
3906        } else {
3907            ops.push(ParseOp::EndComponent(element.name()));
3908            self.enter_component_content_scope(ops);
3909            ops.push(ParseOp::Parse {
3910                range: element.inner(),
3911                depth: depth + 1,
3912            });
3913        }
3914
3915        Ok(())
3916    }
3917
3918    fn analyze_component_dom(
3919        &mut self,
3920        tag_name: &str,
3921        source: &str,
3922        authored_source: Option<&str>,
3923    ) -> Result<ComponentDomAnalysis> {
3924        if let Some(analysis) = self.component_dom_analyses.get(tag_name) {
3925            return Ok(*analysis);
3926        }
3927        let analysis = analyze_component_dom_with_diagnostic_source(
3928            tag_name,
3929            source,
3930            self.options.dom_strategy,
3931            authored_source,
3932        )?;
3933        self.component_dom_analyses
3934            .insert(tag_name.to_string(), analysis);
3935        Ok(analysis)
3936    }
3937
3938    fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
3939        if !Self::should_emit_text_content(content) {
3940            return Ok(());
3941        }
3942
3943        let parsed_fragments = self.handlebars_parser.parse(content)?;
3944        for fragment in parsed_fragments {
3945            if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
3946                if Self::should_emit_text_content(&raw.value) {
3947                    self.add_raw_fragment(&raw.value);
3948                }
3949            } else if let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() {
3950                if signal.raw && is_html_text_only_scope(self.boundary_parent_scope) {
3951                    self.add_fragment(
3952                        WebUIFragment::raw_text_signal(signal.value.as_str(), true),
3953                        fragments,
3954                    );
3955                } else {
3956                    self.add_fragment(fragment, fragments);
3957                }
3958            } else {
3959                self.add_fragment(fragment, fragments);
3960            }
3961        }
3962        Ok(())
3963    }
3964
3965    fn process_style_content(
3966        &mut self,
3967        css: &str,
3968        comments: &[crate::css_parser::CssComment],
3969        fragments: &mut Vec<WebUIFragment>,
3970    ) {
3971        let mut last_end = 0usize;
3972
3973        for comment in comments {
3974            if comment.start_byte < last_end {
3975                if comment.end_byte > last_end {
3976                    last_end = comment.end_byte;
3977                }
3978                continue;
3979            }
3980
3981            self.add_raw_fragment(&css[last_end..comment.start_byte]);
3982            let comment_text = &css[comment.start_byte..comment.end_byte];
3983            if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
3984                self.add_fragment(fragment, fragments);
3985            } else if comment.preserve {
3986                self.add_raw_fragment(comment_text);
3987            }
3988            last_end = comment.end_byte;
3989        }
3990
3991        self.add_raw_fragment(&css[last_end..]);
3992    }
3993
3994    fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
3995        let signal = comment_policy::parse_css_signal_comment(comment)?;
3996        Some(WebUIFragment::raw_text_signal(signal.path, signal.raw))
3997    }
3998
3999    /// Check if an attribute value is a pure handlebars expression (e.g., "{{name}}" or
4000    /// "{{name}}" with quotes). Returns the inner signal name (borrowed) if so.
4001    fn extract_single_handlebars(value: &str) -> Option<&str> {
4002        let trimmed = value.trim();
4003        if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
4004            let inner = trimmed[2..trimmed.len() - 2].trim();
4005            // Verify there's no other {{ in the middle (i.e., it's truly a single expression)
4006            if !inner.contains("{{") && !inner.is_empty() {
4007                return Some(inner);
4008            }
4009        }
4010        None
4011    }
4012
4013    /// Check if an attribute value contains any handlebars expressions.
4014    fn contains_handlebars(value: &str) -> bool {
4015        value.contains("{{")
4016    }
4017
4018    fn process_tag_attributes(
4019        &mut self,
4020        attrs: Attrs<'_>,
4021        fragments: &mut Vec<WebUIFragment>,
4022        is_component: bool,
4023    ) -> Result<u32> {
4024        let mut first_dynamic_emitted = false;
4025        let mut binding_count: u32 = 0;
4026        for attr in attrs {
4027            let attr_name = attr.name;
4028            let attr_value = attr.value;
4029            let is_property_binding = attr_name.starts_with(':')
4030                && attr_value
4031                    .and_then(Self::extract_single_handlebars)
4032                    .is_some();
4033
4034            // `:property="{{expr}}"` is WebUI-owned syntax. Plugins may classify
4035            // FAST's single-brace `:property="{expr}"`, but cannot discard the
4036            // server-side scope transfer before parser-core processes it.
4037            if !is_property_binding {
4038                if let Some(ref mut p) = self.plugin {
4039                    match p.process_attribute(AttributeContext { name: attr_name }) {
4040                        AttributeAction::Keep => {}
4041                        AttributeAction::Skip => continue,
4042                        AttributeAction::SkipAndCountBinding => {
4043                            binding_count += 1;
4044                            continue;
4045                        }
4046                    }
4047                }
4048            }
4049
4050            if let Some(bool_name) = attr_name.strip_prefix('?') {
4051                if is_component {
4052                    if let Some(condition) = self.parse_boolean_condition(attr_value) {
4053                        let frag = Self::maybe_mark_attr_start(
4054                            WebUIFragment::attribute_boolean(bool_name, condition),
4055                            &mut first_dynamic_emitted,
4056                        );
4057                        self.add_fragment(frag, fragments);
4058                        binding_count += 1;
4059                    }
4060                } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
4061                    binding_count += 1;
4062                }
4063            } else if let Some(prop_name) = attr_name.strip_prefix(':') {
4064                if !is_component {
4065                    return Err(ParserError::Parse(format!(
4066                        ":{prop_name} complex binding is only allowed on custom elements. \
4067                         Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
4068                    )));
4069                }
4070                if Self::is_blocked_complex_property(prop_name) {
4071                    return Err(ParserError::Parse(format!(
4072                        ":{prop_name} is not allowed as a complex attribute binding \
4073                         because it enables arbitrary HTML injection. \
4074                         Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
4075                    )));
4076                }
4077                if let Some(val) = attr_value {
4078                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
4079                        let frag = Self::maybe_mark_attr_start(
4080                            WebUIFragment::attribute_complex(attr_name, signal_name),
4081                            &mut first_dynamic_emitted,
4082                        );
4083                        self.add_fragment(frag, fragments);
4084                        binding_count += 1;
4085                    }
4086                }
4087            } else if is_component && Self::is_skipped_attribute(attr_name) {
4088                if let Some(val) = attr_value {
4089                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
4090                        let frag = WebUIFragment {
4091                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4092                                WebUIFragmentAttribute {
4093                                    name: attr_name.to_string(),
4094                                    value: signal_name.to_string(),
4095                                    attr_skip: true,
4096                                    ..Default::default()
4097                                },
4098                            )),
4099                        };
4100                        self.add_fragment(frag, fragments);
4101                        binding_count += 1;
4102                    } else if Self::contains_handlebars(val) {
4103                        let template_id = self.next_fragment_id("attr");
4104                        let parsed = self.parse_attribute_template(val)?;
4105                        self.fragment_records.insert(
4106                            template_id.clone(),
4107                            FragmentList {
4108                                fragments: parsed,
4109                                contains_boundary: false,
4110                            },
4111                        );
4112                        let frag = WebUIFragment {
4113                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4114                                WebUIFragmentAttribute {
4115                                    name: attr_name.to_string(),
4116                                    template: template_id,
4117                                    attr_skip: true,
4118                                    ..Default::default()
4119                                },
4120                            )),
4121                        };
4122                        self.add_fragment(frag, fragments);
4123                        binding_count += 1;
4124                    } else {
4125                        let frag = WebUIFragment {
4126                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4127                                WebUIFragmentAttribute {
4128                                    name: attr_name.to_string(),
4129                                    value: html_escape::decode_html_entities(val).into_owned(),
4130                                    raw_value: true,
4131                                    attr_skip: true,
4132                                    ..Default::default()
4133                                },
4134                            )),
4135                        };
4136                        self.add_fragment(frag, fragments);
4137                    }
4138                } else {
4139                    self.add_raw_fragment(" ");
4140                    self.add_raw_fragment(attr_name);
4141                }
4142            } else if let Some(val) = attr_value {
4143                if Self::contains_handlebars(val) {
4144                    if is_component {
4145                        if let Some(signal_name) = Self::extract_single_handlebars(val) {
4146                            let frag = Self::maybe_mark_attr_start(
4147                                WebUIFragment::attribute(attr_name, signal_name),
4148                                &mut first_dynamic_emitted,
4149                            );
4150                            self.add_fragment(frag, fragments);
4151                            binding_count += 1;
4152                        } else {
4153                            let template_id = self.next_fragment_id("attr");
4154                            let parsed = self.parse_attribute_template(val)?;
4155                            self.fragment_records.insert(
4156                                template_id.clone(),
4157                                FragmentList {
4158                                    fragments: parsed,
4159                                    contains_boundary: false,
4160                                },
4161                            );
4162                            let frag = Self::maybe_mark_attr_start(
4163                                WebUIFragment::attribute_template(attr_name, template_id),
4164                                &mut first_dynamic_emitted,
4165                            );
4166                            self.add_fragment(frag, fragments);
4167                            binding_count += 1;
4168                        }
4169                    } else {
4170                        self.process_dynamic_attribute(attr_name, val, fragments)?;
4171                        binding_count += 1;
4172                    }
4173                } else if is_component {
4174                    let frag = Self::maybe_mark_attr_start(
4175                        WebUIFragment {
4176                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4177                                WebUIFragmentAttribute {
4178                                    name: attr_name.to_string(),
4179                                    value: html_escape::decode_html_entities(val).into_owned(),
4180                                    raw_value: true,
4181                                    ..Default::default()
4182                                },
4183                            )),
4184                        },
4185                        &mut first_dynamic_emitted,
4186                    );
4187                    self.add_fragment(frag, fragments);
4188                } else {
4189                    self.add_raw_fragment(" ");
4190                    self.add_raw_fragment(attr.raw);
4191                }
4192            } else if is_component {
4193                let fragment = Self::maybe_mark_attr_start(
4194                    WebUIFragment::attribute_boolean(attr_name, ConditionExpr::identifier("true")),
4195                    &mut first_dynamic_emitted,
4196                );
4197                self.add_fragment(fragment, fragments);
4198            } else {
4199                self.add_raw_fragment(" ");
4200                self.add_raw_fragment(attr_name);
4201            }
4202        }
4203        Ok(binding_count)
4204    }
4205
4206    fn parse_attribute_template(&mut self, value: &str) -> Result<Vec<WebUIFragment>> {
4207        let mut fragments = self.handlebars_parser.parse(value)?;
4208        for fragment in &mut fragments {
4209            if let Some(web_ui_fragment::Fragment::Raw(raw)) = &mut fragment.fragment {
4210                if let std::borrow::Cow::Owned(decoded) =
4211                    html_escape::decode_html_entities(&raw.value)
4212                {
4213                    raw.value = decoded;
4214                }
4215            }
4216        }
4217        Ok(fragments)
4218    }
4219
4220    /// Set `attr_start = true` on the first non-skipped attribute fragment for
4221    /// a component element.
4222    fn maybe_mark_attr_start(
4223        mut frag: WebUIFragment,
4224        first_dynamic_emitted: &mut bool,
4225    ) -> WebUIFragment {
4226        if !*first_dynamic_emitted {
4227            if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
4228                a.attr_start = true;
4229            }
4230            *first_dynamic_emitted = true;
4231        }
4232        frag
4233    }
4234
4235    /// Process a boolean attribute (?prefix). Silently drops if value is not a
4236    /// pure handlebars expression.
4237    fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
4238        if let Some(val) = value {
4239            if let Some(expr_str) = Self::extract_single_handlebars(val) {
4240                return Some(
4241                    self.condition_parser
4242                        .parse(expr_str)
4243                        .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
4244                );
4245            }
4246        }
4247
4248        None
4249    }
4250
4251    fn process_boolean_attribute(
4252        &mut self,
4253        name: &str,
4254        value: Option<&str>,
4255        fragments: &mut Vec<WebUIFragment>,
4256    ) -> Result<bool> {
4257        if let Some(condition) = self.parse_boolean_condition(value) {
4258            self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
4259            return Ok(true);
4260        }
4261        // Invalid boolean attribute — silently drop (no output at all)
4262        Ok(false)
4263    }
4264
4265    /// Process a dynamic attribute (regular name with handlebars in value).
4266    fn process_dynamic_attribute(
4267        &mut self,
4268        name: &str,
4269        value: &str,
4270        fragments: &mut Vec<WebUIFragment>,
4271    ) -> Result<()> {
4272        if let Some(signal_name) = Self::extract_single_handlebars(value) {
4273            // Pure handlebars — simple attribute fragment
4274            self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
4275        } else {
4276            // Mixed static + dynamic — create a template sub-stream
4277            let template_id = self.next_fragment_id("attr");
4278            let parsed = self.handlebars_parser.parse(value)?;
4279
4280            self.fragment_records.insert(
4281                template_id.clone(),
4282                FragmentList {
4283                    fragments: parsed,
4284                    contains_boundary: false,
4285                },
4286            );
4287
4288            self.add_fragment(
4289                WebUIFragment::attribute_template(name, template_id),
4290                fragments,
4291            );
4292        }
4293        Ok(())
4294    }
4295
4296    fn process_style_element(
4297        &mut self,
4298        element: &Element<'_>,
4299        fragments: &mut Vec<WebUIFragment>,
4300    ) -> Result<()> {
4301        self.add_raw_fragment(element.opening());
4302        let inner = element.inner();
4303        let style_content = &element.source()[inner.start..inner.end];
4304        let (defs, requirements, comments) = self
4305            .css_parser
4306            .extract_definitions_requirements_and_comments(
4307                style_content,
4308                self.options.legal_comments,
4309            )
4310            .map_err(|e| self.css_diagnostic(e))?;
4311        self.record_fragment_css_tokens(defs, requirements);
4312        self.process_style_content(style_content, &comments, fragments);
4313        if element.close_end() > element.content_end() {
4314            // Reconstruct the closing tag from the parsed name so the emitted
4315            // case mirrors the source (e.g. `<STYLE>` closes with `</STYLE>`),
4316            // matching how regular elements are closed via `ParseOp::EmitClose`.
4317            self.add_raw_fragment("</");
4318            self.add_raw_fragment(element.name());
4319            self.add_raw_fragment(">");
4320        }
4321        Ok(())
4322    }
4323
4324    /// Process a `<route>` directive.
4325    ///
4326    /// Emits a `Fragment::Route` protocol fragment. The handler renders
4327    /// `<webui-route>` elements with server-side route matching — matched
4328    /// routes get `active` + component content, non-matched get `display:none`.
4329    fn process_route_directive(
4330        &mut self,
4331        element: &Element<'_>,
4332        fragments: &mut Vec<WebUIFragment>,
4333        depth: usize,
4334    ) -> Result<()> {
4335        let attrs = Self::route_attrs_from_element(element);
4336        let path = attrs.path.clone();
4337        let component = attrs.component.clone();
4338        route_parser::validate_attributes(&attrs)?;
4339
4340        let route_params: std::collections::HashSet<String> =
4341            route_parser::extract_params(&path)?.into_iter().collect();
4342        if !attrs.cache_tags.is_empty() {
4343            route_parser::validate_tag_placeholders(
4344                &attrs.cache_tags,
4345                &route_params,
4346                "cache-tags",
4347                &path,
4348            )?;
4349        }
4350        if !attrs.invalidates.is_empty() {
4351            route_parser::validate_tag_placeholders(
4352                &attrs.invalidates,
4353                &route_params,
4354                "invalidates",
4355                &path,
4356            )?;
4357        }
4358
4359        if !attrs.pending_component.is_empty() {
4360            self.ensure_route_component_parsed(&attrs.pending_component)?;
4361        }
4362        if !attrs.error_component.is_empty() {
4363            self.ensure_route_component_parsed(&attrs.error_component)?;
4364        }
4365        self.ensure_route_component_parsed(&component)?;
4366        self.flush_raw_buffer(fragments);
4367        let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4368
4369        let mut all_params = std::collections::HashSet::new();
4370        all_params.extend(route_params);
4371        self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
4372        let children =
4373            self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
4374
4375        let mut route_fragment =
4376            route_parser::build_route_fragment(&attrs, component.clone(), children);
4377        route_fragment.content_fragment_id = content_fragment_id;
4378        fragments.push(WebUIFragment::route_from(route_fragment));
4379
4380        Ok(())
4381    }
4382
4383    fn parse_route_boundary_content(
4384        &mut self,
4385        route: &Element<'_>,
4386        depth: usize,
4387    ) -> Result<String> {
4388        let inner = route.inner();
4389        if !route.source()[inner.clone()].contains("<boundary") {
4390            return Ok(String::new());
4391        }
4392        let saved_buffer = std::mem::take(&mut self.raw_buffer);
4393        let previous_route_depth = self.route_depth;
4394        self.route_depth += 1;
4395        let mut content = Vec::new();
4396        let parse_result: Result<()> = (|| {
4397            for event in Walker::new_range(route.source(), inner.start, inner.end) {
4398                if let Event::Element(element) = event {
4399                    if element.name() == "boundary" {
4400                        self.parse_range(
4401                            element.source(),
4402                            element.start..element.close_end(),
4403                            &mut content,
4404                            depth + 1,
4405                        )?;
4406                    }
4407                }
4408            }
4409            self.flush_raw_buffer(&mut content);
4410            Ok(())
4411        })();
4412        self.route_depth = previous_route_depth;
4413        self.raw_buffer = saved_buffer;
4414        parse_result?;
4415
4416        if content.is_empty() {
4417            return Ok(String::new());
4418        }
4419        let fragment_id = self.next_fragment_id("route-content");
4420        self.fragment_records.insert(
4421            fragment_id.clone(),
4422            FragmentList {
4423                fragments: content,
4424                contains_boundary: false,
4425            },
4426        );
4427        Ok(fragment_id)
4428    }
4429
4430    /// Parse nested `<route>` children into route fragments.
4431    ///
4432    /// `depth` is the nesting level of the routes produced here (top-level
4433    /// `<route>` children are depth 1). Nested routes are validated against
4434    /// [`MAX_TEMPLATE_DEPTH`] so pathological route trees cannot exhaust the
4435    /// call stack via `parse_route_as_fragment` recursion.
4436    fn parse_child_routes(
4437        &mut self,
4438        source: &str,
4439        range: Range<usize>,
4440        ancestor_params: &std::collections::HashSet<String>,
4441        depth: usize,
4442    ) -> Result<Vec<WebUiFragmentRoute>> {
4443        if depth > MAX_TEMPLATE_DEPTH {
4444            return Err(self
4445                .html_error(
4446                    codes::EXCESSIVE_NESTING,
4447                    format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4448                    source,
4449                    range.start,
4450                )
4451                .help("flatten deeply nested <route> trees before building")
4452                .into());
4453        }
4454
4455        let mut children = Vec::new();
4456        for event in Walker::new_range(source, range.start, range.end) {
4457            match event {
4458                Event::Element(element) => {
4459                    self.validate_closed_element(&element)?;
4460                    self.reject_boundary_or_hydrate_in_route(&element, true)?;
4461                    if element.name() == "route" {
4462                        children.push(self.parse_route_as_fragment(
4463                            &element,
4464                            ancestor_params,
4465                            depth,
4466                        )?);
4467                    } else if element.name().eq_ignore_ascii_case("style") {
4468                        self.validate_style_element(&element)?;
4469                    } else if !element.self_closing() && !element.is_void() {
4470                        self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
4471                    }
4472                }
4473                Event::Text(text) => {
4474                    if text.contains('<') {
4475                        return Err(self
4476                            .authoring_error(
4477                                codes::MALFORMED_HTML_TAG,
4478                                "malformed HTML tag in <route>",
4479                            )
4480                            .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
4481                            .into());
4482                    }
4483                }
4484                Event::Comment(comment_range) => {
4485                    self.validate_comment_range(source, comment_range)?;
4486                }
4487                Event::Declaration(declaration_range) => {
4488                    self.validate_declaration_range(source, declaration_range)?;
4489                }
4490                Event::ClosingTag(closing_range) => {
4491                    return Err(self.unexpected_closing_tag_error(source, closing_range));
4492                }
4493            }
4494        }
4495
4496        Ok(children)
4497    }
4498
4499    fn validate_route_nesting_depth(
4500        &self,
4501        source: &str,
4502        range: Range<usize>,
4503        depth: usize,
4504    ) -> Result<()> {
4505        let mut stack = vec![(range, depth)];
4506        while let Some((range, depth)) = stack.pop() {
4507            if depth > MAX_TEMPLATE_DEPTH {
4508                return Err(self
4509                    .html_error(
4510                        codes::EXCESSIVE_NESTING,
4511                        format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4512                        source,
4513                        range.start,
4514                    )
4515                    .help("flatten deeply nested <route> trees before building")
4516                    .into());
4517            }
4518
4519            for event in Walker::new_range(source, range.start, range.end) {
4520                if let Event::Element(element) = event {
4521                    self.validate_closed_element(&element)?;
4522                    if element.name() == "route" && !element.self_closing() {
4523                        stack.push((element.inner(), depth + 1));
4524                    }
4525                }
4526            }
4527        }
4528        Ok(())
4529    }
4530
4531    fn validate_ignored_route_html(
4532        &mut self,
4533        source: &str,
4534        range: Range<usize>,
4535        depth: usize,
4536    ) -> Result<()> {
4537        let mut stack = vec![(range, depth)];
4538        while let Some((range, depth)) = stack.pop() {
4539            if depth > MAX_TEMPLATE_DEPTH {
4540                return Err(self
4541                    .html_error(
4542                        codes::EXCESSIVE_NESTING,
4543                        format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4544                        source,
4545                        range.start,
4546                    )
4547                    .help("split deeply nested markup into components, or reduce generated nesting")
4548                    .into());
4549            }
4550
4551            for event in Walker::new_range(source, range.start, range.end) {
4552                match event {
4553                    Event::Element(element) => {
4554                        self.validate_closed_element(&element)?;
4555                        self.reject_boundary_or_hydrate_in_route(&element, false)?;
4556                        if element.name().eq_ignore_ascii_case("style") {
4557                            self.validate_style_element(&element)?;
4558                        } else if !element.self_closing() && !element.is_void() {
4559                            stack.push((element.inner(), depth + 1));
4560                        }
4561                    }
4562                    Event::Text(text) => {
4563                        if text.contains('<') {
4564                            return Err(self
4565                                .authoring_error(
4566                                    codes::MALFORMED_HTML_TAG,
4567                                    "malformed HTML tag in <route>",
4568                                )
4569                                .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
4570                                .into());
4571                        }
4572                    }
4573                    Event::Comment(comment_range) => {
4574                        self.validate_comment_range(source, comment_range)?;
4575                    }
4576                    Event::Declaration(declaration_range) => {
4577                        self.validate_declaration_range(source, declaration_range)?;
4578                    }
4579                    Event::ClosingTag(closing_range) => {
4580                        return Err(self.unexpected_closing_tag_error(source, closing_range));
4581                    }
4582                }
4583            }
4584        }
4585        Ok(())
4586    }
4587
4588    /// Reject `<webui-hydrate>` in route markup and boundaries that are not
4589    /// direct route children.
4590    ///
4591    /// `<route>` children are validated by a separate well-formedness walker
4592    /// ([`Self::parse_child_routes`]/[`Self::validate_ignored_route_html`]),
4593    /// not the main fragment dispatch in [`Self::parse_range`], so both
4594    /// reserved directives need an explicit check here to produce the same
4595    /// structured diagnostics as everywhere else instead of silently passing
4596    /// through as ignored HTML.
4597    fn reject_boundary_or_hydrate_in_route(
4598        &self,
4599        element: &Element<'_>,
4600        allow_boundary: bool,
4601    ) -> Result<()> {
4602        if element.name() == "boundary" && !allow_boundary {
4603            Err(self.route_boundary_placement_error(element))
4604        } else if element.name().eq_ignore_ascii_case("webui-hydrate") {
4605            Err(self.authored_webui_hydrate_error(element))
4606        } else {
4607            Ok(())
4608        }
4609    }
4610
4611    fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
4612        if !element.self_closing()
4613            && !element.is_void()
4614            && element.close_end() == element.content_end()
4615        {
4616            return Err(self
4617                .html_error(
4618                    codes::UNCLOSED_HTML_TAG,
4619                    format!("unclosed <{}> tag", element.name()),
4620                    element.source(),
4621                    element.start,
4622                )
4623                .help(format!(
4624                    "add the matching </{}> closing tag, or make the element self-closing",
4625                    element.name()
4626                ))
4627                .into());
4628        }
4629        Ok(())
4630    }
4631
4632    fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
4633        let inner = element.inner();
4634        let style_content = &element.source()[inner.start..inner.end];
4635        self.css_parser
4636            .extract_definitions_requirements_and_comments(
4637                style_content,
4638                self.options.legal_comments,
4639            )
4640            .map_err(|e| self.css_diagnostic(e))?;
4641        Ok(())
4642    }
4643
4644    fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4645        let start = range.start;
4646        if source[range].ends_with("-->") {
4647            return Ok(());
4648        }
4649        Err(self
4650            .html_error(
4651                codes::UNTERMINATED_HTML_COMMENT,
4652                "unterminated HTML comment in <route>",
4653                source,
4654                start,
4655            )
4656            .help("close the comment with `-->`")
4657            .into())
4658    }
4659
4660    fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4661        let start = range.start;
4662        if source[range].ends_with('>') {
4663            return Ok(());
4664        }
4665        Err(self
4666            .html_error(
4667                codes::UNTERMINATED_HTML_DECLARATION,
4668                "unterminated HTML declaration in <route>",
4669                source,
4670                start,
4671            )
4672            .help("close the declaration with `>`")
4673            .into())
4674    }
4675
4676    fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
4677        let start = range.start;
4678        let title = match html::parse_tag(&source[range]) {
4679            Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
4680            None => "unexpected closing tag in <route>".to_string(),
4681        };
4682        self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
4683            .help("remove it, or add the matching opening tag before it")
4684            .into()
4685    }
4686
4687    fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
4688        route_parser::RouteAttributes {
4689            path: element.attr("path").unwrap_or_default().to_string(),
4690            component: element.attr("component").unwrap_or_default().to_string(),
4691            exact: element.has_attr("exact"),
4692            query: element.attr("query").unwrap_or_default().to_string(),
4693            keep_alive: element.has_attr("keep-alive"),
4694            cache_tags: element
4695                .attr("cache-tags")
4696                .map(route_parser::parse_tag_list)
4697                .unwrap_or_default(),
4698            invalidates: element
4699                .attr("invalidates")
4700                .map(route_parser::parse_tag_list)
4701                .unwrap_or_default(),
4702            pending_component: element.attr("pending").unwrap_or_default().to_string(),
4703            error_component: element.attr("error").unwrap_or_default().to_string(),
4704        }
4705    }
4706
4707    fn parse_route_as_fragment(
4708        &mut self,
4709        element: &Element<'_>,
4710        ancestor_params: &std::collections::HashSet<String>,
4711        depth: usize,
4712    ) -> Result<WebUiFragmentRoute> {
4713        let attrs = Self::route_attrs_from_element(element);
4714        let path = attrs.path.clone();
4715        let component = attrs.component.clone();
4716
4717        route_parser::validate_attributes(&attrs)?;
4718
4719        let own_params: std::collections::HashSet<String> =
4720            route_parser::extract_params(&path)?.into_iter().collect();
4721        let mut all_params = ancestor_params.clone();
4722        all_params.extend(own_params.iter().cloned());
4723        if !attrs.cache_tags.is_empty() {
4724            route_parser::validate_tag_placeholders(
4725                &attrs.cache_tags,
4726                &all_params,
4727                "cache-tags",
4728                &path,
4729            )?;
4730        }
4731        if !attrs.invalidates.is_empty() {
4732            route_parser::validate_tag_placeholders(
4733                &attrs.invalidates,
4734                &all_params,
4735                "invalidates",
4736                &path,
4737            )?;
4738        }
4739
4740        if !attrs.pending_component.is_empty() {
4741            self.ensure_route_component_parsed(&attrs.pending_component)?;
4742        }
4743        if !attrs.error_component.is_empty() {
4744            self.ensure_route_component_parsed(&attrs.error_component)?;
4745        }
4746
4747        self.ensure_route_component_parsed(&component)?;
4748        let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4749        let children =
4750            self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
4751
4752        let mut route = route_parser::build_route_fragment(&attrs, component, children);
4753        route.content_fragment_id = content_fragment_id;
4754        Ok(route)
4755    }
4756
4757    /// Ensure a route-referenced component is parsed and registered.
4758    fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
4759        if component.is_empty()
4760            || !self.component_registry.contains(component)
4761            || self.fragment_records.contains_key(component)
4762        {
4763            return Ok(());
4764        }
4765
4766        let component_data = self
4767            .component_registry
4768            .get(component)
4769            .ok_or_else(|| {
4770                self.authoring_error(
4771                    codes::UNKNOWN_COMPONENT,
4772                    format!("unknown component <{component}>"),
4773                )
4774                .help(self.unknown_component_help(component))
4775            })?
4776            .clone();
4777        let artifact_html = self
4778            .component_registry
4779            .component_artifact_source(component)
4780            .map(str::to_string);
4781        let authored_html = self.component_registry.component_authored_source(component);
4782
4783        let built = self.build_component_templates(ComponentTemplateInput {
4784            tag_name: component,
4785            html: &component_data.html_content,
4786            artifact_html: artifact_html.as_deref(),
4787            authored_html: authored_html.as_deref(),
4788            css_content: component_data.css_content.as_deref(),
4789            artifact_needed: self.plugin.is_some(),
4790        })?;
4791
4792        if let Some(ref mut p) = self.plugin {
4793            let component_data = self
4794                .component_registry
4795                .get(component)
4796                .ok_or_else(|| {
4797                    ParserError::NotFound(format!(
4798                        "component <{component}> disappeared during CSS compilation"
4799                    ))
4800                })?
4801                .clone();
4802            p.component_built(built.plugin_context(&component_data))?;
4803        }
4804
4805        let saved_buffer = std::mem::take(&mut self.raw_buffer);
4806        self.parse(component, &built.ssr)?;
4807        self.raw_buffer = saved_buffer;
4808
4809        Ok(())
4810    }
4811
4812    /// Skipped attribute names for components.
4813    const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
4814    /// Skipped attribute prefixes for components.
4815    const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-"];
4816    const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
4817
4818    fn is_skipped_attribute(name: &str) -> bool {
4819        Self::SKIPPED_ATTRIBUTES.contains(&name)
4820            || Self::SKIPPED_ATTRIBUTE_PREFIXES
4821                .iter()
4822                .any(|prefix| name.starts_with(prefix))
4823    }
4824
4825    /// Properties that must never be set via `:attr` complex bindings.
4826    /// These enable XSS (HTML injection) or arbitrary code execution.
4827    fn is_blocked_complex_property(name: &str) -> bool {
4828        matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
4829    }
4830
4831    /// Build both SSR-facing and plugin-facing component template views.
4832    fn build_component_templates(
4833        &mut self,
4834        input: ComponentTemplateInput<'_>,
4835    ) -> Result<BuiltComponentTemplate> {
4836        let tag_name = input.tag_name;
4837        let synthesize_fast_shadow =
4838            input.artifact_html.is_some() && self.options.dom_strategy == DomStrategy::Shadow;
4839        let fast_html = synthesize_fast_shadow
4840            .then(|| Self::append_shadow_mode_if_missing(input.html.to_string()));
4841        let html = fast_html.as_deref().unwrap_or(input.html);
4842        let css_content = input.css_content;
4843        let dom_analysis = self.analyze_component_dom(tag_name, html, input.authored_html)?;
4844        let is_light = !dom_analysis.uses_shadow_dom;
4845        let mut inline_style_ranges = Vec::new();
4846        if is_light {
4847            let mut comment_ranges = Vec::new();
4848            Self::collect_html_comment_and_style_ranges(
4849                html,
4850                &mut comment_ranges,
4851                &mut inline_style_ranges,
4852            )?;
4853            if let Some(css) = css_content {
4854                css_light::validate_global_css(tag_name, css)?;
4855            }
4856            for (start, end) in &inline_style_ranges {
4857                css_light::validate_global_css(tag_name, &html[*start..*end])?;
4858            }
4859        }
4860        if self.component_registry.contains(tag_name) {
4861            self.component_registry
4862                .prepare_policy_css(tag_name, dom_analysis.uses_shadow_dom)?;
4863        }
4864        let css_content = self
4865            .component_registry
4866            .get(tag_name)
4867            .and_then(|component| component.css_content.as_deref())
4868            .or(css_content);
4869        let adopted_specifier = match self.options.css_strategy {
4870            CssStrategy::Module if css_content.is_some() => Some(tag_name),
4871            _ => None,
4872        };
4873        // CSS resources are installed by the handler from the precomputed
4874        // tree-local closure. Keeping template serialization style-free avoids
4875        // duplicating Light descendants and lets repeated Shadow instances each
4876        // receive an exact, claimable resource set.
4877        //
4878        // The resolved delivery is reported to the plugin as build context; a
4879        // plugin whose client runtime builds its own roots from the captured
4880        // template decides for itself what to do with it. Light CSS is
4881        // Document/tree-owned and is installed by the precomputed closure.
4882        let style = if dom_analysis.uses_shadow_dom && self.plugin.is_some() {
4883            self.component_style_delivery(tag_name, css_content)
4884        } else {
4885            None
4886        };
4887
4888        let runtime_attr_source = match dom_analysis.authored_shadow_root {
4889            Some((start, end)) => &html[start..end],
4890            None => html,
4891        };
4892        let artifact_source = if input.artifact_needed {
4893            input.artifact_html.or_else(|| {
4894                Self::template_has_stripped_runtime_attrs(runtime_attr_source).then_some(html)
4895            })
4896        } else {
4897            None
4898        };
4899        // A `w-render`/`w-hydrate` component authors its policy on a plain
4900        // `<template>` wrapper. The policy lands on the host element as
4901        // generated CSS, so the wrapper itself must not survive into a Light
4902        // template, where its contents would never render.
4903        let policy_wrapper = parse_component_render_policy(tag_name, html)?.is_authored();
4904        // Keep root runtime attributes only when the plugin processes them.
4905        let preserve_root_bindings = self.component_processing.process_root_template_attributes;
4906        // Keep CSS braces after client bindings when requested by the plugin.
4907        let styles_at_end = matches!(self.options.css_strategy, CssStrategy::Style)
4908            && self.component_processing.inline_styles_after_content;
4909        let ssr = self.process_component_template_with_mode(
4910            html,
4911            ComponentStyleInjection {
4912                css_snippet: None,
4913                adopted_specifier,
4914            },
4915            dom_analysis,
4916            ComponentTemplateMode {
4917                preserve_runtime_attrs: preserve_root_bindings,
4918                policy_wrapper,
4919                styles_at_end,
4920            },
4921        )?;
4922        let artifact = match artifact_source {
4923            Some(source) => {
4924                let fast_artifact = synthesize_fast_shadow
4925                    .then(|| Self::append_shadow_mode_if_missing(source.to_string()));
4926                let source = fast_artifact.as_deref().unwrap_or(source);
4927                let artifact_dom_analysis = analyze_component_dom_with_diagnostic_source(
4928                    tag_name,
4929                    source,
4930                    self.options.dom_strategy,
4931                    input.authored_html,
4932                )?;
4933                Some(self.process_component_template_with_mode(
4934                    source,
4935                    ComponentStyleInjection {
4936                        css_snippet: None,
4937                        adopted_specifier,
4938                    },
4939                    artifact_dom_analysis,
4940                    ComponentTemplateMode {
4941                        // Client artifacts preserve runtime-only attributes
4942                        // (`@event`, `:bind`, `?cond`) for the plugin runtime.
4943                        preserve_runtime_attrs: true,
4944                        policy_wrapper,
4945                        styles_at_end,
4946                    },
4947                )?)
4948            }
4949            None => None,
4950        };
4951        Ok(BuiltComponentTemplate {
4952            ssr,
4953            artifact,
4954            uses_shadow_dom: dom_analysis.uses_shadow_dom,
4955            style,
4956        })
4957    }
4958
4959    /// Resolve how a component's compiled CSS reaches the browser.
4960    ///
4961    /// Build-time only, once per component definition, and only when a plugin
4962    /// can observe it.
4963    fn component_style_delivery(
4964        &self,
4965        tag_name: &str,
4966        css_content: Option<&str>,
4967    ) -> Option<OwnedComponentStyle> {
4968        let css = css_content?;
4969        Some(match self.options.css_strategy {
4970            CssStrategy::Link => {
4971                OwnedComponentStyle::Link(self.options.css_link_options.resolve(tag_name, css).href)
4972            }
4973            CssStrategy::Style => OwnedComponentStyle::Inline(css.trim().to_string()),
4974            CssStrategy::Module => OwnedComponentStyle::Adopted(tag_name.to_string()),
4975        })
4976    }
4977
4978    /// Process component template HTML for SSR output.
4979    ///
4980    /// An authored declarative Shadow DOM wrapper is the source of truth.
4981    ///
4982    /// - **Dev supplied `<template shadowrootmode="open">`:** preserved verbatim — including
4983    ///   `shadowrootmode`, `shadowrootadoptedstylesheets`, signal fragments,
4984    ///   and any other custom attributes. SSR strips runtime-only attributes
4985    ///   (`@event`, `:bind`, `?cond`) from the opening tag, since those are
4986    ///   protocol metadata and never appear in HTML output. Plugin-facing
4987    ///   artifacts preserve them. If a CSS snippet is supplied, it is injected
4988    ///   immediately inside the opening tag (before the dev's children) so
4989    ///   styles still apply. For `CssStrategy::Module`, the parser appends
4990    ///   `shadowrootadoptedstylesheets="<tag>"` when it is missing.
4991    ///
4992    /// - **Sole bare `<template>` root:** unwraps the wrapper and emits its
4993    ///   contents as explicit Light DOM.
4994    ///
4995    /// - **No authored root:** follows the configured fallback, generating an
4996    ///   open Shadow wrapper for `DomStrategy::Shadow` or emitting direct Light
4997    ///   DOM for `DomStrategy::Light`.
4998    ///
4999    /// Performance: zero recursion, zero regex. The dev-template path uses
5000    /// quote-aware scanners for opening-tag queries and pre-sizes the output
5001    /// buffer to avoid reallocation in the hot path.
5002    #[cfg(test)]
5003    fn process_component_template(
5004        &mut self,
5005        html: &str,
5006        css_snippet: Option<&str>,
5007        adopted_specifier: Option<&str>,
5008    ) -> Result<String> {
5009        let dom_analysis = analyze_component_dom("component", html, self.options.dom_strategy)?;
5010        self.process_component_template_with_mode(
5011            html,
5012            ComponentStyleInjection {
5013                css_snippet,
5014                adopted_specifier,
5015            },
5016            dom_analysis,
5017            ComponentTemplateMode {
5018                preserve_runtime_attrs: false,
5019                policy_wrapper: false,
5020                styles_at_end: false,
5021            },
5022        )
5023    }
5024
5025    fn process_component_template_with_mode(
5026        &mut self,
5027        html: &str,
5028        style: ComponentStyleInjection<'_>,
5029        dom_analysis: ComponentDomAnalysis,
5030        mode: ComponentTemplateMode,
5031    ) -> Result<String> {
5032        let trimmed_end = html.trim_end();
5033        let (trimmed, content_start) = html::leading_content(trimmed_end);
5034        let snippet = style.css_snippet.unwrap_or_default();
5035
5036        let processed = if let Some((root_start, root_end)) = dom_analysis.authored_shadow_root {
5037            let trim_start = content_start;
5038            let trim_end = html.trim_end().len();
5039            if root_start < trim_start || root_end > trim_end || root_start >= root_end {
5040                return Err(ParserError::Html(
5041                    "invalid declarative Shadow DOM source range".to_string(),
5042                ));
5043            }
5044            let root = &html[root_start..root_end];
5045            // Compiler-owned policy attributes are stripped from both views;
5046            // runtime attributes survive only in the captured client template.
5047            let base = self.strip_template_build_attrs(root, mode.preserve_runtime_attrs);
5048            let with_adopted = Self::append_adopted_attr_if_missing(base, style.adopted_specifier);
5049            let root =
5050                Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end);
5051            let mut result =
5052                String::with_capacity(trimmed.len() + root.len() - (root_end - root_start));
5053            result.push_str(&html[trim_start..root_start]);
5054            result.push_str(&root);
5055            result.push_str(&html[root_end..trim_end]);
5056            result
5057        } else if let Some((content_start, content_end)) = dom_analysis.authored_light_root {
5058            let base = html[content_start..content_end].to_string();
5059            if snippet.is_empty() {
5060                base
5061            } else {
5062                let mut result = String::with_capacity(snippet.len() + base.len());
5063                result.push_str(snippet);
5064                result.push_str(&base);
5065                result
5066            }
5067        } else if dom_analysis.uses_shadow_dom {
5068            if mode.policy_wrapper {
5069                let base = self.strip_template_build_attrs(trimmed, mode.preserve_runtime_attrs);
5070                let with_shadow = Self::append_shadow_mode_if_missing(base);
5071                let with_adopted =
5072                    Self::append_adopted_attr_if_missing(with_shadow, style.adopted_specifier);
5073                Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end)
5074            } else {
5075                let adopted = style.adopted_specifier.unwrap_or_default();
5076                let adopted_extra = if adopted.is_empty() {
5077                    0
5078                } else {
5079                    Self::adopted_attr_len(adopted)
5080                };
5081                let mut result =
5082                    String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
5083                result.push_str("<template shadowrootmode=\"open\"");
5084                if !adopted.is_empty() {
5085                    Self::push_adopted_attr(&mut result, adopted);
5086                }
5087                result.push('>');
5088                if mode.styles_at_end {
5089                    result.push_str(trimmed);
5090                    result.push_str(snippet);
5091                } else {
5092                    result.push_str(snippet);
5093                    result.push_str(trimmed);
5094                }
5095                result.push_str("</template>");
5096                result
5097            }
5098        } else {
5099            // A policy component authors its `w-render`/`w-hydrate` on a plain
5100            // `<template>` wrapper. The policy is applied to the host element
5101            // through generated CSS, so a Light template must drop the wrapper
5102            // or its contents would never render.
5103            let base = if mode.policy_wrapper {
5104                Self::unwrap_component_template(trimmed)
5105            } else {
5106                trimmed.to_string()
5107            };
5108            if snippet.is_empty() {
5109                base
5110            } else {
5111                let mut result = String::with_capacity(snippet.len() + base.len());
5112                result.push_str(snippet);
5113                result.push_str(&base);
5114                result
5115            }
5116        };
5117
5118        self.strip_template_comments(processed)
5119    }
5120
5121    fn inject_css_snippet_into_template(html: String, snippet: &str, at_end: bool) -> String {
5122        if snippet.is_empty() {
5123            return html;
5124        }
5125
5126        // Trail the snippet after the template body (before the closing
5127        // `</template>`) so a client runtime that scans the body for `{`/`}`
5128        // bindings (FAST's declarative TemplateParser) never mistakes raw CSS
5129        // rule blocks for bindings. Styles apply regardless of shadow-root
5130        // position. Falls back to opening-tag injection when no closing tag is
5131        // present so styles are never silently dropped.
5132        if at_end {
5133            if let Some(close_start) = html.rfind("</template>") {
5134                let mut result = String::with_capacity(html.len() + snippet.len());
5135                result.push_str(&html[..close_start]);
5136                result.push_str(snippet);
5137                result.push_str(&html[close_start..]);
5138                return result;
5139            }
5140        }
5141
5142        match html::find_tag_close(&html) {
5143            Some(open_end) => {
5144                let mut result = String::with_capacity(html.len() + snippet.len());
5145                result.push_str(&html[..=open_end]);
5146                result.push_str(snippet);
5147                result.push_str(&html[open_end + 1..]);
5148                result
5149            }
5150            // Malformed opening tag (no closing `>`): emit as-is rather than
5151            // panic. The downstream parser surfaces the error.
5152            None => html,
5153        }
5154    }
5155
5156    fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
5157        let Some(adopted) = adopted_specifier else {
5158            return html;
5159        };
5160        let Some(tag) = html::parse_tag(&html) else {
5161            return html;
5162        };
5163        if !tag.name.eq_ignore_ascii_case("template")
5164            || tag.closing
5165            || tag.attrs().any(|attr| {
5166                attr.name
5167                    .eq_ignore_ascii_case(Self::ADOPTED_STYLESHEETS_ATTR)
5168            })
5169        {
5170            return html;
5171        }
5172
5173        let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
5174        result.push_str(&html[..tag.close]);
5175        Self::push_adopted_attr(&mut result, adopted);
5176        result.push_str(&html[tag.close..]);
5177        result
5178    }
5179
5180    fn append_shadow_mode_if_missing(html: String) -> String {
5181        let Some(tag) = html::parse_tag(&html) else {
5182            return html;
5183        };
5184        if !tag.name.eq_ignore_ascii_case("template")
5185            || tag.closing
5186            || tag
5187                .attrs()
5188                .any(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
5189        {
5190            return html;
5191        }
5192
5193        let mut result = String::with_capacity(html.len() + 22);
5194        result.push_str(&html[..tag.close]);
5195        result.push_str(" shadowrootmode=\"open\"");
5196        result.push_str(&html[tag.close..]);
5197        result
5198    }
5199
5200    fn unwrap_component_template(html: &str) -> String {
5201        let Some(tag) = html::parse_tag(html) else {
5202            return html.to_string();
5203        };
5204        let inner_start = tag.close + 1;
5205        let inner_end = html.rfind("</template>").unwrap_or(html.len());
5206        if inner_start >= inner_end {
5207            return String::new();
5208        }
5209        html[inner_start..inner_end].to_string()
5210    }
5211
5212    fn push_adopted_attr(out: &mut String, adopted: &str) {
5213        out.push(' ');
5214        out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
5215        out.push_str("=\"");
5216        out.push_str(adopted);
5217        out.push('"');
5218    }
5219
5220    fn adopted_attr_len(adopted: &str) -> usize {
5221        4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
5222    }
5223
5224    fn template_has_stripped_runtime_attrs(html: &str) -> bool {
5225        let (trimmed, _) = html::leading_content(html);
5226        let Some(tag) = html::parse_tag(trimmed) else {
5227            return false;
5228        };
5229        if !tag.name.eq_ignore_ascii_case("template") || tag.closing {
5230            return false;
5231        }
5232        tag.attrs().any(|attr| {
5233            attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
5234        })
5235    }
5236
5237    fn strip_template_comments(&mut self, html: String) -> Result<String> {
5238        let mut ranges = Vec::new();
5239        let mut style_ranges = Vec::new();
5240        Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
5241
5242        for (style_start, style_end) in style_ranges {
5243            let css = &html[style_start..style_end];
5244            let (_defs, _requirements, comments) = self
5245                .css_parser
5246                .extract_definitions_requirements_and_comments(css, self.options.legal_comments)
5247                .map_err(|e| self.css_diagnostic(e))?;
5248            for comment in comments {
5249                let comment_text = &css[comment.start_byte..comment.end_byte];
5250                if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
5251                    continue;
5252                }
5253                ranges.push((
5254                    style_start + comment.start_byte,
5255                    style_start + comment.end_byte,
5256                ));
5257            }
5258        }
5259
5260        if ranges.is_empty() {
5261            return Ok(html);
5262        }
5263
5264        Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
5265    }
5266
5267    /// Collect HTML comment ranges and `<style>` raw-text ranges in one scan.
5268    fn collect_html_comment_and_style_ranges(
5269        html: &str,
5270        comment_ranges: &mut Vec<(usize, usize)>,
5271        style_ranges: &mut Vec<(usize, usize)>,
5272    ) -> Result<()> {
5273        let mut index = 0usize;
5274        while index < html.len() {
5275            let remaining = &html[index..];
5276            if remaining.starts_with("<!--") {
5277                let Some(end) = html::find_comment_close(remaining) else {
5278                    return Err(Diagnostic::error("unterminated HTML comment")
5279                        .code(codes::UNTERMINATED_HTML_COMMENT)
5280                        .at_offset(html, index)
5281                        .help("close the comment with `-->`")
5282                        .into());
5283                };
5284                comment_ranges.push((index, index + end));
5285                index += end;
5286                continue;
5287            }
5288
5289            if remaining.starts_with('<') {
5290                if let Some((style_start, style_end, close_end)) =
5291                    html::style_element_bounds(remaining)
5292                {
5293                    style_ranges.push((index + style_start, index + style_end));
5294                    index += close_end;
5295                    continue;
5296                }
5297
5298                if let Some(tag) = html::parse_tag(remaining) {
5299                    index += tag.close + 1;
5300                    continue;
5301                }
5302            }
5303
5304            let Some(ch) = remaining.chars().next() else {
5305                break;
5306            };
5307            index += ch.len_utf8();
5308        }
5309        Ok(())
5310    }
5311
5312    /// Strip compiler-owned component policy attributes and, for the SSR view,
5313    /// runtime attributes starting with `@`, `:`, or `?` from the opening
5314    /// `<template>` tag.
5315    ///
5316    /// Uses the same quote-aware tag scanner as the main HTML pipeline.
5317    fn strip_template_build_attrs(&mut self, html: &str, preserve_runtime_attrs: bool) -> String {
5318        let Some(tag) = html::parse_tag(html) else {
5319            return html.to_string();
5320        };
5321
5322        let mut removals: Vec<(usize, usize)> = Vec::new();
5323        for attr in tag.attrs() {
5324            let runtime_attr = attr.name.starts_with('@')
5325                || attr.name.starts_with(':')
5326                || attr.name.starts_with('?');
5327            let policy_attr = matches!(
5328                attr.name,
5329                COMPONENT_RENDER_ATTR | COMPONENT_HYDRATE_ATTR | COMPONENT_RESERVE_BLOCK_SIZE_ATTR
5330            );
5331            if policy_attr || (!preserve_runtime_attrs && runtime_attr) {
5332                let mut start = attr.raw_range.start;
5333                while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
5334                    start -= 1;
5335                }
5336                removals.push((start, attr.raw_range.end));
5337            }
5338        }
5339
5340        if removals.is_empty() {
5341            return html.to_string();
5342        }
5343
5344        // Rebuild the string, skipping removed ranges
5345        let mut result = String::with_capacity(html.len());
5346        let mut pos = 0;
5347        for (start, end) in &removals {
5348            result.push_str(&html[pos..*start]);
5349            pos = *end;
5350        }
5351        result.push_str(&html[pos..]);
5352        result
5353    }
5354}
5355
5356#[cfg(test)]
5357mod tests {
5358    use webui_test_utils::*;
5359
5360    use super::*;
5361
5362    #[derive(Default)]
5363    struct ContextArtifactPlugin {
5364        artifacts: Vec<crate::plugin::ComponentTemplateArtifact>,
5365    }
5366
5367    impl ParserPlugin for ContextArtifactPlugin {
5368        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5369            self.artifacts
5370                .push(crate::plugin::ComponentTemplateArtifact::template(
5371                    context.component.tag_name.clone(),
5372                    context.template.to_string(),
5373                    context.uses_shadow_dom,
5374                ));
5375            Ok(())
5376        }
5377
5378        fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
5379            Ok(ParserPluginArtifacts::ComponentTemplates(self.artifacts))
5380        }
5381    }
5382
5383    fn structural_matcher(value: &str) -> FragmentMatcher {
5384        signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
5385    }
5386
5387    mod fast_parser;
5388
5389    fn boundary_matcher(name: &str, declaration: u32) -> FragmentMatcher {
5390        webui_test_utils::boundary(name, declaration)
5391    }
5392
5393    fn boundary_end_matcher(declaration: u32) -> FragmentMatcher {
5394        webui_test_utils::boundary_end(declaration)
5395    }
5396
5397    #[test]
5398    fn unwrapped_component_uses_configured_dom_strategy() {
5399        assert!(
5400            analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Shadow)
5401                .expect("valid component")
5402                .uses_shadow_dom
5403        );
5404        assert!(
5405            !analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Light)
5406                .expect("valid component")
5407                .uses_shadow_dom
5408        );
5409    }
5410
5411    #[test]
5412    fn bare_template_explicitly_selects_light_dom_over_shadow_fallback() {
5413        let analysis = analyze_component_dom(
5414            "x-card",
5415            "<template><div>content</div></template>",
5416            DomStrategy::Shadow,
5417        )
5418        .expect("valid explicit Light wrapper");
5419        assert!(!analysis.uses_shadow_dom);
5420        assert!(analysis.authored_light_root.is_some());
5421
5422        let attributed = analyze_component_dom(
5423            "x-card",
5424            r#"<template data-purpose="content"><div>content</div></template>"#,
5425            DomStrategy::Shadow,
5426        )
5427        .expect("ordinary template remains valid");
5428        assert!(attributed.uses_shadow_dom);
5429        assert!(attributed.authored_light_root.is_none());
5430    }
5431
5432    #[test]
5433    fn plugin_artifacts_preserve_resolved_shadow_mode() {
5434        let component_html = r#"<template shadowrootmode="open"><p>shadow</p></template>"#;
5435        {
5436            let mut parser = HtmlParser::with_plugin_options(
5437                Box::new(ContextArtifactPlugin::default()),
5438                CssStrategy::Style,
5439            );
5440            parser
5441                .component_registry_mut()
5442                .register_component(ComponentRegistration::new(
5443                    "x-card",
5444                    component_html,
5445                    None,
5446                    false,
5447                ))
5448                .expect("register component");
5449            parser
5450                .parse("index.html", "<x-card></x-card>")
5451                .expect("parse component");
5452
5453            let ParserPluginArtifacts::ComponentTemplates(artifacts) =
5454                parser.take_plugin_artifacts().expect("consume artifacts")
5455            else {
5456                panic!("expected component artifacts");
5457            };
5458            assert!(artifacts[0].uses_shadow_dom);
5459        }
5460    }
5461
5462    #[test]
5463    fn open_shadow_wrapper_marks_component_shadow() {
5464        let html =
5465            "<!-- lead --><template shadowrootmode='open'><slot></slot></template><!-- tail -->";
5466        assert!(
5467            analyze_component_dom("x-card", html, DomStrategy::Light)
5468                .expect("valid Shadow wrapper")
5469                .uses_shadow_dom
5470        );
5471    }
5472
5473    #[test]
5474    fn closed_shadow_wrapper_is_rejected() {
5475        let error = analyze_component_dom(
5476            "x-card",
5477            "<template shadowrootmode=\"closed\"><p>content</p></template>",
5478            DomStrategy::Shadow,
5479        )
5480        .expect_err("closed mode must fail");
5481        assert!(matches!(
5482            error,
5483            ParserError::Template(ref diagnostic)
5484                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5485                    && diagnostic.component_name() == Some("x-card")
5486        ));
5487    }
5488
5489    #[test]
5490    fn shadow_wrapper_must_be_the_only_top_level_element() {
5491        let error = analyze_component_dom(
5492            "x-card",
5493            "<template shadowrootmode=\"open\"><p>content</p></template><span>extra</span>",
5494            DomStrategy::Light,
5495        )
5496        .expect_err("multiple roots must fail");
5497        assert!(matches!(
5498            error,
5499            ParserError::Template(ref diagnostic)
5500                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5501        ));
5502    }
5503
5504    #[test]
5505    fn shadowrootmode_rejects_invalid_value_and_placement() {
5506        for html in [
5507            "<template shadowrootmode=\"invalid\"><p>content</p></template>",
5508            "<template shadowrootmode=\" open \"><p>content</p></template>",
5509            "<template :shadowrootmode=\"{{mode}}\"><p>content</p></template>",
5510            "<template shadowrootmode=\"open\" />",
5511            "<div><template shadowrootmode=\"open\"><p>content</p></template></div>",
5512            "<div shadowrootmode=\"open\">content</div>",
5513        ] {
5514            let error = analyze_component_dom("x-card", html, DomStrategy::Shadow)
5515                .expect_err("invalid shadowrootmode must fail");
5516            assert!(matches!(
5517                error,
5518                ParserError::Template(ref diagnostic)
5519                    if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5520                        && diagnostic.component_name() == Some("x-card")
5521                        && diagnostic.help_text().is_some()
5522                        && diagnostic.snippet_text().is_some()
5523            ));
5524        }
5525    }
5526
5527    #[test]
5528    fn light_component_slot_is_rejected() {
5529        let source = "<div><slot name=\"label\"></slot></div>";
5530        let error = analyze_component_dom("x-card", source, DomStrategy::Light)
5531            .expect_err("Light slot must fail");
5532        assert!(matches!(
5533            error,
5534            ParserError::Template(ref diagnostic)
5535                if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5536                    && diagnostic.help_text().is_some_and(|help| help.contains("shadowrootmode"))
5537        ));
5538        assert!(
5539            analyze_component_dom("x-card", source, DomStrategy::Shadow)
5540                .expect("implicit Shadow components support slots")
5541                .uses_shadow_dom
5542        );
5543
5544        let explicit_light = "<template><slot name=\"label\"></slot></template>";
5545        let error = analyze_component_dom("x-card", explicit_light, DomStrategy::Shadow)
5546            .expect_err("bare template explicitly selects Light");
5547        assert!(matches!(
5548            error,
5549            ParserError::Template(ref diagnostic)
5550                if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5551        ));
5552    }
5553
5554    #[test]
5555    fn webui_plugin_records_authored_shadow_wrapper() {
5556        let mut parser =
5557            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5558        parser
5559            .component_registry_mut()
5560            .register_component(ComponentRegistration::new(
5561                "x-card",
5562                "<!-- lead --><template shadowrootmode=\"open\" @click=\"{onClick()}\"><slot></slot></template><!-- tail -->",
5563                None,
5564                true,
5565            ))
5566            .expect("register component");
5567        parser
5568            .parse("index.html", "<x-card>projected</x-card>")
5569            .expect("parse component");
5570
5571        let ParserPluginArtifacts::ComponentTemplates(templates) =
5572            parser.take_plugin_artifacts().expect("plugin artifacts")
5573        else {
5574            panic!("expected component templates");
5575        };
5576        assert!(templates[0].uses_shadow_dom);
5577        assert!(templates[0].template_json.contains("\"sd\":1"));
5578        assert!(templates[0]
5579            .template_json
5580            .contains(r#""re":[["click","onClick",[]]]"#));
5581    }
5582
5583    /// Records the build context so tests can assert what the parser reports
5584    /// without asserting how any particular plugin reacts to it.
5585    struct StyleContextPlugin {
5586        captured: std::rc::Rc<std::cell::RefCell<Vec<(bool, Option<String>, String)>>>,
5587    }
5588
5589    impl crate::plugin::ParserPlugin for StyleContextPlugin {
5590        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5591            self.captured.borrow_mut().push((
5592                context.uses_shadow_dom,
5593                context.style.map(|style| format!("{style:?}")),
5594                context.template.to_string(),
5595            ));
5596            Ok(())
5597        }
5598    }
5599
5600    fn capture_style_context(
5601        strategy: CssStrategy,
5602        dom_strategy: DomStrategy,
5603        template: &str,
5604        css: Option<&str>,
5605    ) -> (bool, Option<String>, String) {
5606        let captured = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
5607        let mut parser = HtmlParser::with_plugin_options(
5608            Box::new(StyleContextPlugin {
5609                captured: std::rc::Rc::clone(&captured),
5610            }),
5611            ParserOptions {
5612                css_strategy: strategy,
5613                dom_strategy,
5614                ..ParserOptions::default()
5615            },
5616        );
5617        parser
5618            .component_registry_mut()
5619            .register_component(ComponentRegistration::new("x-card", template, css, true))
5620            .expect("register component");
5621        parser
5622            .parse("index.html", "<x-card></x-card>")
5623            .expect("parse component");
5624        let captured = captured.borrow();
5625        captured.first().cloned().expect("component registered")
5626    }
5627
5628    /// The parser reports the resolved delivery as data and injects nothing:
5629    /// a plugin that ignores `context.style` gets a style-free template even
5630    /// for an authored Shadow component.
5631    #[test]
5632    fn parser_reports_shadow_style_delivery_without_injecting_it() {
5633        for (strategy, expected) in [
5634            (CssStrategy::Link, "Link { href: \"x-card.css\" }"),
5635            (CssStrategy::Style, "Inline { css: \".card{color:red}\" }"),
5636            (CssStrategy::Module, "Adopted { specifier: \"x-card\" }"),
5637        ] {
5638            let (uses_shadow_dom, style, template) = capture_style_context(
5639                strategy,
5640                DomStrategy::Shadow,
5641                "<template shadowrootmode=\"open\" shadowrootadoptedstylesheets=\"x-card\"><div class=\"card\"></div></template>",
5642                Some(".card{color:red}"),
5643            );
5644            assert!(uses_shadow_dom, "{strategy:?} should be shadow");
5645            assert_eq!(style.as_deref(), Some(expected), "{strategy:?} delivery");
5646            assert!(
5647                !template.contains("<style>") && !template.contains("<link"),
5648                "{strategy:?} template must stay injection-free: {template}"
5649            );
5650        }
5651    }
5652
5653    /// Light CSS belongs to the owning CSS tree, so there is nothing for a
5654    /// plugin to place inside a runtime-created root.
5655    #[test]
5656    fn parser_reports_no_style_delivery_for_light_components() {
5657        let (uses_shadow_dom, style, _) = capture_style_context(
5658            CssStrategy::Style,
5659            DomStrategy::Light,
5660            "<div class=\"card\"></div>",
5661            Some(".card{color:red}"),
5662        );
5663        assert!(!uses_shadow_dom);
5664        assert_eq!(style, None);
5665    }
5666
5667    /// A component without CSS reports no delivery at all.
5668    #[test]
5669    fn parser_reports_no_style_delivery_without_css() {
5670        let (uses_shadow_dom, style, _) = capture_style_context(
5671            CssStrategy::Style,
5672            DomStrategy::Shadow,
5673            "<template shadowrootmode=\"open\"><div></div></template>",
5674            None,
5675        );
5676        assert!(uses_shadow_dom);
5677        assert_eq!(style, None);
5678    }
5679
5680    #[test]
5681    fn test_plugin_display_names() {
5682        assert_eq!(Plugin::Fast.to_string(), "fast");
5683        assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
5684        assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
5685        assert_eq!(Plugin::WebUI.to_string(), "webui");
5686    }
5687
5688    #[test]
5689    fn test_plugin_from_str_names() {
5690        assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
5691        assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
5692        assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
5693        assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
5694    }
5695
5696    #[test]
5697    fn test_plugin_from_str_unknown_mentions_supported_names() {
5698        let err = "legacy".parse::<Plugin>().unwrap_err();
5699        assert!(
5700            err.contains("fast-v3"),
5701            "error should mention fast-v3: {err}"
5702        );
5703        assert!(
5704            err.contains("fast-v2"),
5705            "error should mention fast-v2: {err}"
5706        );
5707        assert!(err.contains("fast"), "error should mention fast: {err}");
5708    }
5709
5710    #[test]
5711    fn test_parse_signal() {
5712        let mut parser = HtmlParser::new();
5713        let html = "Hello, {{name}}!";
5714        let result = parser.parse("test.html", html);
5715
5716        assert!(result.is_ok());
5717        let fragment_records = parser.into_fragment_records();
5718
5719        assert_stream!(
5720            fragment_records,
5721            "test.html",
5722            [raw("Hello, "), signal("name"), raw("!"),]
5723        );
5724    }
5725
5726    #[test]
5727    fn test_parse_raw_signal() {
5728        let mut parser = HtmlParser::new();
5729        let html = "Hello, {{{html_content}}}!";
5730        let result = parser.parse("test.html", html);
5731
5732        assert!(result.is_ok());
5733        let fragment_records = parser.into_fragment_records();
5734
5735        assert_stream!(
5736            fragment_records,
5737            "test.html",
5738            [raw("Hello, "), signal_raw("html_content"), raw("!"),]
5739        );
5740    }
5741
5742    #[test]
5743    fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
5744        let mut parser = HtmlParser::new();
5745        let html = "<nav>{{sectionName}} &gt; {{topicName}}</nav>";
5746        let result = parser.parse("test.html", html);
5747
5748        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5749        let fragment_records = parser.into_fragment_records();
5750
5751        assert_stream!(
5752            fragment_records,
5753            "test.html",
5754            [
5755                raw("<nav>"),
5756                signal("sectionName"),
5757                raw(" &gt; "),
5758                signal("topicName"),
5759                raw("</nav>"),
5760            ]
5761        );
5762    }
5763
5764    #[test]
5765    fn test_invalid_for_each_is_template_diagnostic() {
5766        let mut parser = HtmlParser::new();
5767        let err = parser
5768            .parse(
5769                "index.html",
5770                "<div>\n  <for each=\"person inpeople\"><p>x</p></for>\n</div>",
5771            )
5772            .expect_err("invalid for-each must error");
5773        assert!(matches!(err, ParserError::Template(_)));
5774        let msg = err.to_string();
5775        assert!(msg.contains("invalid <for> each expression"), "{msg}");
5776        assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
5777        // The <for> sits on line 2, column 3 (after the two-space indent),
5778        // reported rustc-style as `--> index.html:2:3`.
5779        assert!(
5780            msg.contains("--> index.html:2:3"),
5781            "missing line:column — {msg}"
5782        );
5783        assert!(msg.contains("help:"), "{msg}");
5784    }
5785
5786    #[test]
5787    fn test_invalid_if_condition_is_template_diagnostic() {
5788        let mut parser = HtmlParser::new();
5789        let err = parser
5790            .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
5791            .expect_err("invalid condition must error");
5792        assert!(matches!(err, ParserError::Template(_)));
5793        let msg = err.to_string();
5794        assert!(msg.contains("invalid <if> condition expression"), "{msg}");
5795        assert!(
5796            msg.contains("--> index.html:1:1"),
5797            "missing line:column — {msg}"
5798        );
5799    }
5800
5801    #[test]
5802    fn test_parse_for_directive() {
5803        let mut parser = HtmlParser::new();
5804        let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
5805
5806        let result = parser.parse("test.html", html);
5807        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5808        let fragment_records = parser.into_fragment_records();
5809
5810        assert_stream!(
5811            fragment_records,
5812            "test.html",
5813            [for_loop("item", "items", "for-1"),]
5814        );
5815
5816        // Verify the sub-fragment contains our item content
5817        assert_stream!(
5818            fragment_records,
5819            "for-1",
5820            [
5821                raw("<div class=\"item\">"),
5822                signal("item.name"),
5823                raw("</div>"),
5824            ]
5825        );
5826    }
5827
5828    #[test]
5829    fn test_parse_if_directive() {
5830        let mut parser = HtmlParser::new();
5831        let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
5832
5833        let result = parser.parse("test.html", html);
5834        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5835        let fragment_records = parser.into_fragment_records();
5836
5837        assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
5838
5839        // Verify the sub-fragment contains our content
5840        assert_stream!(
5841            fragment_records,
5842            "if-1",
5843            [
5844                raw("<div>Welcome back, "),
5845                signal("username"),
5846                raw("!</div>"),
5847            ]
5848        );
5849    }
5850
5851    #[test]
5852    fn test_component_directive() {
5853        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5854        parser
5855            .component_registry
5856            .register_component(ComponentRegistration::new(
5857                "my-component",
5858                "<div>My Component</div>",
5859                Some("div { color: blue; }"),
5860                true,
5861            ))
5862            .expect("Failed to register component");
5863
5864        let result = parser.parse("test.html", "<my-component></my-component>");
5865        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5866        let records = parser.into_fragment_records();
5867
5868        assert_stream!(
5869            records,
5870            "test.html",
5871            [
5872                raw("<my-component"),
5873                structural_matcher("streaming_root:my-component"),
5874                raw(">"),
5875                component("my-component"),
5876                raw("</my-component>"),
5877            ]
5878        );
5879
5880        // Component template stream should contain the component content with
5881        // no compiler-owned Light DOM markers.
5882        let comp = &records["my-component"].fragments;
5883        assert_eq!(comp.len(), 1);
5884        assert!(
5885            matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5886                !raw.value.contains("<template shadowrootmode")
5887                    && raw.value.contains("<div")
5888                    && raw.value.contains(">My Component</div>"))
5889        );
5890    }
5891
5892    #[test]
5893    fn shadow_component_emits_style_hook_inside_declarative_root() {
5894        let mut parser = HtmlParser::new();
5895        parser
5896            .component_registry
5897            .register_component(ComponentRegistration::new(
5898                "my-component",
5899                r#"<template shadowrootmode="open"><div>My Component</div></template>"#,
5900                Some("div { color: blue; }"),
5901                true,
5902            ))
5903            .expect("register");
5904
5905        parser
5906            .parse("test.html", "<my-component></my-component>")
5907            .expect("parse");
5908        let records = parser.into_fragment_records();
5909        assert_stream!(
5910            records,
5911            "my-component",
5912            [
5913                raw("<template shadowrootmode=\"open\">"),
5914                structural_matcher("shadow_styles:my-component"),
5915                raw("<div>My Component</div></template>"),
5916            ]
5917        );
5918        assert!(
5919            records["my-component"]
5920                .fragments
5921                .iter()
5922                .all(|fragment| !matches!(
5923                    fragment.fragment.as_ref(),
5924                    Some(Fragment::Raw(raw)) if raw.value.contains("color: blue")
5925                )),
5926            "component CSS is delivered by the handler closure"
5927        );
5928    }
5929
5930    #[test]
5931    fn authored_shadow_attribute_name_is_ascii_case_insensitive_for_style_hook() {
5932        let mut parser = HtmlParser::new();
5933        parser
5934            .component_registry
5935            .register_component(ComponentRegistration::new(
5936                "my-component",
5937                r#"<template shadowRootMode="open"><slot></slot></template>"#,
5938                None,
5939                true,
5940            ))
5941            .expect("register");
5942
5943        parser
5944            .parse("test.html", "<my-component></my-component>")
5945            .expect("parse");
5946        let records = parser.into_fragment_records();
5947        assert_stream!(
5948            records,
5949            "my-component",
5950            [
5951                raw(r#"<template shadowRootMode="open">"#),
5952                structural_matcher("shadow_styles:my-component"),
5953                raw("<slot></slot></template>"),
5954            ]
5955        );
5956    }
5957
5958    #[test]
5959    fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
5960        // The compiler-owned streamed SSR root signal must sit between the
5961        // component's attribute run and the closing `>`, carrying the tag,
5962        // so the streaming handler can inject ` data-ws` inside the tag before
5963        // custom-element upgrade. Ordinary rendering ignores the signal.
5964        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5965        parser
5966            .component_registry
5967            .register_component(ComponentRegistration::new(
5968                "my-widget",
5969                "<div>w</div>",
5970                None,
5971                true,
5972            ))
5973            .expect("register");
5974
5975        let result = parser.parse("test.html", "<my-widget></my-widget>");
5976        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5977        let records = parser.into_fragment_records();
5978
5979        assert_stream!(
5980            records,
5981            "test.html",
5982            [
5983                raw("<my-widget"),
5984                structural_matcher("streaming_root:my-widget"),
5985                raw(">"),
5986                component("my-widget"),
5987                raw("</my-widget>"),
5988            ]
5989        );
5990    }
5991
5992    #[test]
5993    fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
5994        // Each distinct component host emits its own streaming_root signal,
5995        // including nested hosts and hosts repeated across sibling positions.
5996        use webui_protocol::web_ui_fragment::Fragment;
5997        let mut parser = HtmlParser::new();
5998        for name in ["outer-box", "inner-pill"] {
5999            parser
6000                .component_registry
6001                .register_component(ComponentRegistration::new(
6002                    name,
6003                    "<div>content</div>",
6004                    None,
6005                    true,
6006                ))
6007                .expect("register");
6008        }
6009
6010        let result = parser.parse(
6011            "test.html",
6012            "<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
6013        );
6014        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6015        let records = parser.into_fragment_records();
6016        let fragments = &records["test.html"].fragments;
6017
6018        let signals: Vec<&str> = fragments
6019            .iter()
6020            .filter_map(|f| match f.fragment.as_ref() {
6021                Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
6022                _ => None,
6023            })
6024            .collect();
6025        assert_eq!(
6026            signals,
6027            [
6028                "}}}webui:streaming_root:outer-box",
6029                "}}}webui:streaming_root:inner-pill",
6030                "}}}webui:streaming_root:inner-pill",
6031            ]
6032        );
6033
6034        // Every streaming_root signal is immediately followed by a `>` raw and
6035        // preceded by a raw ending in the host tag name (never a bare `<tag>`).
6036        for (i, frag) in fragments.iter().enumerate() {
6037            let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
6038                continue;
6039            };
6040            if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
6041                continue;
6042            }
6043            match fragments[i + 1].fragment.as_ref() {
6044                Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
6045                other => panic!("expected raw(\">\") after signal, got {other:?}"),
6046            }
6047        }
6048    }
6049
6050    #[test]
6051    fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
6052        let mut parser = HtmlParser::new();
6053        parser
6054            .component_registry
6055            .register_component(ComponentRegistration::new(
6056                "mp-button",
6057                "<button>b</button>",
6058                None,
6059                true,
6060            ))
6061            .expect("Failed to register component");
6062
6063        // `<mp-buton>` is a same-namespace one-character typo of `mp-button`.
6064        let err = parser
6065            .parse("test.html", "<mp-buton></mp-buton>")
6066            .expect_err("a near-typo component tag should error");
6067        let ParserError::Template(diag) = err else {
6068            panic!("expected ParserError::Template, got {err:?}");
6069        };
6070        assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
6071        let help = diag
6072            .help_text()
6073            .expect("diagnostic should carry a help line");
6074        assert!(
6075            help.contains("did you mean <mp-button>?"),
6076            "expected a suggestion, got: {help}"
6077        );
6078    }
6079
6080    #[test]
6081    fn external_custom_element_in_other_namespace_passes_through() {
6082        let mut parser = HtmlParser::new();
6083        parser
6084            .component_registry
6085            .register_component(ComponentRegistration::new(
6086                "mp-button",
6087                "<button>b</button>",
6088                None,
6089                true,
6090            ))
6091            .expect("Failed to register component");
6092
6093        // `<md-button>` is a different namespace (md- vs mp-): a genuine
6094        // third-party custom element must pass through as raw HTML, not error.
6095        let result = parser.parse("test.html", "<md-button></md-button>");
6096        assert!(
6097            result.is_ok(),
6098            "external custom element should pass through, got: {:?}",
6099            result.err()
6100        );
6101    }
6102
6103    #[test]
6104    fn for_missing_each_suggests_typoed_attribute() {
6105        let mut parser = HtmlParser::new();
6106        // `eahc` is a transposition of the required `each` attribute.
6107        let err = parser
6108            .parse("test.html", "<for eahc=\"todo in todos\"></for>")
6109            .expect_err("a <for> without `each` should error");
6110        let ParserError::Template(diag) = err else {
6111            panic!("expected ParserError::Template, got {err:?}");
6112        };
6113        assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
6114        let help = diag
6115            .help_text()
6116            .expect("diagnostic should carry a help line");
6117        assert!(
6118            help.contains("found `eahc`") && help.contains("did you mean"),
6119            "expected an attribute-typo suggestion, got: {help}"
6120        );
6121    }
6122
6123    #[test]
6124    fn parent_diagnostic_owner_survives_nested_component_parse() {
6125        // Regression: a component parse recurses through `self.parse(child, …)`,
6126        // which used to leave `current_fragment_id` pointing at the child. A
6127        // later error in the PARENT template must still be attributed to the
6128        // parent (here `index.html`), not the nested component.
6129        let mut parser = HtmlParser::new();
6130        parser
6131            .component_registry
6132            .register_component(ComponentRegistration::new(
6133                "my-card",
6134                "<div>card</div>",
6135                None,
6136                true,
6137            ))
6138            .expect("register");
6139
6140        // A registered component first, then a broken <for> in the same template.
6141        let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
6142        let err = parser
6143            .parse("index.html", html)
6144            .expect_err("the malformed <for> should error");
6145        let ParserError::Template(diag) = err else {
6146            panic!("expected ParserError::Template, got {err:?}");
6147        };
6148        assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
6149        assert_eq!(
6150            diag.component_name(),
6151            Some("index.html"),
6152            "parent diagnostic must be owned by index.html, not the nested component"
6153        );
6154    }
6155
6156    #[test]
6157    fn test_component_directive_with_slots() {
6158        let mut parser = HtmlParser::new();
6159        parser
6160            .component_registry
6161            .register_component(ComponentRegistration::new(
6162                "my-component",
6163                "<div>My Component</div>",
6164                Some("div { color: blue; }"),
6165                true,
6166            ))
6167            .expect("Failed to register component");
6168
6169        let result = parser.parse(
6170            "test.html",
6171            "Hello<my-component><p>World</p></my-component>",
6172        );
6173        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6174        let records = parser.into_fragment_records();
6175        let fragments = &records["test.html"].fragments;
6176
6177        // Entry: raw(Hello<my-component>) + component + raw(<p>World</p></my-component>)
6178        assert!(fragments.len() >= 3);
6179        // First fragment should contain "Hello" and "<my-component" (the opening
6180        // tag is split by the compiler-owned streaming_root signal before `>`).
6181        assert!(
6182            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
6183        );
6184        // Should have component fragment
6185        assert!(fragments.iter().any(|f| matches!(
6186            f.fragment.as_ref(),
6187            Some(Fragment::Component(c)) if c.fragment_id == "my-component"
6188        )));
6189        // Should end with closing tag
6190        let last = fragments.last().unwrap();
6191        assert!(
6192            matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
6193        );
6194    }
6195
6196    #[test]
6197    fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
6198        // Reproduces commerce app: mp-app has a shadow DOM template containing
6199        // child components (mp-navbar, mp-cart-panel, mp-footer) plus an <outlet>.
6200        // The parser must emit Fragment::Component entries for ALL child components
6201        // so the inventory walk finds them.
6202        let mut parser = HtmlParser::new();
6203
6204        parser
6205            .component_registry
6206            .register_component(ComponentRegistration::new(
6207                "app-shell",
6208                r#"<template shadowrootmode="open">
6209                  <my-navbar></my-navbar>
6210                  <main><outlet /></main>
6211                  <cart-panel></cart-panel>
6212                  <my-footer></my-footer>
6213                </template>"#,
6214                Some(":host{display:flex}"),
6215                true,
6216            ))
6217            .expect("register app-shell");
6218        parser
6219            .component_registry
6220            .register_component(ComponentRegistration::new(
6221                "my-navbar",
6222                "<nav>Nav</nav>",
6223                Some("nav{color:red}"),
6224                true,
6225            ))
6226            .expect("register my-navbar");
6227        parser
6228            .component_registry
6229            .register_component(ComponentRegistration::new(
6230                "cart-panel",
6231                "<aside>Cart</aside>",
6232                Some("aside{color:green}"),
6233                true,
6234            ))
6235            .expect("register cart-panel");
6236        parser
6237            .component_registry
6238            .register_component(ComponentRegistration::new(
6239                "my-footer",
6240                "<footer>Footer</footer>",
6241                Some("footer{color:blue}"),
6242                true,
6243            ))
6244            .expect("register my-footer");
6245
6246        parser
6247            .parse("index.html", "<app-shell></app-shell>")
6248            .expect("parse index.html");
6249        let records = parser.into_fragment_records();
6250
6251        // app-shell's fragment list must contain Component entries for all children
6252        let app_frags = &records["app-shell"].fragments;
6253        let component_ids: Vec<&str> = app_frags
6254            .iter()
6255            .filter_map(|f| match f.fragment.as_ref() {
6256                Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
6257                _ => None,
6258            })
6259            .collect();
6260
6261        assert!(
6262            component_ids.contains(&"my-navbar"),
6263            "app-shell fragments should contain my-navbar: {component_ids:?}"
6264        );
6265        assert!(
6266            component_ids.contains(&"cart-panel"),
6267            "app-shell fragments should contain cart-panel: {component_ids:?}"
6268        );
6269        assert!(
6270            component_ids.contains(&"my-footer"),
6271            "app-shell fragments should contain my-footer: {component_ids:?}"
6272        );
6273    }
6274
6275    // ── Component template wrapping tests ────────────────────────────
6276
6277    #[test]
6278    fn test_component_no_double_wrap_template() {
6279        // Developer-authored <template foo="bar"> must be preserved verbatim
6280        // in Light DOM. The framework only strips runtime-only attrs;
6281        // every other attribute is the developer's responsibility.
6282        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6283        parser
6284            .component_registry
6285            .register_component(ComponentRegistration::new(
6286                "custom-element",
6287                r#"<template foo="bar"><div>content</div></template>"#,
6288                None,
6289                true,
6290            ))
6291            .expect("register");
6292        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6293        assert!(result.is_ok());
6294        let records = parser.into_fragment_records();
6295
6296        assert_fragments!(
6297            records["index.html"].fragments,
6298            [
6299                raw("<custom-element"),
6300                structural_matcher("streaming_root:custom-element"),
6301                raw(">"),
6302                component("custom-element"),
6303                raw("Hello</custom-element>"),
6304            ]
6305        );
6306
6307        assert_stream!(
6308            records,
6309            "custom-element",
6310            [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6311        );
6312    }
6313
6314    #[test]
6315    fn test_component_styled_no_double_wrap() {
6316        // Light DOM with a developer-supplied ordinary <template> wrapper
6317        // preserves the wrapper verbatim. CSS is delivered by the owning
6318        // closure rather than spliced into the template.
6319        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6320        parser
6321            .component_registry
6322            .register_component(ComponentRegistration::new(
6323                "custom-element",
6324                r#"<template foo="bar"><div>content</div></template>"#,
6325                Some("div { color: red; }"),
6326                true,
6327            ))
6328            .expect("register");
6329        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6330        assert!(result.is_ok());
6331        let records = parser.into_fragment_records();
6332
6333        assert_stream!(
6334            records,
6335            "custom-element",
6336            [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6337        );
6338    }
6339
6340    #[test]
6341    fn test_component_strip_runtime_attrs() {
6342        // Runtime-only attributes (`@event`, `:bind`, `?cond`) are stripped
6343        // from the opening <template> tag, but the wrapper itself is
6344        // preserved. The declarative Shadow DOM attribute remains.
6345        let mut parser = HtmlParser::new();
6346        parser
6347            .component_registry
6348            .register_component(ComponentRegistration::new(
6349                "custom-element",
6350                r#"<template shadowrootmode="open" @click={foo} :bar="baz" ?bool="true"><div>content</div></template>"#,
6351                None,
6352                true,
6353            ))
6354            .expect("register");
6355        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6356        assert!(result.is_ok());
6357        let records = parser.into_fragment_records();
6358
6359        assert_stream!(
6360            records,
6361            "custom-element",
6362            [
6363                raw(r#"<template shadowrootmode="open">"#),
6364                structural_matcher("shadow_styles:custom-element"),
6365                raw("<div>content</div></template>"),
6366            ]
6367        );
6368    }
6369
6370    #[test]
6371    fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
6372        let mut parser = HtmlParser::new();
6373        parser
6374            .component_registry
6375            .register_component(ComponentRegistration::new(
6376                "custom-element",
6377                r#"<template shadowrootmode="open" data-note="@click={foo}" @click={foo}><div>content</div></template>"#,
6378                None,
6379                true,
6380            ))
6381            .expect("register");
6382        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6383        assert!(result.is_ok());
6384        let records = parser.into_fragment_records();
6385
6386        assert_stream!(
6387            records,
6388            "custom-element",
6389            [
6390                raw(r#"<template shadowrootmode="open" data-note="@click={foo}">"#),
6391                structural_matcher("shadow_styles:custom-element"),
6392                raw("<div>content</div></template>"),
6393            ]
6394        );
6395    }
6396
6397    #[test]
6398    fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
6399        let mut parser = HtmlParser::new();
6400        parser
6401            .component_registry
6402            .register_component(ComponentRegistration::new(
6403                "custom-element",
6404                r#"<template shadowrootmode="open" @click={foo} @click={bar}><div>content</div></template>"#,
6405                None,
6406                true,
6407            ))
6408            .expect("register");
6409        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6410        assert!(result.is_ok());
6411        let records = parser.into_fragment_records();
6412
6413        assert_stream!(
6414            records,
6415            "custom-element",
6416            [
6417                raw(r#"<template shadowrootmode="open">"#),
6418                structural_matcher("shadow_styles:custom-element"),
6419                raw("<div>content</div></template>"),
6420            ]
6421        );
6422    }
6423
6424    #[test]
6425    fn test_component_with_slots_and_attrs() {
6426        let mut parser = HtmlParser::new();
6427        parser
6428            .component_registry
6429            .register_component(ComponentRegistration::new(
6430                "custom-element",
6431                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6432                None,
6433                true,
6434            ))
6435            .expect("register");
6436        let result = parser.parse(
6437            "index.html",
6438            r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
6439        );
6440        assert!(result.is_ok());
6441        let records = parser.into_fragment_records();
6442        assert_fragments!(
6443            records["index.html"].fragments,
6444            [
6445                raw("<custom-element"),
6446                attr_raw_start("appearance", "subtle"),
6447                structural_matcher("streaming_root:custom-element"),
6448                raw(">"),
6449                component("custom-element"),
6450                raw("Hello World</custom-element>"),
6451            ]
6452        );
6453    }
6454
6455    #[test]
6456    fn test_component_legacy_no_styles() {
6457        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6458        parser
6459            .component_registry
6460            .register_component(ComponentRegistration::new(
6461                "custom-element",
6462                "<div>Custom Element</div>",
6463                None,
6464                true,
6465            ))
6466            .expect("register");
6467        let result = parser.parse("index.html", "<custom-element></custom-element>");
6468        assert!(result.is_ok());
6469        let records = parser.into_fragment_records();
6470
6471        assert_fragments!(
6472            records["index.html"].fragments,
6473            [
6474                raw("<custom-element"),
6475                structural_matcher("streaming_root:custom-element"),
6476                raw(">"),
6477                component("custom-element"),
6478                raw("</custom-element>"),
6479            ]
6480        );
6481
6482        assert_stream!(
6483            records,
6484            "custom-element",
6485            [raw("<div>Custom Element</div>"),]
6486        );
6487    }
6488
6489    #[test]
6490    fn test_component_self_closing() {
6491        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6492        parser
6493            .component_registry
6494            .register_component(ComponentRegistration::new(
6495                "custom-widget",
6496                "<div>Widget Content</div>",
6497                None,
6498                true,
6499            ))
6500            .expect("register");
6501        let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
6502        assert!(result.is_ok());
6503        let records = parser.into_fragment_records();
6504
6505        assert_fragments!(
6506            records["index.html"].fragments,
6507            [
6508                raw("<custom-widget"),
6509                attr_start("config", "settings"),
6510                structural_matcher("streaming_root:custom-widget"),
6511                raw("/>"),
6512                component("custom-widget"),
6513            ]
6514        );
6515    }
6516
6517    #[test]
6518    fn test_component_nested_self_closing_in_slot() {
6519        let mut parser = HtmlParser::new();
6520        parser
6521            .component_registry
6522            .register_component(ComponentRegistration::new(
6523                "custom-icon",
6524                "<template shadowrootmode=\"open\"><svg><slot></slot></svg></template>",
6525                None,
6526                true,
6527            ))
6528            .expect("register");
6529        let result = parser.parse(
6530            "index.html",
6531            r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
6532        );
6533        assert!(result.is_ok());
6534        let records = parser.into_fragment_records();
6535
6536        assert_fragments!(
6537            records["index.html"].fragments,
6538            [
6539                raw("<custom-icon"),
6540                structural_matcher("streaming_root:custom-icon"),
6541                raw(">"),
6542                component("custom-icon"),
6543                raw("<use"),
6544                attr_template("href", "attr-1"),
6545                raw("/></custom-icon>"),
6546            ]
6547        );
6548
6549        assert_stream!(
6550            records,
6551            "custom-icon",
6552            [
6553                raw("<template shadowrootmode=\"open\">"),
6554                structural_matcher("shadow_styles:custom-icon"),
6555                raw("<svg><slot></slot></svg></template>"),
6556            ]
6557        );
6558    }
6559
6560    #[test]
6561    fn test_component_leading_boolean_attr_start() {
6562        let mut parser = HtmlParser::new();
6563        parser
6564            .component_registry
6565            .register_component(ComponentRegistration::new(
6566                "custom-element",
6567                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6568                None,
6569                true,
6570            ))
6571            .expect("register");
6572        let result = parser.parse(
6573            "index.html",
6574            r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
6575        );
6576        assert!(result.is_ok());
6577        let records = parser.into_fragment_records();
6578
6579        assert_fragments!(
6580            records["index.html"].fragments,
6581            [
6582                raw("<custom-element"),
6583                // First dynamic attr: boolean with attrStart
6584                bool_attr_start("disabled", "isDisabled"),
6585                // Static attr after dynamic: rawValue
6586                attr_raw("title", "Hello"),
6587                structural_matcher("streaming_root:custom-element"),
6588                raw(">"),
6589                component("custom-element"),
6590                raw("</custom-element>"),
6591            ]
6592        );
6593    }
6594
6595    #[test]
6596    fn test_component_boolean_predicate_preserves_condition_tree() {
6597        let mut parser = HtmlParser::new();
6598        parser
6599            .component_registry
6600            .register_component(ComponentRegistration::new(
6601                "custom-element",
6602                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6603                None,
6604                true,
6605            ))
6606            .expect("register");
6607        let result = parser.parse(
6608            "index.html",
6609            r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
6610        );
6611        assert!(result.is_ok());
6612
6613        let records = parser.into_fragment_records();
6614        let fragments = &records["index.html"].fragments;
6615        match fragments
6616            .get(1)
6617            .and_then(|fragment| fragment.fragment.as_ref())
6618        {
6619            Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
6620                assert_eq!(attr.name, "disabled");
6621                assert!(attr.attr_start);
6622                match attr
6623                    .condition_tree
6624                    .as_ref()
6625                    .and_then(|condition| condition.expr.as_ref())
6626                {
6627                    Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
6628                        assert_eq!(pred.left, "page");
6629                        assert_eq!(pred.operator, 3);
6630                        assert_eq!(pred.right, "'dashboard'");
6631                    }
6632                    other => panic!("expected predicate condition tree, got {:?}", other),
6633                }
6634            }
6635            other => panic!("expected attribute fragment, got {:?}", other),
6636        }
6637    }
6638
6639    #[test]
6640    fn test_component_meta_link_tags() {
6641        let (fragments, _) = parse_and_get_fragments(
6642            r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
6643        );
6644        assert!(fragments.len() >= 7);
6645        assert!(
6646            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
6647        );
6648        assert!(
6649            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
6650                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
6651        );
6652        assert!(
6653            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
6654        );
6655        assert!(
6656            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
6657        );
6658        assert!(
6659            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
6660        );
6661        assert!(
6662            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
6663                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
6664        );
6665        assert!(
6666            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
6667        );
6668    }
6669
6670    #[test]
6671    fn test_nested_directives() {
6672        let mut parser = HtmlParser::new();
6673        let html = r#"<for each="category in categories">
6674            <if condition="category.hasItems">
6675                <for each="item in category.items">
6676                   {{item.title}}
6677                </for>
6678            </if>
6679        </for>"#;
6680
6681        let result = parser.parse("test.html", html);
6682
6683        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6684        let fragment_records = parser.into_fragment_records();
6685
6686        assert_stream!(
6687            fragment_records,
6688            "test.html",
6689            [for_loop("category", "categories", "for-1"),]
6690        );
6691
6692        assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
6693
6694        assert_stream!(
6695            fragment_records,
6696            "if-1",
6697            [for_loop("item", "category.items", "for-2"),]
6698        );
6699
6700        assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
6701    }
6702
6703    #[test]
6704    fn test_complex_directives() {
6705        let mut parser = HtmlParser::new();
6706        let html = r#"<for each="category in categories">
6707            <div class="category">
6708                <h2>{{category.name}}</h2>
6709                <if condition="category.hasItems">
6710                    <ul>
6711                        <for each="item in category.items">
6712                            <li>{{item.title}}</li>
6713                        </for>
6714                    </ul>
6715                </if>
6716            </div>
6717        </for>"#;
6718
6719        let result = parser.parse("test.html", html);
6720
6721        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6722        let fragment_records = parser.into_fragment_records();
6723
6724        assert_stream!(
6725            fragment_records,
6726            "test.html",
6727            [for_loop("category", "categories", "for-1"),]
6728        );
6729
6730        // Verify for fragments contains the category.name signal
6731        assert_stream!(
6732            fragment_records,
6733            "for-1",
6734            [
6735                raw("<div class=\"category\"><h2>"),
6736                signal("category.name"),
6737                raw("</h2>"),
6738                if_cond("if-1"),
6739                raw("</div>"),
6740            ]
6741        );
6742
6743        // Verify nested if condition.
6744        assert_stream!(
6745            fragment_records,
6746            "if-1",
6747            [
6748                raw("<ul>"),
6749                for_loop("item", "category.items", "for-2"),
6750                raw("</ul>"),
6751            ]
6752        );
6753
6754        // Verify nested for each.
6755        assert_stream!(
6756            fragment_records,
6757            "for-2",
6758            [raw("<li>"), signal("item.title"), raw("</li>"),]
6759        );
6760    }
6761
6762    // ── Attribute fragment tests ─────────────────────────────────────────
6763
6764    /// Helper to parse HTML and return the fragments for the entry stream.
6765    fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6766        let mut parser = HtmlParser::new();
6767        let result = parser.parse("index.html", html);
6768        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6769        let records = parser.into_fragment_records();
6770        let fragments = records
6771            .get("index.html")
6772            .expect("Failed to get index.html fragment")
6773            .fragments
6774            .clone();
6775        (fragments, records)
6776    }
6777
6778    /// Helper to parse HTML with a pre-registered component.
6779    fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6780        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6781        parser
6782            .component_registry
6783            .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
6784            .expect("register");
6785        let result = parser.parse("index.html", html);
6786        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6787        let records = parser.into_fragment_records();
6788        let fragments = records
6789            .get("index.html")
6790            .expect("Failed to get index.html fragment")
6791            .fragments
6792            .clone();
6793        (fragments, records)
6794    }
6795
6796    #[test]
6797    fn test_attribute_handlebars_in_href() {
6798        // Port of: 'should process handlebars from attributes as signals'
6799        let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
6800        assert_fragments!(
6801            fragments,
6802            [
6803                raw("<a"),
6804                attr("href", "url"),
6805                raw(">"),
6806                signal("name"),
6807                raw("</a>"),
6808            ]
6809        );
6810    }
6811
6812    #[test]
6813    fn test_attribute_boolean_with_handlebars() {
6814        // Port of: 'should process boolean attribute with handlebars expression'
6815        let (fragments, _) =
6816            parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
6817        assert_fragments!(
6818            fragments,
6819            [
6820                raw("<button"),
6821                bool_attr("disabled", "isDisabled"),
6822                raw(">Click</button>"),
6823            ]
6824        );
6825    }
6826
6827    #[test]
6828    fn test_attribute_multiple_boolean() {
6829        // Port of: 'should process multiple boolean attributes'
6830        // <input ?checked={{isChecked}} ?disabled={{isDisabled}} />
6831        let (fragments, _) =
6832            parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
6833
6834        assert_fragments!(
6835            fragments,
6836            [
6837                raw("<input"),
6838                bool_attr("checked", "isChecked"),
6839                bool_attr("disabled", "isDisabled"),
6840                raw("/>"),
6841            ]
6842        );
6843    }
6844
6845    #[test]
6846    fn test_attribute_boolean_and_regular_together() {
6847        // Port of: 'should process a boolean attribute and a regular attribute together'
6848        // <button ?disabled="{{isDisabled}}" type="button">Hi</button>
6849        let (fragments, _) = parse_and_get_fragments(
6850            r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6851        );
6852
6853        assert_fragments!(
6854            fragments,
6855            [
6856                raw("<button"),
6857                bool_attr("disabled", "isDisabled"),
6858                raw(" type=\"button\">Hi</button>"),
6859            ]
6860        );
6861    }
6862
6863    #[test]
6864    fn test_attribute_boolean_sandwiched() {
6865        // Port of: 'should process a boolean attribute sandwiched between regular attributes'
6866        // <button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>
6867        let (fragments, _) = parse_and_get_fragments(
6868            r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6869        );
6870
6871        assert_fragments!(
6872            fragments,
6873            [
6874                raw("<button"),
6875                attr("version", "edition"),
6876                bool_attr("disabled", "isDisabled"),
6877                raw(" type=\"button\">Hi</button>"),
6878            ]
6879        );
6880    }
6881
6882    #[test]
6883    fn test_attribute_boolean_ending() {
6884        // Port of: 'should process html ending with boolean attribute correctly'
6885        // <button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>
6886        let (fragments, _) = parse_and_get_fragments(
6887            r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
6888        );
6889
6890        assert_fragments!(
6891            fragments,
6892            [
6893                raw("<button"),
6894                attr("version", "edition"),
6895                bool_attr("disabled", "isDisabled"),
6896                raw(">Hi</button>"),
6897            ]
6898        );
6899    }
6900
6901    #[test]
6902    fn test_attribute_boolean_dotted_path() {
6903        // Port of: 'should process boolean attribute with dotted path'
6904        // <div ?checked={{layout.isPinned}}>Content</div>
6905        let (fragments, _) =
6906            parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
6907
6908        assert_fragments!(
6909            fragments,
6910            [
6911                raw("<div"),
6912                bool_attr("checked", "layout.isPinned"),
6913                raw(">Content</div>"),
6914            ]
6915        );
6916    }
6917
6918    #[test]
6919    fn test_attribute_colon_prefixed_complex() {
6920        // Port of: 'should process colon-prefixed attribute with handlebars'
6921        // <my-component :config="{{settings}}"></my-component>
6922        let (fragments, _) = parse_with_component(
6923            "my-component",
6924            r#"<my-component :config="{{settings}}"></my-component>"#,
6925        );
6926
6927        assert_fragments!(
6928            fragments,
6929            [
6930                raw("<my-component"),
6931                attr_complex_start(":config", "settings"),
6932                structural_matcher("streaming_root:my-component"),
6933                raw(">"),
6934                component("my-component"),
6935                raw("</my-component>"),
6936            ]
6937        );
6938    }
6939
6940    #[test]
6941    fn test_attribute_multiple_colon_prefixed() {
6942        // Port of: 'should process multiple colon-prefixed complex attributes'
6943        // <my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>
6944        let (fragments, _) = parse_with_component(
6945            "my-component",
6946            r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
6947        );
6948
6949        assert_fragments!(
6950            fragments,
6951            [
6952                raw("<my-component"),
6953                attr_complex_start(":prop1", "val1"),
6954                attr_complex(":prop2", "val2"),
6955                structural_matcher("streaming_root:my-component"),
6956                raw(">"),
6957                component("my-component"),
6958                raw("</my-component>"),
6959            ]
6960        );
6961    }
6962
6963    #[test]
6964    fn test_blocked_complex_property_innerhtml() {
6965        let mut parser = HtmlParser::new();
6966        let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
6967        assert!(
6968            result.is_err(),
6969            "Expected error for :innerHTML on native element"
6970        );
6971        let err = result.unwrap_err().to_string();
6972        assert!(
6973            err.contains("only allowed on custom elements"),
6974            "Error: {err}"
6975        );
6976    }
6977
6978    #[test]
6979    fn test_blocked_complex_on_native_element() {
6980        let mut parser = HtmlParser::new();
6981        let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
6982        assert!(
6983            result.is_err(),
6984            "Expected error for :data on native element"
6985        );
6986        let err = result.unwrap_err().to_string();
6987        assert!(
6988            err.contains("only allowed on custom elements"),
6989            "Error: {err}"
6990        );
6991    }
6992
6993    #[test]
6994    fn test_blocked_complex_property_on_component() {
6995        let mut parser = HtmlParser::new();
6996        parser
6997            .component_registry
6998            .register_component(ComponentRegistration::new(
6999                "my-widget",
7000                "<div></div>",
7001                None,
7002                true,
7003            ))
7004            .expect("register");
7005        let result = parser.parse(
7006            "index.html",
7007            r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
7008        );
7009        assert!(
7010            result.is_err(),
7011            "Expected error for :innerHTML on component"
7012        );
7013        let err = result.unwrap_err().to_string();
7014        assert!(err.contains("HTML injection"), "Error: {err}");
7015    }
7016
7017    #[test]
7018    fn test_allowed_complex_property() {
7019        // :config on a component should still work
7020        let (fragments, _) = parse_with_component(
7021            "my-component",
7022            r#"<my-component :config="{{settings}}"></my-component>"#,
7023        );
7024        assert_fragments!(
7025            fragments,
7026            [
7027                raw("<my-component"),
7028                attr_complex_start(":config", "settings"),
7029                structural_matcher("streaming_root:my-component"),
7030                raw(">"),
7031                component("my-component"),
7032                raw("</my-component>"),
7033            ]
7034        );
7035    }
7036
7037    #[test]
7038    fn test_attribute_mixed_normal_boolean_colon() {
7039        // Port of: 'should process mixed normal, boolean, and colon-prefixed attributes'
7040        // <my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>
7041        let (fragments, _) = parse_with_component(
7042            "my-component",
7043            r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
7044        );
7045
7046        assert_fragments!(
7047            fragments,
7048            [
7049                raw("<my-component"),
7050                attr_raw_start("id", "comp"),
7051                attr_complex(":config", "settings"),
7052                bool_attr("enabled", "isEnabled"),
7053                structural_matcher("streaming_root:my-component"),
7054                raw(">"),
7055                component("my-component"),
7056                raw("</my-component>"),
7057            ]
7058        );
7059    }
7060
7061    #[test]
7062    fn test_attribute_reject_boolean_without_handlebars() {
7063        // Port of: 'should reject boolean attribute without handlebars'
7064        // <button ?checked="name"></button>
7065        let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
7066
7067        // Boolean attribute is silently dropped
7068        assert_fragments!(fragments, [raw("<button></button>"),]);
7069    }
7070
7071    #[test]
7072    fn test_attribute_reject_boolean_with_partial_handlebars() {
7073        // Port of: 'should reject boolean attribute with partial handlebars'
7074        // <button ?checked="Hello {{name}}"></button>
7075        let (fragments, _) =
7076            parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
7077
7078        // Boolean attribute is silently dropped
7079        assert_fragments!(fragments, [raw("<button></button>"),]);
7080    }
7081
7082    #[test]
7083    fn test_attribute_reject_boolean_with_plain_value() {
7084        // Port of: 'should reject boolean attribute with plain value'
7085        // <button ?disabled="true">Click</button>
7086        let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
7087
7088        // Boolean attribute is silently dropped
7089        assert_fragments!(fragments, [raw("<button>Click</button>"),]);
7090    }
7091
7092    #[test]
7093    fn test_attribute_boolean_predicate_equal() {
7094        // Boolean attribute with == predicate expression
7095        let (fragments, _) =
7096            parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
7097        assert_fragments!(
7098            fragments,
7099            [
7100                raw("<div"),
7101                bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
7102                raw(">X</div>"),
7103            ]
7104        );
7105    }
7106
7107    #[test]
7108    fn test_attribute_boolean_predicate_greater_than() {
7109        // Boolean attribute with > predicate expression
7110        let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
7111        assert_fragments!(
7112            fragments,
7113            [
7114                raw("<span"),
7115                bool_attr_predicate("hidden", "num", 1, "9"),
7116                raw(">X</span>"),
7117            ]
7118        );
7119    }
7120
7121    #[test]
7122    fn test_attribute_boolean_predicate_not_equal() {
7123        // Boolean attribute with != predicate expression
7124        let (fragments, _) =
7125            parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
7126        assert_fragments!(
7127            fragments,
7128            [
7129                raw("<a"),
7130                bool_attr_predicate("data-active", "status", 4, "'inactive'"),
7131                raw(">X</a>"),
7132            ]
7133        );
7134    }
7135
7136    #[test]
7137    fn test_attribute_boolean_negation() {
7138        // Boolean attribute with negated expression
7139        let (fragments, _) =
7140            parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
7141        assert_fragments!(
7142            fragments,
7143            [
7144                raw("<button"),
7145                bool_attr_not("disabled", "isReady"),
7146                raw(">X</button>"),
7147            ]
7148        );
7149    }
7150
7151    #[test]
7152    fn test_attribute_mixed_static_dynamic() {
7153        // Port of: 'should process mixed attributes correctly'
7154        // <textarea value="hello {{world}}">Hi</textarea>
7155        let (fragments, records) =
7156            parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
7157
7158        assert_fragments!(
7159            fragments,
7160            [
7161                raw("<textarea"),
7162                attr_template("value", "attr-1"),
7163                raw(">Hi</textarea>"),
7164            ]
7165        );
7166
7167        // Verify the template sub-stream
7168        assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
7169    }
7170
7171    // ── Body signal tests ─────────────────────────────────────────────
7172
7173    #[test]
7174    fn test_body_signals() {
7175        let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
7176        assert_fragments!(
7177            fragments,
7178            [
7179                raw("<body>"),
7180                structural_matcher("body_start"),
7181                raw("<app-shell></app-shell>"),
7182                structural_matcher("body_end"),
7183                raw("</body>"),
7184            ]
7185        );
7186    }
7187
7188    // ── Empty for handling tests ──────────────────────────────────────
7189
7190    #[test]
7191    fn test_empty_for_produces_nothing() {
7192        let (fragments, records) =
7193            parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
7194        assert_fragments!(fragments, [raw("<div></div>"),]);
7195        assert!(!records.contains_key("for-1"));
7196    }
7197
7198    // ── Self-closing / void element tests ─────────────────────────────
7199
7200    #[test]
7201    fn test_self_closing_svg_path() {
7202        let (fragments, _) =
7203            parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
7204        assert_fragments!(
7205            fragments,
7206            [raw(
7207                r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
7208            ),]
7209        );
7210    }
7211
7212    #[test]
7213    fn test_html5_void_elements() {
7214        let (fragments, _) = parse_and_get_fragments(
7215            r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
7216        );
7217        assert_fragments!(
7218            fragments,
7219            [raw(
7220                r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
7221            ),]
7222        );
7223    }
7224
7225    #[test]
7226    fn test_self_closing_with_dynamic_attributes() {
7227        let (fragments, _) =
7228            parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
7229        assert_fragments!(
7230            fragments,
7231            [
7232                raw("<img"),
7233                attr("src", "imageUrl"),
7234                attr("alt", "imageAlt"),
7235                raw("/>"),
7236            ]
7237        );
7238    }
7239
7240    #[test]
7241    fn test_self_closing_with_boolean_attributes() {
7242        let (fragments, _) = parse_and_get_fragments(
7243            r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
7244        );
7245        assert_fragments!(
7246            fragments,
7247            [
7248                raw("<input type=\"checkbox\""),
7249                bool_attr("checked", "isSelected"),
7250                bool_attr("disabled", "isDisabled"),
7251                raw("/>"),
7252            ]
7253        );
7254    }
7255
7256    #[test]
7257    fn test_multiple_self_closing_in_sequence() {
7258        let (fragments, _) =
7259            parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
7260        assert_fragments!(
7261            fragments,
7262            [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
7263        );
7264    }
7265
7266    #[test]
7267    fn test_self_closing_with_mixed_content() {
7268        let (fragments, _) =
7269            parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
7270        assert_fragments!(
7271            fragments,
7272            [
7273                raw("<div>Text before<img"),
7274                attr("src", "url"),
7275                raw("/>Text after</div>"),
7276            ]
7277        );
7278    }
7279
7280    #[test]
7281    fn test_self_closing_svg_elements() {
7282        let (fragments, _) = parse_and_get_fragments(
7283            r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
7284        );
7285        assert_fragments!(
7286            fragments,
7287            [
7288                raw("<svg><circle"),
7289                attr("cx", "x"),
7290                attr("cy", "y"),
7291                raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
7292            ]
7293        );
7294    }
7295
7296    #[test]
7297    fn test_self_closing_inside_for_loop() {
7298        let (fragments, records) = parse_and_get_fragments(
7299            r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
7300        );
7301        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7302        assert_stream!(
7303            records,
7304            "for-1",
7305            [raw("<img"), attr("src", "item.url"), raw("/>"),]
7306        );
7307    }
7308
7309    #[test]
7310    fn test_self_closing_whitespace_variations() {
7311        let (fragments, _) =
7312            parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
7313        assert_fragments!(
7314            fragments,
7315            [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
7316        );
7317    }
7318
7319    #[test]
7320    fn test_deeply_nested_self_closing() {
7321        let (fragments, _) = parse_and_get_fragments(
7322            r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
7323        );
7324        assert_fragments!(
7325            fragments,
7326            [raw(
7327                r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
7328            ),]
7329        );
7330    }
7331
7332    #[test]
7333    fn test_self_closing_vs_empty_regular_tags() {
7334        let (fragments, _) =
7335            parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
7336        assert_fragments!(
7337            fragments,
7338            [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
7339        );
7340    }
7341
7342    // ── Feature 1: Named <for> bodies ─────────────────────────────────
7343
7344    #[test]
7345    fn test_for_custom_id_attribute() {
7346        let (fragments, records) = parse_and_get_fragments(
7347            r#"<for each="item in items" id="static"><span>Item</span></for>"#,
7348        );
7349        assert_fragments!(fragments, [for_loop("item", "items", "static-1"),]);
7350        assert_stream!(records, "static-1", [raw("<span>Item</span>"),]);
7351    }
7352
7353    #[test]
7354    fn test_for_recursive_template() {
7355        let mut parser = HtmlParser::new();
7356        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>"#;
7357        let result = parser.parse("index.html", html);
7358        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7359        let records = parser.into_fragment_records();
7360
7361        assert_fragments!(
7362            records["index.html"].fragments,
7363            [for_loop("item", "items", "static-1"),]
7364        );
7365
7366        assert_stream!(
7367            records,
7368            "static-1",
7369            [
7370                raw("<div><span>"),
7371                signal("item.name"),
7372                raw("</span>"),
7373                for_loop("item", "item.children", "static-1"),
7374                raw("</div>"),
7375            ]
7376        );
7377    }
7378
7379    #[test]
7380    fn each_accepts_only_direct_item_in_collection_syntax() {
7381        assert_eq!(
7382            parse_for_each("test-tree", "child in items").unwrap(),
7383            ("child", "items")
7384        );
7385        assert_eq!(
7386            parse_for_each("test-tree", " \tchild \n in \r child.children ").unwrap(),
7387            ("child", "child.children")
7388        );
7389    }
7390
7391    #[test]
7392    fn braced_each_is_rejected_by_server_and_client_compilation() {
7393        use crate::plugin::webui::generate_compiled_template;
7394
7395        for source in [
7396            r#"<for each="{{child in items}}"><span>{{child.name}}</span></for>"#,
7397            r#"<for id="tree" each="{{child in items}}"><span>{{child.name}}</span></for>"#,
7398            r#"<for id="tree" each="child in items"><for id="tree" each="{{child in child.children}}" /></for>"#,
7399            r#"<for id="tree" each="{{ child in items }}"><span>{{child.name}}</span></for>"#,
7400            r#"<for id="tree" each="{child in items}"><span>{{child.name}}</span></for>"#,
7401        ] {
7402            let server = HtmlParser::new().parse("test-tree", source).unwrap_err();
7403            let client = generate_compiled_template("test-tree", source).unwrap_err();
7404            let ParserError::Template(server) = server else {
7405                panic!("expected a server authoring diagnostic");
7406            };
7407            let ParserError::Template(client) = client else {
7408                panic!("expected a client authoring diagnostic");
7409            };
7410            assert_eq!(server.error_code(), client.error_code(), "{source}");
7411            assert!(matches!(
7412                server.error_code(),
7413                Some(codes::INVALID_FOR_EACH | codes::INVALID_FOR_IDENTIFIER)
7414            ));
7415            assert!(server.position_line_column().is_some());
7416            assert!(server.help_text().unwrap().contains("without braces"));
7417            assert!(client.help_text().unwrap().contains("without braces"));
7418        }
7419    }
7420
7421    #[test]
7422    fn named_for_recursion_reuses_one_scoped_record() {
7423        let mut parser = HtmlParser::new();
7424        parser
7425            .parse(
7426                "file-a.html",
7427                r#"<for id="tree-item" each="child in items"><li>{{child.name}}</li><for id="tree-item" each="child in child.children" /></for>"#,
7428            )
7429            .unwrap();
7430        let records = parser.into_fragment_records();
7431        assert_eq!(records.len(), 2);
7432        assert_stream!(
7433            records,
7434            "file-a.html",
7435            [for_loop("child", "items", "tree-item-1")]
7436        );
7437        assert_stream!(
7438            records,
7439            "tree-item-1",
7440            [
7441                raw("<li>"),
7442                signal("child.name"),
7443                raw("</li>"),
7444                for_loop("child", "child.children", "tree-item-1"),
7445            ]
7446        );
7447    }
7448
7449    #[test]
7450    fn named_for_forward_references_and_empty_definitions() {
7451        let mut parser = HtmlParser::new();
7452        parser
7453            .parse(
7454                "file-a.html",
7455                r#"<for id="loop" each="item in earlier" /><for id="loop" each="item in later"></for>"#,
7456            )
7457            .unwrap();
7458        let records = parser.into_fragment_records();
7459        assert_stream!(
7460            records,
7461            "file-a.html",
7462            [
7463                for_loop("item", "earlier", "loop-1"),
7464                for_loop("item", "later", "loop-1")
7465            ]
7466        );
7467        assert!(records["loop-1"].fragments.is_empty());
7468    }
7469
7470    #[test]
7471    fn named_for_scope_survives_component_parsing_and_reparsing() {
7472        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7473        parser
7474            .component_registry_mut()
7475            .register_component(ComponentRegistration::new(
7476                "other-tree",
7477                r#"<for id="loop" each="item in other"><b>{{item.label}}</b></for>"#,
7478                None,
7479                false,
7480            ))
7481            .unwrap();
7482        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>"#;
7483        parser.parse("file-a.html", source).unwrap();
7484        parser
7485            .parse(
7486                "file-b.html",
7487                r#"<for id="loop" each="item in items">B</for>"#,
7488            )
7489            .unwrap();
7490        parser.parse("file-a.html", source).unwrap();
7491        let records = parser.into_fragment_records();
7492        assert_stream!(
7493            records,
7494            "file-a.html",
7495            [for_loop("item", "items", "loop-1")]
7496        );
7497        assert_stream!(records, "other-tree", [for_loop("item", "other", "loop-2")]);
7498        assert_stream!(
7499            records,
7500            "file-b.html",
7501            [for_loop("item", "items", "loop-3")]
7502        );
7503        assert_stream!(records, "loop-3", [raw("B")]);
7504        assert!(records["loop-1"].fragments.iter().any(|fragment| {
7505            matches!(fragment.fragment.as_ref(), Some(Fragment::ForLoop(repeat)) if repeat.fragment_id == "loop-1")
7506        }));
7507    }
7508
7509    #[test]
7510    fn named_for_reports_authoring_errors() {
7511        for (source, code) in [
7512            (r#"<for id each="x in xs" />"#, codes::INVALID_FOR_ID),
7513            (r#"<for id="" each="x in xs" />"#, codes::INVALID_FOR_ID),
7514            (
7515                r#"<for id="{{name}}" each="x in xs" />"#,
7516                codes::INVALID_FOR_ID,
7517            ),
7518            (
7519                r#"<for id="tree" template="tree" each="x in xs" />"#,
7520                codes::INVALID_FOR_ID,
7521            ),
7522            (
7523                r#"<for id="tree" id="tree" each="x in xs" />"#,
7524                codes::INVALID_FOR_ID,
7525            ),
7526            (r#"<for id="tree" each="x in xs" />"#, codes::UNKNOWN_FOR_ID),
7527            (
7528                r#"<for id="tree" each="x in xs">a</for><for id="tree" each="x in ys">b</for>"#,
7529                codes::DUPLICATE_FOR_ID,
7530            ),
7531            (
7532                r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children"><b>{{y.name}}</b></for></for>"#,
7533                codes::DUPLICATE_FOR_ID,
7534            ),
7535            (
7536                r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children" /></for>"#,
7537                codes::INCOMPATIBLE_FOR_ITEM,
7538            ),
7539        ] {
7540            let error = HtmlParser::new().parse("file-a.html", source).unwrap_err();
7541            let ParserError::Template(diagnostic) = error else {
7542                panic!("expected structured diagnostic for {source}");
7543            };
7544            assert_eq!(diagnostic.error_code(), Some(code), "{source}");
7545            assert!(!diagnostic.to_string().contains('\u{1b}'));
7546            assert!(diagnostic.to_string().contains("help:"));
7547            assert!(diagnostic.to_string().contains("file-a.html"));
7548            assert!(diagnostic.position_line_column().is_some(), "{source}");
7549        }
7550    }
7551
7552    #[test]
7553    fn named_for_cannot_reference_a_definition_from_another_file() {
7554        let mut parser = HtmlParser::new();
7555        parser
7556            .parse("a.html", r#"<for id="tree" each="x in xs">a</for>"#)
7557            .unwrap();
7558        let error = parser
7559            .parse("b.html", r#"<for id="tree" each="x in xs" />"#)
7560            .unwrap_err();
7561        assert!(
7562            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::UNKNOWN_FOR_ID))
7563        );
7564    }
7565
7566    #[test]
7567    fn named_for_does_not_overwrite_generated_fragments() {
7568        let mut parser = HtmlParser::new();
7569        parser
7570            .parse(
7571                "a.html",
7572                r#"<for id="for" each="x in xs">named</for><for each="x in xs">ordinary</for>"#,
7573            )
7574            .unwrap();
7575        let records = parser.into_fragment_records();
7576        assert_stream!(records, "for-1", [raw("named")]);
7577        assert_stream!(records, "for-2", [raw("ordinary")]);
7578        let error = HtmlParser::new()
7579            .parse(
7580                "a.html",
7581                r#"<for each="x in xs">ordinary</for><for id="for" each="x in xs">named</for>"#,
7582            )
7583            .unwrap_err();
7584        assert!(
7585            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::INVALID_FOR_ID))
7586        );
7587    }
7588
7589    #[test]
7590    fn named_for_css_analysis_terminates_without_losing_sibling_scopes() {
7591        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7592        for (name, source, css) in [
7593            ("token-leaf", "<b>leaf</b>", "b { color: var(--accent); }"),
7594            (
7595                "defined-tree",
7596                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
7597                ":root { --accent: red; }",
7598            ),
7599            (
7600                "undefined-tree",
7601                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
7602                "",
7603            ),
7604        ] {
7605            parser
7606                .component_registry_mut()
7607                .register_component(ComponentRegistration::new(name, source, Some(css), false))
7608                .unwrap();
7609        }
7610        parser
7611            .parse(
7612                "index.html",
7613                "<defined-tree></defined-tree><undefined-tree></undefined-tree>",
7614            )
7615            .unwrap();
7616        let analysis = parser.token_analysis();
7617        assert_eq!(analysis.protocol_tokens, ["accent"]);
7618        assert_eq!(analysis.fallback_chains.len(), 1);
7619    }
7620
7621    #[test]
7622    fn named_for_css_analysis_deduplicates_equivalent_diamond_paths() {
7623        use std::fmt::Write;
7624
7625        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7626        parser
7627            .component_registry_mut()
7628            .register_component(ComponentRegistration::new(
7629                "token-leaf",
7630                "<b>leaf</b>",
7631                Some("b { color: var(--accent); }"),
7632                false,
7633            ))
7634            .unwrap();
7635        let mut source = String::with_capacity(12 * 150);
7636        for index in 0..12 {
7637            write!(source, r#"<for id="b{index}" each="item in items">"#).unwrap();
7638            if index < 11 {
7639                let next = index + 1;
7640                write!(source, r#"<for id="b{next}" each="item in item.children"/><for id="b{next}" each="item in item.children"/>"#).unwrap();
7641            } else {
7642                source.push_str("<token-leaf></token-leaf>");
7643            }
7644            source.push_str("</for>");
7645        }
7646        parser.parse("index.html", &source).unwrap();
7647        let analysis = parser.token_analysis();
7648        assert_eq!(analysis.protocol_tokens, ["accent"]);
7649        assert_eq!(analysis.fallback_chains.len(), 1);
7650    }
7651
7652    #[test]
7653    fn named_for_css_analysis_preserves_distinct_same_size_definition_sets() {
7654        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7655        for (name, source, css) in [
7656            (
7657                "token-leaf",
7658                "<b>leaf</b>",
7659                "b { color: var(--accent); background: var(--shade); }",
7660            ),
7661            (
7662                "shared-tree",
7663                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children"/></for>"#,
7664                "",
7665            ),
7666            (
7667                "accent-tree",
7668                "<shared-tree></shared-tree>",
7669                ":root { --accent: red; }",
7670            ),
7671            (
7672                "shade-tree",
7673                "<shared-tree></shared-tree>",
7674                ":root { --shade: blue; }",
7675            ),
7676        ] {
7677            parser
7678                .component_registry_mut()
7679                .register_component(ComponentRegistration::new(name, source, Some(css), false))
7680                .unwrap();
7681        }
7682        parser
7683            .parse(
7684                "index.html",
7685                "<accent-tree></accent-tree><shade-tree></shade-tree>",
7686            )
7687            .unwrap();
7688        assert_eq!(parser.token_analysis().protocol_tokens, ["accent", "shade"]);
7689    }
7690
7691    #[test]
7692    fn legacy_for_template_attribute_is_rejected() {
7693        for source in [
7694            r#"<for template="legacy" each="x in xs"><span>{{x.name}}</span></for>"#,
7695            r#"<for template="legacy" each="x in xs" />"#,
7696            r#"<for template each="x in xs"></for>"#,
7697            r#"<for template="" each="x in xs"></for>"#,
7698        ] {
7699            let error = HtmlParser::new().parse("index.html", source).unwrap_err();
7700            let ParserError::Template(diagnostic) = error else {
7701                panic!("expected an authoring diagnostic for {source}");
7702            };
7703            assert_eq!(diagnostic.error_code(), Some(codes::INVALID_FOR_ID));
7704            assert!(diagnostic
7705                .help_text()
7706                .unwrap()
7707                .contains("replace template with id"));
7708            assert!(diagnostic.position_line_column().is_some());
7709        }
7710    }
7711
7712    #[test]
7713    fn named_for_recursion_still_rejects_reachable_boundaries() {
7714        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();
7715        assert!(
7716            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::BOUNDARY_IN_REPEAT))
7717        );
7718    }
7719
7720    #[test]
7721    fn named_for_fast_components_reject_references_but_keep_plain_loops() {
7722        use crate::plugin::{
7723            fast_v2::FastV2ParserPlugin, fast_v3::FastV3ParserPlugin, ParserPlugin,
7724            ParserPluginArtifacts,
7725        };
7726
7727        for fast_v2 in [true, false] {
7728            for references in [true, false] {
7729                let plugin: Box<dyn ParserPlugin> = if fast_v2 {
7730                    Box::new(FastV2ParserPlugin::new())
7731                } else {
7732                    Box::new(FastV3ParserPlugin::new())
7733                };
7734                let mut parser = HtmlParser::with_plugin(plugin);
7735                let body = if references {
7736                    r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#
7737                } else {
7738                    r#"<for id="tree" each="item in items"><if condition="{{item.name}}"><b>{{item.name}}</b></if></for>"#
7739                };
7740                parser
7741                    .component_registry_mut()
7742                    .register_component(ComponentRegistration::new("my-tree", body, None, true))
7743                    .unwrap();
7744                let result = parser.parse("index.html", "<my-tree></my-tree>");
7745                if references {
7746                    let error = result.unwrap_err();
7747                    assert!(
7748                        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())
7749                    );
7750                } else {
7751                    result.unwrap();
7752                    let ParserPluginArtifacts::ComponentTemplates(templates) =
7753                        parser.take_plugin_artifacts().unwrap()
7754                    else {
7755                        panic!("FAST produces component templates");
7756                    };
7757                    assert!(templates[0]
7758                        .template
7759                        .contains(r#"<f-repeat value="{{item in items}}">"#));
7760                    assert!(templates[0]
7761                        .template
7762                        .contains(r#"<f-when value="{{item.name}}">"#));
7763                }
7764            }
7765        }
7766    }
7767
7768    // ── Feature 2: <if> / <for> with multiple children ──────────────────
7769
7770    #[test]
7771    fn test_if_multiple_children() {
7772        // Port of: 'should handle <if> with multiple children'
7773        let (fragments, records) =
7774            parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
7775        assert_fragments!(fragments, [if_cond("if-1"),]);
7776        assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
7777    }
7778
7779    #[test]
7780    fn if_condition_with_double_quoted_literal_round_trips_through_parser() {
7781        // The FAST converter emits a single-quoted `<if condition='…'>` when the
7782        // expression contains a double-quoted literal. The WebUI parser must
7783        // extract the full expression (no truncation at the embedded quote), so
7784        // SSR and client see the same predicate.
7785        let (fragments, _) =
7786            parse_and_get_fragments(r#"<if condition='status == "ready"'><span>ok</span></if>"#);
7787        let condition = fragments
7788            .iter()
7789            .find_map(|fragment| match fragment.fragment.as_ref() {
7790                Some(webui_protocol::web_ui_fragment::Fragment::IfCond(if_cond)) => {
7791                    if_cond.condition.as_ref()
7792                }
7793                _ => None,
7794            })
7795            .expect("if-condition fragment with a parsed condition");
7796        match condition.expr.as_ref() {
7797            Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
7798                assert_eq!(pred.left, "status");
7799                assert_eq!(pred.right, "\"ready\"");
7800            }
7801            other => panic!("expected a predicate condition, got {other:?}"),
7802        }
7803    }
7804
7805    #[test]
7806    fn test_for_multiple_children() {
7807        // Port of: 'should handle <for> with multiple children'
7808        let (fragments, records) =
7809            parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
7810        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7811        assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
7812    }
7813
7814    // ── Feature 3: Handlebars at beginning/end of text ──────────────────
7815
7816    #[test]
7817    fn test_handlebars_at_beginning() {
7818        // Port of: 'should process handlebars from text at beginning'
7819        let (fragments, _) = parse_and_get_fragments("{{first}}");
7820        assert_fragments!(fragments, [signal("first"),]);
7821    }
7822
7823    #[test]
7824    fn test_handlebars_at_beginning_and_raw() {
7825        // Port of: 'should process handlebars from text at beginning and raw'
7826        let (fragments, _) = parse_and_get_fragments("{{first}}test");
7827        assert_fragments!(fragments, [signal("first"), raw("test"),]);
7828    }
7829
7830    #[test]
7831    fn test_handlebars_raw_and_end() {
7832        // Port of: 'should process handlebars from text at raw and end'
7833        let (fragments, _) = parse_and_get_fragments("test{{first}}");
7834        assert_fragments!(fragments, [raw("test"), signal("first"),]);
7835    }
7836
7837    // ── Feature 4: Handlebars edge cases ────────────────────────────────
7838
7839    #[test]
7840    fn test_handlebars_invalid_triple_open() {
7841        // Port of: 'should not process handlebars when invalid'
7842        let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
7843        assert_fragments!(fragments, [raw("{{{invalid}}"),]);
7844    }
7845
7846    #[test]
7847    fn test_handlebars_four_open_braces() {
7848        // Port of: 'should not process handlebars when invalid since triple exists'
7849        let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
7850        assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
7851    }
7852
7853    #[test]
7854    fn test_handlebars_five_open_with_valid_double() {
7855        // Port of: 'should not process handlebars when invalid but with valid triple'
7856        let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
7857        assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
7858    }
7859
7860    #[test]
7861    fn test_entities_preserved() {
7862        // Port of: 'should process entities correctly'
7863        let (fragments, _) = parse_and_get_fragments("<p>Hello&#125;World</p>");
7864        assert_fragments!(fragments, [raw("<p>Hello&#125;World</p>"),]);
7865    }
7866
7867    // ── Feature 5: DOCTYPE handling ─────────────────────────────────────
7868
7869    #[test]
7870    fn test_doctype_preserved() {
7871        // DOCTYPE should be preserved as raw content
7872        let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
7873        assert!(!fragments.is_empty());
7874        assert!(
7875            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
7876        );
7877    }
7878
7879    // ── Feature 6: Component attribute skip / multiple nested ───────────
7880
7881    #[test]
7882    fn test_component_attr_skip() {
7883        // Port of: 'should set attrSkip for skipped component attributes'
7884        let mut parser = HtmlParser::new();
7885        parser
7886            .component_registry
7887            .register_component(ComponentRegistration::new(
7888                "custom-element",
7889                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7890                None,
7891                true,
7892            ))
7893            .expect("register");
7894        let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
7895        let result = parser.parse("index.html", html);
7896        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7897        let records = parser.into_fragment_records();
7898
7899        // <custom-element, :config(attrStart), class(attrSkip), style(attrSkip),
7900        // role(attrSkip), data-test(attrSkip), aria-test, >, component, </custom-element>
7901        assert_fragments!(
7902            records["index.html"].fragments,
7903            [
7904                raw("<custom-element"),
7905                // :config with attrStart
7906                attr_complex_start(":config", "config"),
7907                // Skipped attrs
7908                attr_skip("class", "value0"),
7909                attr_skip("style", "value1"),
7910                attr_skip("role", "value2"),
7911                attr_skip("data-test", "value3"),
7912                attr("aria-test", "value4"),
7913                structural_matcher("streaming_root:custom-element"),
7914                raw(">"),
7915                component("custom-element"),
7916                raw("</custom-element>"),
7917            ]
7918        );
7919    }
7920
7921    #[test]
7922    fn test_component_attr_skip_static_and_embedded() {
7923        // Skipped attrs with static values and embedded bindings should
7924        // emit fragments (not be silently dropped).
7925        let mut parser = HtmlParser::new();
7926        parser
7927            .component_registry
7928            .register_component(ComponentRegistration::new(
7929                "item-group",
7930                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7931                None,
7932                true,
7933            ))
7934            .expect("register");
7935
7936        let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
7937        let result = parser.parse("index.html", html);
7938        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7939        let records = parser.into_fragment_records();
7940
7941        assert_fragments!(
7942            records["index.html"].fragments,
7943            [
7944                raw("<item-group"),
7945                attr_skip_raw("role", "list"),
7946                FragmentMatcher::Attribute(AttrMatcher {
7947                    name: "aria-labelledby".into(),
7948                    template: Some("attr-1".into()),
7949                    attr_start: true,
7950                    ..Default::default()
7951                }),
7952                attr_skip_template("data-testid", "attr-2"),
7953                attr_skip_raw("class", "fixed-class"),
7954                structural_matcher("streaming_root:item-group"),
7955                raw(">"),
7956                component("item-group"),
7957                raw("</item-group>"),
7958            ]
7959        );
7960
7961        // Verify the embedded-binding template sub-streams exist and
7962        // contain the expected static + signal fragments.
7963        assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
7964        assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
7965    }
7966
7967    #[test]
7968    fn test_component_multiple_nested() {
7969        // Port of: 'handle multiple nested web components'
7970        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7971        parser
7972            .component_registry
7973            .register_component(ComponentRegistration::new(
7974                "custom-element",
7975                "<custom-child></custom-child><span></span>",
7976                None,
7977                true,
7978            ))
7979            .expect("register");
7980        parser
7981            .component_registry
7982            .register_component(ComponentRegistration::new(
7983                "custom-button",
7984                "<span></span>",
7985                None,
7986                true,
7987            ))
7988            .expect("register");
7989        parser
7990            .component_registry
7991            .register_component(ComponentRegistration::new(
7992                "custom-child",
7993                "<h1>Hello World!</h1>",
7994                None,
7995                true,
7996            ))
7997            .expect("register");
7998
7999        let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
8000        let result = parser.parse("index.html", html);
8001        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8002        let records = parser.into_fragment_records();
8003
8004        // Entry stream
8005        assert_fragments!(
8006            records["index.html"].fragments,
8007            [for_loop("item", "items", "for-1"),]
8008        );
8009
8010        // For stream
8011        assert_stream!(
8012            records,
8013            "for-1",
8014            [
8015                raw("<custom-element"),
8016                structural_matcher("streaming_root:custom-element"),
8017                raw(">"),
8018                component("custom-element"),
8019                raw("<custom-button"),
8020                structural_matcher("streaming_root:custom-button"),
8021                raw(">"),
8022                component("custom-button"),
8023                raw("Ok</custom-button></custom-element>"),
8024            ]
8025        );
8026
8027        // Component streams — custom-element has contains() checks, keep manual
8028        let ce = &records["custom-element"].fragments;
8029        assert_eq!(ce.len(), 5);
8030        assert!(
8031            matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
8032        );
8033        assert!(
8034            matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
8035        );
8036        assert!(
8037            matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><span></span>"))
8038        );
8039
8040        assert_stream!(records, "custom-button", [raw("<span></span>"),]);
8041
8042        assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
8043    }
8044
8045    // ── Error handling tests ──────────────────────────────────────────
8046
8047    #[test]
8048    fn test_invalid_markup_returns_error() {
8049        // Port of: 'should fail with invalid markup'
8050        let mut parser = HtmlParser::new();
8051        let result = parser.parse("index.html", "<div><span>Unclosed div");
8052        assert!(matches!(result, Err(ParserError::Template(diag)) if
8053            diag.to_string().contains("unclosed <div> tag")
8054        ));
8055    }
8056
8057    #[test]
8058    fn test_unterminated_opening_tag_returns_error() {
8059        let mut parser = HtmlParser::new();
8060        let result = parser.parse("index.html", "before <div");
8061        assert!(matches!(result, Err(ParserError::Template(diag)) if
8062            diag.to_string().contains("malformed HTML tag")
8063        ));
8064    }
8065
8066    #[test]
8067    fn test_unterminated_html_comment_returns_error() {
8068        let mut parser = HtmlParser::new();
8069        let result = parser.parse("index.html", "<div><!-- missing close</div>");
8070        assert!(matches!(result, Err(ParserError::Template(diag)) if
8071            diag.to_string().contains("unclosed <div> tag")
8072                || diag.to_string().contains("unterminated HTML comment")
8073        ));
8074    }
8075
8076    #[test]
8077    fn test_unexpected_closing_tag_returns_error() {
8078        let mut parser = HtmlParser::new();
8079        let result = parser.parse("index.html", "</span>");
8080        assert!(matches!(result, Err(ParserError::Template(diag)) if
8081            diag.to_string().contains("unexpected closing tag </span>")
8082        ));
8083    }
8084
8085    #[test]
8086    fn test_rejects_templates_over_size_limit() {
8087        let mut parser = HtmlParser::new();
8088        let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
8089        let result = parser.parse("index.html", &html);
8090
8091        assert!(matches!(result, Err(ParserError::Html(message)) if
8092            message.contains("exceeds") && message.contains("parser limit")
8093        ));
8094    }
8095
8096    #[test]
8097    fn test_rejects_templates_over_depth_limit() {
8098        let mut parser = HtmlParser::new();
8099        let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
8100        for _ in 0..=MAX_TEMPLATE_DEPTH {
8101            html.push_str("<div>");
8102        }
8103        for _ in 0..=MAX_TEMPLATE_DEPTH {
8104            html.push_str("</div>");
8105        }
8106
8107        let result = parser.parse("index.html", &html);
8108
8109        assert!(matches!(result, Err(ParserError::Template(diag)) if
8110            diag.to_string().contains("nesting exceeds")
8111        ));
8112    }
8113
8114    #[test]
8115    fn test_rejects_routes_over_depth_limit() {
8116        let mut parser = HtmlParser::new();
8117        parser
8118            .component_registry
8119            .register_component(ComponentRegistration::new(
8120                "route-page",
8121                r#"<template shadowrootmode="open"><slot></slot></template>"#,
8122                None,
8123                true,
8124            ))
8125            .expect("register");
8126
8127        let depth = MAX_TEMPLATE_DEPTH + 2;
8128        let mut html = String::with_capacity(depth * 48);
8129        for _ in 0..depth {
8130            html.push_str(r#"<route path="x" component="route-page">"#);
8131        }
8132        for _ in 0..depth {
8133            html.push_str("</route>");
8134        }
8135
8136        let result = parser.parse("index.html", &html);
8137
8138        assert!(matches!(result, Err(ParserError::Template(diag)) if
8139            diag.error_code() == Some(codes::EXCESSIVE_NESTING)
8140                && diag.to_string().contains("route nesting exceeds")
8141        ));
8142    }
8143
8144    #[test]
8145    fn test_rejects_recursive_component_templates() {
8146        let mut parser = HtmlParser::new();
8147        parser
8148            .component_registry
8149            .register_component(ComponentRegistration::new(
8150                "self-card",
8151                "<self-card></self-card>",
8152                None,
8153                true,
8154            ))
8155            .expect("register");
8156
8157        let result = parser.parse("index.html", "<self-card></self-card>");
8158
8159        assert!(matches!(result, Err(ParserError::Template(diag)) if
8160            diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
8161                && diag.to_string().contains("recursive template reference")
8162        ));
8163    }
8164
8165    // ── Integration tests ─────────────────────────────────────────────
8166
8167    #[test]
8168    fn test_complex_raw_text_full_page() {
8169        // Port of: 'should process a complex raw text'
8170        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>"#;
8171        let (fragments, _) = parse_and_get_fragments(html);
8172
8173        // DOCTYPE + <head>, head_start, head content, head_end, </head><body>,
8174        // body_start, body content, body_end, </body></html>
8175        assert!(fragments.len() >= 9);
8176        assert!(
8177            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8178                raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
8179        );
8180        assert!(
8181            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
8182                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
8183        );
8184        assert!(
8185            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8186                raw.value.contains("<title>Test</title>"))
8187        );
8188        assert!(
8189            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
8190                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
8191        );
8192        assert!(
8193            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8194                raw.value.contains("</head>") && raw.value.ends_with("<body>"))
8195        );
8196        assert!(
8197            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
8198                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
8199        );
8200        assert!(
8201            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8202                raw.value.contains("<app-shell>"))
8203        );
8204        assert!(
8205            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
8206                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
8207        );
8208        assert!(
8209            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8210                raw.value.contains("</body>") && raw.value.contains("</html>"))
8211        );
8212    }
8213
8214    #[test]
8215    fn raw_bindings_in_html_text_contexts_do_not_own_html_ranges() {
8216        for element in ["title", "textarea", "script", "xmp"] {
8217            let html = format!("<{element}>{{{{{{value}}}}}}</{element}>");
8218            let (fragments, _) = parse_and_get_fragments(&html);
8219            let signal = fragments
8220                .iter()
8221                .find_map(|fragment| match fragment.fragment.as_ref() {
8222                    Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
8223                    _ => None,
8224                });
8225            let signal = signal
8226                .unwrap_or_else(|| panic!("expected raw signal inside <{element}>: {fragments:?}"));
8227            assert!(
8228                signal.raw,
8229                "<{element}> should preserve raw brace semantics"
8230            );
8231            assert!(
8232                signal.raw_text_context,
8233                "<{element}> should suppress HTML ownership markers"
8234            );
8235        }
8236    }
8237
8238    #[test]
8239    fn raw_bindings_in_template_content_own_html_ranges() {
8240        let (fragments, _) = parse_and_get_fragments("<template>{{{value}}}</template>");
8241        let signal = fragments
8242            .iter()
8243            .find_map(|fragment| match fragment.fragment.as_ref() {
8244                Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
8245                _ => None,
8246            });
8247        let signal =
8248            signal.unwrap_or_else(|| panic!("expected raw signal inside template: {fragments:?}"));
8249        assert!(signal.raw);
8250        assert!(!signal.raw_text_context);
8251    }
8252
8253    #[test]
8254    fn test_css_strategy_external_defers_link_to_server_closure() {
8255        let mut parser = HtmlParser::new();
8256        parser
8257            .component_registry_mut()
8258            .register_component(ComponentRegistration::new(
8259                "my-card",
8260                r#"<template shadowrootmode="open"><p>content</p></template>"#,
8261                Some("p { color: red; }"),
8262                true,
8263            ))
8264            .ok();
8265        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
8266        let records = parser.into_fragment_records();
8267        let my_card = &records["my-card"].fragments;
8268        let raw_text: String = my_card
8269            .iter()
8270            .filter_map(|f| match &f.fragment {
8271                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8272                _ => None,
8273            })
8274            .collect();
8275        assert!(
8276            !raw_text.contains(r#"<link rel="stylesheet""#),
8277            "component templates must not own tree-local links: {}",
8278            raw_text
8279        );
8280        assert!(my_card.iter().any(|fragment| matches!(
8281            fragment.fragment.as_ref(),
8282            Some(Fragment::Signal(signal))
8283                if signal.value == "}}}webui:shadow_styles:my-card"
8284        )));
8285    }
8286
8287    #[test]
8288    fn test_uppercase_style_element_is_processed_as_css() {
8289        // HTML tag names are ASCII case-insensitive: an uppercase `<STYLE>`
8290        // block must be CSS-processed exactly like `<style>`, so a CSS signal
8291        // comment becomes a Signal fragment (not inert raw text) and the
8292        // reconstructed closing tag mirrors the source case.
8293        let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
8294        assert!(
8295            fragments.iter().any(|f| matches!(
8296                f.fragment.as_ref(),
8297                Some(Fragment::Signal(s))
8298                    if s.value == "tokens" && !s.raw && s.raw_text_context
8299            )),
8300            "uppercase <STYLE> should be CSS-processed, got: {:?}",
8301            fragments
8302        );
8303        let raw: String = fragments
8304            .iter()
8305            .filter_map(|f| match f.fragment.as_ref() {
8306                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8307                _ => None,
8308            })
8309            .collect();
8310        assert!(
8311            raw.contains("</STYLE>"),
8312            "closing tag should preserve source case, got: {raw}"
8313        );
8314    }
8315
8316    #[test]
8317    fn test_css_strategy_inline_defers_style_to_server_closure() {
8318        let mut parser = HtmlParser::with_options(CssStrategy::Style);
8319        parser
8320            .component_registry_mut()
8321            .register_component(ComponentRegistration::new(
8322                "my-card",
8323                "<p>content</p>",
8324                Some("p { color: red; }"),
8325                true,
8326            ))
8327            .expect("register component");
8328        parser
8329            .parse("index.html", "<my-card>Hello</my-card>")
8330            .expect("parse component");
8331        let records = parser.into_fragment_records();
8332        let my_card = &records["my-card"].fragments;
8333        let raw_text: String = my_card
8334            .iter()
8335            .filter_map(|f| match &f.fragment {
8336                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8337                _ => None,
8338            })
8339            .collect();
8340        assert!(
8341            !raw_text.contains("<style>"),
8342            "component templates must not own tree-local styles: {}",
8343            raw_text
8344        );
8345        assert!(
8346            !raw_text.contains("<link"),
8347            "Should not have <link> tag in inline mode: {}",
8348            raw_text
8349        );
8350    }
8351
8352    #[test]
8353    fn test_css_strategy_module_emits_adopted_stylesheets() {
8354        let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
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
8369        // Component template should have shadowrootadoptedstylesheets, no CSS
8370        // module in raw fragments (CSS lives on the component fragment's css field)
8371        let my_card = &records["my-card"].fragments;
8372        let template_text: String = my_card
8373            .iter()
8374            .filter_map(|f| match &f.fragment {
8375                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8376                _ => None,
8377            })
8378            .collect();
8379        assert!(
8380            !template_text.contains("shadowrootadoptedstylesheets"),
8381            "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
8382        );
8383        assert!(
8384            !template_text.contains("<link"),
8385            "Should not have <link> in module mode: {template_text}"
8386        );
8387        // No CSS module baked into raw fragments — CSS is stored in
8388        // protocol.components, populated by the build system, and emitted
8389        // by the handler (SSR inline + SPA partials) as importmap scripts.
8390        assert!(
8391            !template_text.contains(r#"<style type="module""#),
8392            "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
8393        );
8394        assert!(
8395            !template_text.contains(r#"<script type="importmap""#),
8396            "CSS module importmap should NOT be in raw fragments: {template_text}"
8397        );
8398    }
8399
8400    #[test]
8401    fn light_component_css_remains_authored_and_unscoped() {
8402        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8403        parser
8404            .component_registry_mut()
8405            .register_component(ComponentRegistration::new(
8406                "my-card",
8407                "<p class=\"label\">content</p>",
8408                Some(".label{color:red}"),
8409                false,
8410            ))
8411            .expect("register component");
8412        parser
8413            .parse("index.html", "<my-card></my-card>")
8414            .expect("parse component");
8415
8416        let css = parser
8417            .component_registry()
8418            .get("my-card")
8419            .and_then(|component| component.css_content.as_deref())
8420            .expect("authored CSS");
8421        assert_eq!(css, ".label{color:red}");
8422    }
8423
8424    #[test]
8425    fn light_component_inline_styles_remain_unscoped_and_unmarked() {
8426        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8427        let built = parser
8428            .build_component_templates(ComponentTemplateInput {
8429                tag_name: "my-card",
8430                html: "<style>.label{color:red}</style><p class=\"label\">content</p>",
8431                artifact_html: None,
8432                authored_html: None,
8433                css_content: None,
8434                artifact_needed: false,
8435            })
8436            .expect("build Light component");
8437
8438        assert_eq!(
8439            built.ssr,
8440            "<style>.label{color:red}</style><p class=\"label\">content</p>"
8441        );
8442        assert!(!built.ssr.contains("data-wl"));
8443    }
8444
8445    #[test]
8446    fn light_component_rejects_shadow_only_selectors() {
8447        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8448        for selector in [":host", ":host-context(body.dark)", "::slotted(*)"] {
8449            let html = format!("<style>{selector}{{color:red}}</style><p>content</p>");
8450            let result = parser.build_component_templates(ComponentTemplateInput {
8451                tag_name: "my-card",
8452                html: &html,
8453                artifact_html: None,
8454                authored_html: None,
8455                css_content: None,
8456                artifact_needed: false,
8457            });
8458            let error = match result {
8459                Ok(_) => panic!("Shadow-only selector must fail in Light DOM"),
8460                Err(error) => error,
8461            };
8462            assert!(matches!(
8463                error,
8464                ParserError::Template(ref diagnostic)
8465                    if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
8466            ));
8467        }
8468    }
8469
8470    #[test]
8471    fn light_component_external_css_rejects_host_selector() {
8472        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8473        parser
8474            .component_registry_mut()
8475            .register_component(ComponentRegistration::new(
8476                "my-card",
8477                "<p>content</p>",
8478                Some(":host{display:block}"),
8479                false,
8480            ))
8481            .expect("register component");
8482        let error = parser
8483            .parse("index.html", "<my-card></my-card>")
8484            .expect_err("external :host must fail in Light DOM");
8485        assert!(matches!(
8486            error,
8487            ParserError::Template(ref diagnostic)
8488                if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
8489        ));
8490    }
8491
8492    #[test]
8493    fn shadow_component_inline_styles_remain_tree_local_and_unchanged() {
8494        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
8495        let built = parser
8496            .build_component_templates(ComponentTemplateInput {
8497                tag_name: "my-card",
8498                html: "<style>:host{display:block}.label{color:red}</style><p class=\"label\">content</p>",
8499                artifact_html: None,
8500                authored_html: None,
8501                css_content: None,
8502                artifact_needed: false,
8503            })
8504            .expect("build Shadow component");
8505
8506        assert!(built.ssr.contains(
8507            "<template shadowrootmode=\"open\"><style>:host{display:block}.label{color:red}</style>"
8508        ));
8509        assert!(!built.ssr.contains("data-wl"));
8510    }
8511
8512    #[test]
8513    fn nested_light_hosts_are_unmarked() {
8514        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8515        parser
8516            .component_registry_mut()
8517            .register_component(ComponentRegistration::new(
8518                "my-child",
8519                "<p class=\"inner\">child</p>",
8520                Some(".inner{color:green}"),
8521                false,
8522            ))
8523            .expect("register child");
8524        parser
8525            .component_registry_mut()
8526            .register_component(ComponentRegistration::new(
8527                "my-card",
8528                "<my-child></my-child>",
8529                Some("my-child{display:block}"),
8530                false,
8531            ))
8532            .expect("register parent");
8533        parser
8534            .parse("index.html", "<my-card></my-card>")
8535            .expect("parse component");
8536
8537        let records = parser.into_fragment_records();
8538        let raw: String = records["my-card"]
8539            .fragments
8540            .iter()
8541            .filter_map(|fragment| match &fragment.fragment {
8542                Some(web_ui_fragment::Fragment::Raw(raw)) => Some(raw.value.as_str()),
8543                _ => None,
8544            })
8545            .collect();
8546        assert!(raw.contains("<my-child>"), "nested host was changed: {raw}");
8547        assert!(!raw.contains("data-wl"), "Light markers remain: {raw}");
8548    }
8549
8550    #[test]
8551    fn raw_html_binding_does_not_change_global_css() {
8552        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8553        parser
8554            .component_registry_mut()
8555            .register_component(ComponentRegistration::new(
8556                "my-card",
8557                "<div class=\"body\">{{{descriptionHtml}}}</div>",
8558                Some(".body p{margin:0}"),
8559                false,
8560            ))
8561            .expect("register component");
8562        parser
8563            .parse("index.html", "<my-card></my-card>")
8564            .expect("parse component");
8565
8566        let css = parser
8567            .component_registry()
8568            .get("my-card")
8569            .and_then(|component| component.css_content.as_deref())
8570            .expect("authored CSS");
8571        assert_eq!(css, ".body p{margin:0}");
8572    }
8573
8574    #[test]
8575    fn test_css_strategy_module_no_css_no_adopted_attr() {
8576        let mut parser = HtmlParser::with_options(CssStrategy::Module);
8577        parser
8578            .component_registry_mut()
8579            .register_component(ComponentRegistration::new(
8580                "my-card",
8581                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
8582                None,
8583                true,
8584            ))
8585            .ok();
8586        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
8587        let records = parser.into_fragment_records();
8588        let my_card = &records["my-card"].fragments;
8589        let template_text: String = my_card
8590            .iter()
8591            .filter_map(|f| match &f.fragment {
8592                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8593                _ => None,
8594            })
8595            .collect();
8596        // No CSS → no shadowrootadoptedstylesheets attribute
8597        assert!(
8598            !template_text.contains("shadowrootadoptedstylesheets"),
8599            "Should not have adopted attr without CSS: {template_text}"
8600        );
8601    }
8602
8603    // ── Dev-authored <template> wrapper handling ────────────────────
8604    //
8605    // These tests distinguish an ordinary `<template>` element from a
8606    // declarative Shadow DOM root:
8607    //
8608    // An ordinary `<template ...>` is Light DOM content and is preserved
8609    // verbatim. Only a sole open declarative Shadow root selects Shadow DOM.
8610    //
8611    // Calls `process_component_template` directly so the assertions observe
8612    // the exact HTML string the framework emits for the component template
8613    // (this is what gets handed to the inner parse + plugin hooks).
8614
8615    #[test]
8616    fn light_preserves_dev_template_with_static_attrs() {
8617        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8618        let processed = parser
8619            .process_component_template(
8620                r#"<template foo="bar"><div>hi</div></template>"#,
8621                None,
8622                None,
8623            )
8624            .expect("process failed");
8625        assert!(
8626            processed.contains(r#"<template foo="bar">"#),
8627            "expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
8628        );
8629        assert!(
8630            processed.contains("</template>"),
8631            "expected closing </template> preserved, got: {processed}"
8632        );
8633    }
8634
8635    #[test]
8636    fn light_preserves_dev_template_with_signal_fragment_attrs() {
8637        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8638        let processed = parser
8639            .process_component_template(
8640                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8641                None,
8642                None,
8643            )
8644            .expect("process failed");
8645        assert!(
8646            processed.contains(r#"<template foo="{{foo}}">"#),
8647            "expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
8648        );
8649    }
8650
8651    #[test]
8652    fn light_preserves_dev_template_with_multiple_attrs() {
8653        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8654        let processed = parser.process_component_template(
8655            r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
8656            None,
8657            None,
8658        )
8659        .expect("process failed");
8660        assert!(
8661            processed.contains("autofocus")
8662                && processed.contains(r#"tabindex="0""#)
8663                && processed.contains(r#"role="region""#)
8664                && processed.contains(r#"data-x="y""#),
8665            "expected all dev template attrs preserved, got: {processed}"
8666        );
8667    }
8668
8669    #[test]
8670    fn light_no_template_no_wrapper_added() {
8671        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8672        let processed = parser
8673            .process_component_template("<div>hi</div>", None, None)
8674            .expect("process failed");
8675        assert!(
8676            !processed.contains("<template"),
8677            "framework must not add a <template> wrapper when dev omits one, got: {processed}"
8678        );
8679        assert!(
8680            processed.contains("<div>hi</div>"),
8681            "expected inner content emitted as-is, got: {processed}"
8682        );
8683    }
8684
8685    #[test]
8686    fn ordinary_template_remains_light_dom_content() {
8687        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8688        let processed = parser
8689            .process_component_template(
8690                r#"<template foo="bar"><div>hi</div></template>"#,
8691                None,
8692                None,
8693            )
8694            .expect("process failed");
8695        assert!(
8696            processed.contains(r#"<template foo="bar">"#),
8697            "ordinary template content must be preserved, got: {processed}"
8698        );
8699        assert!(
8700            !processed.contains("shadowrootmode"),
8701            "ordinary template must not gain a generated Shadow root, got: {processed}"
8702        );
8703    }
8704
8705    #[test]
8706    fn bare_template_is_unwrapped_as_explicit_light_dom() {
8707        let mut parser = HtmlParser::new();
8708        let built = parser
8709            .build_component_templates(ComponentTemplateInput {
8710                tag_name: "my-card",
8711                html: "<template><div class=\"label\">hi</div></template>",
8712                artifact_html: None,
8713                authored_html: None,
8714                css_content: Some(".label{color:red}"),
8715                artifact_needed: false,
8716            })
8717            .expect("bare template should select Light DOM");
8718        assert!(!built.uses_shadow_dom);
8719        assert_eq!(built.ssr, r#"<div class="label">hi</div>"#);
8720    }
8721
8722    #[test]
8723    fn attributed_template_keeps_the_shadow_fallback() {
8724        let mut parser = HtmlParser::new();
8725        let built = parser
8726            .build_component_templates(ComponentTemplateInput {
8727                tag_name: "my-card",
8728                html: r#"<template data-purpose="content"><div>hi</div></template>"#,
8729                artifact_html: None,
8730                authored_html: None,
8731                css_content: None,
8732                artifact_needed: false,
8733            })
8734            .expect("attributed template should remain ordinary content");
8735        assert!(built.uses_shadow_dom);
8736        assert_eq!(
8737            built.ssr,
8738            r#"<template shadowrootmode="open"><template data-purpose="content"><div>hi</div></template></template>"#
8739        );
8740    }
8741
8742    #[test]
8743    fn closed_shadow_wrapper_is_rejected_during_template_processing() {
8744        let mut parser = HtmlParser::new();
8745        let error = parser
8746            .process_component_template(
8747                r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
8748                None,
8749                None,
8750            )
8751            .expect_err("closed roots must fail");
8752        assert!(matches!(
8753            error,
8754            ParserError::Template(ref diagnostic)
8755                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
8756        ));
8757    }
8758
8759    #[test]
8760    fn ordinary_template_preserves_signal_fragment_attrs() {
8761        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8762        let processed = parser
8763            .process_component_template(
8764                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8765                None,
8766                None,
8767            )
8768            .expect("process failed");
8769        assert!(
8770            processed.starts_with(r#"<template foo="{{foo}}">"#),
8771            "ordinary template must remain Light content, got: {processed}"
8772        );
8773    }
8774
8775    #[test]
8776    fn unwrapped_component_never_gains_shadow_wrapper() {
8777        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8778        let processed = parser
8779            .process_component_template("<div>hi</div>", None, None)
8780            .expect("process failed");
8781        assert!(
8782            !processed.contains("shadowrootmode"),
8783            "framework must not generate a Shadow root, got: {processed}"
8784        );
8785        assert!(
8786            processed.contains("<div>hi</div>"),
8787            "Light content must survive processing, got: {processed}"
8788        );
8789    }
8790
8791    #[test]
8792    fn default_unwrapped_component_gains_open_shadow_wrapper() {
8793        let mut parser = HtmlParser::new();
8794        let processed = parser
8795            .process_component_template("<div>hi</div>", None, None)
8796            .expect("process failed");
8797        assert_eq!(
8798            processed,
8799            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8800        );
8801    }
8802
8803    #[test]
8804    fn leading_comments_do_not_change_root_template_detection() {
8805        const COMMENT: &str = "<!-- Copyright (C) Corporation. All rights reserved. -->";
8806        let fixtures = [
8807            (
8808                format!(
8809                    "{COMMENT}\n<template shadowrootmode=\"open\">\n  <h1>Hello</h1>\n</template>"
8810                ),
8811                "<template shadowrootmode=\"open\">\n  <h1>Hello</h1>\n</template>",
8812            ),
8813            (format!("{COMMENT}\n<h1>Hello</h1>"), "<h1>Hello</h1>"),
8814            (format!("{COMMENT}\nHello"), "Hello"),
8815        ];
8816
8817        for (input, expected) in fixtures {
8818            let mut parser = HtmlParser::with_options(DomStrategy::Light);
8819            let processed = parser
8820                .process_component_template(&input, None, None)
8821                .expect("leading-comment fixture should process");
8822            assert_eq!(processed, expected);
8823        }
8824    }
8825
8826    #[test]
8827    fn component_policy_wrapper_is_build_only_and_unwrapped_for_light() {
8828        // A policy wrapper is authored on a plain `<template>`; the policy is
8829        // applied to the host through generated CSS, so the wrapper must not
8830        // survive into a Light template.
8831        let html = r#"<template w-hydrate="lazy"><div>hi</div></template>"#;
8832        let mut shadow = HtmlParser::with_options(DomStrategy::Shadow);
8833        let shadow_built = shadow
8834            .build_component_templates(ComponentTemplateInput {
8835                tag_name: "my-comp",
8836                html,
8837                artifact_html: None,
8838                authored_html: None,
8839                css_content: None,
8840                artifact_needed: true,
8841            })
8842            .expect("shadow policy wrapper should compile");
8843        assert_eq!(
8844            shadow_built.ssr,
8845            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8846        );
8847        assert!(!shadow_built.artifact().contains("w-hydrate"));
8848        let mut light = HtmlParser::with_options(DomStrategy::Light);
8849        let light_built = light
8850            .build_component_templates(ComponentTemplateInput {
8851                tag_name: "my-comp",
8852                html,
8853                artifact_html: None,
8854                authored_html: None,
8855                css_content: None,
8856                artifact_needed: true,
8857            })
8858            .expect("light policy wrapper should compile");
8859        assert_eq!(light_built.ssr, "<div>hi</div>");
8860        assert_eq!(light_built.artifact(), "<div>hi</div>");
8861
8862        let mut implicit_shadow = HtmlParser::new();
8863        let shadow_built = implicit_shadow
8864            .build_component_templates(ComponentTemplateInput {
8865                tag_name: "my-comp",
8866                html,
8867                artifact_html: None,
8868                authored_html: None,
8869                css_content: None,
8870                artifact_needed: true,
8871            })
8872            .expect("default policy wrapper should compile as Shadow");
8873        assert_eq!(
8874            shadow_built.ssr,
8875            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8876        );
8877        assert!(!shadow_built.artifact().contains("w-hydrate"));
8878
8879        // An authored Shadow component keeps its wrapper; only the
8880        // compiler-owned policy attributes are stripped.
8881        let shadow_html =
8882            r#"<template shadowrootmode="open" w-hydrate="lazy"><div>hi</div></template>"#;
8883        let mut shadow = HtmlParser::new();
8884        let authored_shadow_built = shadow
8885            .build_component_templates(ComponentTemplateInput {
8886                tag_name: "my-comp",
8887                html: shadow_html,
8888                artifact_html: None,
8889                authored_html: None,
8890                css_content: None,
8891                artifact_needed: true,
8892            })
8893            .expect("shadow policy wrapper should compile");
8894        assert_eq!(
8895            authored_shadow_built.ssr,
8896            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8897        );
8898        assert!(!authored_shadow_built.artifact().contains("w-hydrate"));
8899    }
8900
8901    #[test]
8902    fn interaction_policy_marks_component_hosts_in_ssr_fragments() {
8903        let mut parser = HtmlParser::new();
8904        parser
8905            .component_registry
8906            .register_component(ComponentRegistration::new(
8907                "interaction-shell",
8908                r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
8909                None,
8910                true,
8911            ))
8912            .expect("component registration");
8913        parser
8914            .parse(
8915                "index.html",
8916                "<body><interaction-shell></interaction-shell></body>",
8917            )
8918            .expect("entry parse");
8919        let records = parser.into_fragment_records();
8920        let entry_html: String = records["index.html"]
8921            .fragments
8922            .iter()
8923            .filter_map(|fragment| match fragment.fragment.as_ref() {
8924                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8925                _ => None,
8926            })
8927            .collect();
8928        assert!(entry_html.contains("<interaction-shell data-webui-interaction"));
8929    }
8930
8931    #[test]
8932    fn combined_interaction_policy_marks_hosts_and_emits_render_css() {
8933        let mut parser = HtmlParser::new();
8934        parser
8935            .component_registry
8936            .register_component(ComponentRegistration::new(
8937                "interaction-panel",
8938                concat!(
8939                    r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
8940                    r#"w-hydrate="interaction"><button>Open</button></template>"#,
8941                ),
8942                None,
8943                true,
8944            ))
8945            .expect("component registration");
8946        parser
8947            .parse(
8948                "index.html",
8949                "<body><interaction-panel></interaction-panel></body>",
8950            )
8951            .expect("entry parse");
8952        assert!(parser
8953            .component_registry
8954            .render_policy_css(["interaction-panel"])
8955            .contains("content-visibility:auto"));
8956        let records = parser.into_fragment_records();
8957        let entry_html: String = records["index.html"]
8958            .fragments
8959            .iter()
8960            .filter_map(|fragment| match fragment.fragment.as_ref() {
8961                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8962                _ => None,
8963            })
8964            .collect();
8965        assert!(entry_html.contains("<interaction-panel data-webui-interaction"));
8966    }
8967
8968    // ── CSS-module adoption on dev-authored <template> wrappers ────────
8969    //
8970    // Regression guard: under `CssStrategy::Module`, the
8971    // `shadowrootadoptedstylesheets` attribute is the only wire between
8972    // the framework-emitted importmap/module-script and the shadow root.
8973    // When the developer supplies their own `<template>` wrapper (e.g.
8974    // to attach `@event` handlers), the framework preserves that wrapper
8975    // and appends the CSS-module adoption attribute when it is missing.
8976
8977    #[test]
8978    fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
8979        let mut parser = HtmlParser::new();
8980        let processed = parser
8981            .process_component_template(
8982                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
8983                None,
8984                Some("my-comp"),
8985            )
8986            .expect("process failed");
8987        assert!(
8988            processed.contains(r#"shadowrootmode="open""#),
8989            "dev-authored shadowrootmode must be preserved, got: {processed}"
8990        );
8991        assert!(
8992            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
8993            "module CSS should append adopted stylesheets, got: {processed}"
8994        );
8995    }
8996
8997    #[test]
8998    fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
8999        let mut parser = HtmlParser::new();
9000        let processed = parser
9001            .process_component_template(
9002                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
9003                None,
9004                Some("my-comp"),
9005            )
9006            .unwrap();
9007        assert!(
9008            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
9009            "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
9010        );
9011        assert_eq!(
9012            processed.matches("shadowrootadoptedstylesheets").count(),
9013            1,
9014            "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
9015        );
9016    }
9017
9018    #[test]
9019    fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
9020        for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
9021            let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
9022            let built = match parser.build_component_templates(ComponentTemplateInput {
9023                tag_name: "my-comp",
9024                html: r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
9025                artifact_html: None,
9026                authored_html: None,
9027                css_content: Some(":host { color: red; }"),
9028                artifact_needed: true,
9029            }) {
9030                Ok(built) => built,
9031                Err(err) => panic!(
9032                    "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
9033                ),
9034            };
9035            let artifact = built.artifact();
9036
9037            assert!(artifact.contains(r#"shadowrootmode="open""#));
9038            assert!(artifact.contains(r#"@click="{onClick()}""#));
9039            assert!(artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#));
9040            assert_eq!(artifact.matches("shadowrootadoptedstylesheets").count(), 1);
9041            assert!(!built.ssr.contains("@click"));
9042        }
9043    }
9044
9045    #[test]
9046    fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
9047        // Multi-specifier scenarios: the dev wants to adopt extra sheets
9048        // alongside their own component's module. Honored verbatim — the
9049        // framework's only job is to validate that *some*
9050        // `shadowrootadoptedstylesheets` is present.
9051        let mut parser = HtmlParser::new();
9052        let processed = parser
9053            .process_component_template(
9054                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
9055                None,
9056                Some("my-comp"),
9057            )
9058            .unwrap();
9059        assert!(
9060            processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
9061            "dev's multi-specifier list must be preserved verbatim, got: {processed}"
9062        );
9063    }
9064
9065    #[test]
9066    fn dev_template_no_adopted_specifier_does_not_validate() {
9067        // CssStrategy::Link or CssStrategy::Style pass `adopted_specifier=None`.
9068        // Dev's <template> must be preserved verbatim and the validation
9069        // must not fire.
9070        let mut parser = HtmlParser::new();
9071        let processed = parser
9072            .process_component_template(
9073                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
9074                None,
9075                None,
9076            )
9077            .unwrap();
9078        assert!(
9079            !processed.contains("shadowrootadoptedstylesheets"),
9080            "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
9081        );
9082    }
9083
9084    // test_signal_with_default_value — SKIPPED
9085    // The NodeJS `<f-signal value="testSignal">Default Text</f-signal>` feature
9086    // is not supported in the Rust parser. There is no `f-signal` element
9087    // handling in HtmlParser and no corresponding fragment type in
9088    // webui_protocol.
9089
9090    // test_estimated_buffer_size — SKIPPED
9091    // The NodeJS `estimatedBufferSize` field does not exist in the Rust
9092    // WebUIFragmentRecords / WebUIProtocol types. Buffer size estimation is
9093    // not part of the Rust parser output.
9094
9095    #[test]
9096    fn test_body_start_end_injection() {
9097        // Port of: 'should inject body_start and body_end signals around body content'
9098        // Verifies body_start appears immediately after <body> and body_end
9099        // appears immediately before </body> in a full HTML page.
9100        let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
9101        let (fragments, _) = parse_and_get_fragments(html);
9102
9103        assert_fragments!(
9104            fragments,
9105            [
9106                raw("<html><head>"),
9107                structural_matcher("head_start"),
9108                raw("<title>Test</title>"),
9109                structural_matcher("head_end"),
9110                raw("</head><body>"),
9111                structural_matcher("body_start"),
9112                raw("<div>Content</div><p>More</p>"),
9113                structural_matcher("body_end"),
9114                raw("</body></html>"),
9115            ]
9116        );
9117    }
9118
9119    #[test]
9120    fn test_body_preserves_static_attributes() {
9121        // The <body> tag's attributes must be carried through to the rendered
9122        // HTML, otherwise host frames (e.g. webui-press's `<body data-layout="…">`)
9123        // lose their styling hooks. Earlier versions of `process_body_with_signals`
9124        // emitted a bare `<body>` and dropped every attribute on the element.
9125        let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
9126        let (fragments, _) = parse_and_get_fragments(html);
9127
9128        assert_fragments!(
9129            fragments,
9130            [
9131                raw("<html><head>"),
9132                structural_matcher("head_start"),
9133                raw("<title>T</title>"),
9134                structural_matcher("head_end"),
9135                raw(r#"</head><body data-layout="doc" class="page">"#),
9136                structural_matcher("body_start"),
9137                raw("<p>x</p>"),
9138                structural_matcher("body_end"),
9139                raw("</body></html>"),
9140            ]
9141        );
9142    }
9143
9144    #[test]
9145    fn test_body_preserves_dynamic_attributes() {
9146        // Dynamic attributes on <body> (e.g. `data-layout="{{layout}}"`) must
9147        // emit attribute fragments rather than being inlined as literal text,
9148        // so the handler binds them to runtime state at render time.
9149        let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
9150        let (fragments, _) = parse_and_get_fragments(html);
9151
9152        assert_fragments!(
9153            fragments,
9154            [
9155                raw("<html><head>"),
9156                structural_matcher("head_start"),
9157                raw("<title>T</title>"),
9158                structural_matcher("head_end"),
9159                raw("</head><body"),
9160                attr("data-layout", "layout"),
9161                raw(">"),
9162                structural_matcher("body_start"),
9163                raw("<p>x</p>"),
9164                structural_matcher("body_end"),
9165                raw("</body></html>"),
9166            ]
9167        );
9168    }
9169
9170    #[test]
9171    fn test_head_start_signal_ordering_with_authored_script() {
9172        // `render_streaming` preflights on `head_start` before authored async
9173        // scripts in <head>, mirroring `body_start`. `head_start` must appear
9174        // immediately after the raw `<head>` opening fragment and before any
9175        // child content, including an authored `<script>`.
9176        let html = r#"<html><head><script async src="./analytics.js"></script></head><body></body></html>"#;
9177        let (fragments, _) = parse_and_get_fragments(html);
9178
9179        assert_fragments!(
9180            fragments,
9181            [
9182                raw("<html><head>"),
9183                structural_matcher("head_start"),
9184                raw(r#"<script async src="./analytics.js"></script>"#),
9185                structural_matcher("head_end"),
9186                raw("</head><body>"),
9187                structural_matcher("body_start"),
9188                structural_matcher("body_end"),
9189                raw("</body></html>"),
9190            ]
9191        );
9192    }
9193
9194    #[test]
9195    fn test_head_static_attributes_preserve_legacy_opening_bytes() {
9196        // Compatibility: origin/main emitted a literal `<head>` and ignored
9197        // authored head attributes. The structural signal must not alter that
9198        // ordinary protocol/output shape.
9199        let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
9200        let (fragments, _) = parse_and_get_fragments(html);
9201
9202        assert_fragments!(
9203            fragments,
9204            [
9205                raw("<html><head>"),
9206                structural_matcher("head_start"),
9207                raw("<title>T</title>"),
9208                structural_matcher("head_end"),
9209                raw("</head><body>"),
9210                structural_matcher("body_start"),
9211                structural_matcher("body_end"),
9212                raw("</body></html>"),
9213            ]
9214        );
9215    }
9216
9217    #[test]
9218    fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
9219        // Bound head attributes were also ignored by origin/main. In
9220        // particular, do not introduce an attribute fragment before
9221        // `head_start`, because ordinary rendering must remain byte-identical.
9222        let html =
9223            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
9224        let (fragments, _) = parse_and_get_fragments(html);
9225
9226        assert_fragments!(
9227            fragments,
9228            [
9229                raw("<html><head>"),
9230                structural_matcher("head_start"),
9231                raw("<title>T</title>"),
9232                structural_matcher("head_end"),
9233                raw("</head><body>"),
9234                structural_matcher("body_start"),
9235                structural_matcher("body_end"),
9236                raw("</body></html>"),
9237            ]
9238        );
9239    }
9240
9241    #[test]
9242    fn native_head_and_body_are_ascii_case_insensitive() {
9243        let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
9244        let (fragments, _) = parse_and_get_fragments(html);
9245
9246        assert_fragments!(
9247            fragments,
9248            [
9249                raw(r#"<html><HEAD data-theme="ignored">"#),
9250                structural_matcher("head_start"),
9251                raw("<title>T</title>"),
9252                structural_matcher("head_end"),
9253                raw("</HEAD><BODY"),
9254                attr("data-layout", "layout"),
9255                raw(">"),
9256                structural_matcher("body_start"),
9257                raw("<p>x</p>"),
9258                structural_matcher("body_end"),
9259                raw("</BODY></html>"),
9260            ]
9261        );
9262    }
9263
9264    // ── `<boundary>` / `<webui-hydrate>` tests (Progressive Streaming
9265    // Hydration) ───────────────────────────────────────────────────────────
9266
9267    #[test]
9268    fn entry_boundary_is_an_inline_start_end_tape() {
9269        let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
9270        let (fragments, records) = parse_and_get_fragments(html);
9271
9272        let boundary = match fragments[3].fragment.as_ref() {
9273            Some(Fragment::Boundary(boundary)) => boundary,
9274            other => panic!("expected typed boundary start, got {other:?}"),
9275        };
9276        assert_eq!(boundary.phase(), BoundaryPhase::Start);
9277        assert_eq!(boundary.declaration_id, 0);
9278        assert_eq!(boundary.owner_fragment_id, "index.html");
9279        assert_eq!(boundary.name, "counter-ready");
9280        assert_eq!(boundary.key, None);
9281        assert!(!boundary.may_repeat);
9282        // The body stays inline in the owner record, closed by an end marker,
9283        // so ordinary rendering never looks up a second record.
9284        assert_stream!(
9285            records,
9286            "index.html",
9287            [
9288                raw("<body>"),
9289                structural_matcher("body_start"),
9290                raw("<div>"),
9291                boundary_matcher("counter-ready", 0),
9292                raw("<my-counter></my-counter>"),
9293                boundary_end_matcher(0),
9294                raw("</div>"),
9295                structural_matcher("body_end"),
9296                raw("</body>"),
9297            ]
9298        );
9299        assert!(records["index.html"].contains_boundary);
9300        assert!(
9301            !records.contains_key("boundary-1"),
9302            "an inline tape must not create a body record"
9303        );
9304    }
9305
9306    #[test]
9307    fn boundary_names_are_scoped_per_owner_and_declaration_ids_are_protocol_wide() {
9308        let mut parser = HtmlParser::new();
9309        parser
9310            .parse(
9311                "first.html",
9312                r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
9313            )
9314            .expect("first entry parses");
9315        parser
9316            .parse(
9317                "second.html",
9318                r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
9319            )
9320            .expect("a second owner may reuse the name");
9321        let records = parser.into_fragment_records();
9322        let first = records["first.html"]
9323            .fragments
9324            .iter()
9325            .find_map(|fragment| match fragment.fragment.as_ref() {
9326                Some(Fragment::Boundary(boundary)) => Some(boundary),
9327                _ => None,
9328            })
9329            .expect("first boundary");
9330        let second = records["second.html"]
9331            .fragments
9332            .iter()
9333            .find_map(|fragment| match fragment.fragment.as_ref() {
9334                Some(Fragment::Boundary(boundary)) => Some(boundary),
9335                _ => None,
9336            })
9337            .expect("second boundary");
9338        assert_eq!((first.declaration_id, first.name.as_str()), (0, "a"));
9339        assert_eq!((second.declaration_id, second.name.as_str()), (1, "a"));
9340    }
9341
9342    #[test]
9343    fn boundary_missing_name_errors() {
9344        for html in [
9345            "<body><boundary><p>x</p></boundary></body>",
9346            r#"<body><boundary name=""><p>x</p></boundary></body>"#,
9347            r#"<body><boundary name="   "><p>x</p></boundary></body>"#,
9348        ] {
9349            let mut parser = HtmlParser::new();
9350            let err = parser
9351                .parse("index.html", html)
9352                .expect_err("a boundary without a non-empty name must error");
9353            let ParserError::Template(diag) = err else {
9354                panic!("expected ParserError::Template, got {err:?}");
9355            };
9356            assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
9357        }
9358    }
9359
9360    #[test]
9361    fn boundary_dynamic_name_errors() {
9362        let mut parser = HtmlParser::new();
9363        let err = parser
9364            .parse(
9365                "index.html",
9366                r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
9367            )
9368            .expect_err("a dynamic boundary name must error");
9369        let ParserError::Template(diag) = err else {
9370            panic!("expected ParserError::Template, got {err:?}");
9371        };
9372        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
9373    }
9374
9375    #[test]
9376    fn boundary_names_are_free_form_and_live_on_typed_fragments() {
9377        let mut parser = HtmlParser::new();
9378        let html = concat!(
9379            "<body>",
9380            r#"<boundary name="above the fold"><p>1</p></boundary>"#,
9381            r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
9382            r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
9383            "</body>",
9384        );
9385        parser
9386            .parse("index.html", html)
9387            .expect("free-form boundary names must be accepted");
9388        let records = parser.into_fragment_records();
9389        let names: Vec<&str> = records["index.html"]
9390            .fragments
9391            .iter()
9392            .filter_map(|fragment| match fragment.fragment.as_ref() {
9393                Some(Fragment::Boundary(boundary)) if boundary.phase() == BoundaryPhase::Start => {
9394                    Some(boundary.name.as_str())
9395                }
9396                _ => None,
9397            })
9398            .collect();
9399        assert_eq!(names, ["above the fold", "feed/items #2", "ダッシュボード"]);
9400    }
9401
9402    #[test]
9403    fn module_entry_srcs_records_only_preloadable_critical_entries() {
9404        let mut parser = HtmlParser::new();
9405        parser
9406            .component_registry
9407            .register_component(ComponentRegistration::new(
9408                "weather-island",
9409                r#"<script type="module" src="/nested-weather.js"></script>"#,
9410                None,
9411                true,
9412            ))
9413            .expect("register");
9414        let html = concat!(
9415            "<head>",
9416            // Recorded: a static, module-typed, non-boundary entry.
9417            r#"<script type="module" async src="/index.js"></script>"#,
9418            // Recorded: mixed case on the tag, and a padded/mixed-case `type`
9419            // value, still identify a module. (Attribute *names* are matched
9420            // case-sensitively here, as everywhere else in this parser.)
9421            r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
9422            // Skipped: classic scripts have no ES module graph to preload.
9423            r#"<script src="/legacy.js"></script>"#,
9424            // Skipped: no `src` means nothing to resolve.
9425            r#"<script type="module">import "./x.js";</script>"#,
9426            // Skipped: a per-request binding matches no build artifact.
9427            r#"<script type="module" src="/{{bundle}}.js"></script>"#,
9428            "</head>",
9429            "<body>",
9430            // Skipped: an island loader is deferred on purpose.
9431            r#"<boundary name="weather">"#,
9432            r#"<script type="module" async src="/weather-panel.js"></script>"#,
9433            "<weather-island></weather-island>",
9434            "</boundary>",
9435            // Recorded once: a bottom-of-body entry is still critical, and a
9436            // repeat of an already-seen src must not duplicate a hint.
9437            r#"<script type="module" src="/index.js"></script>"#,
9438            "</body>",
9439        );
9440        parser.parse("index.html", html).expect("parse");
9441
9442        assert_eq!(
9443            parser.module_entry_srcs(),
9444            ["/index.js", "/late.js"],
9445            "only static module entries outside boundaries are preloadable"
9446        );
9447    }
9448
9449    #[test]
9450    fn module_entry_srcs_reset_between_top_level_parses() {
9451        let mut parser = HtmlParser::new();
9452        parser
9453            .parse(
9454                "a.html",
9455                r#"<head><script type="module" src="/a.js"></script></head>"#,
9456            )
9457            .expect("parse a");
9458        assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
9459
9460        parser
9461            .parse(
9462                "b.html",
9463                r#"<head><script type="module" src="/b.js"></script></head>"#,
9464            )
9465            .expect("parse b");
9466        assert_eq!(
9467            parser.module_entry_srcs(),
9468            ["/b.js"],
9469            "each entry owns its own critical modules"
9470        );
9471    }
9472
9473    #[test]
9474    fn module_entry_srcs_preserve_low_priority_script_delivery() {
9475        let mut parser = HtmlParser::new();
9476        let html = concat!(
9477            "<head>",
9478            r#"<script type="module" async src="/streaming.js"></script>"#,
9479            r#"<script type="module" src="/low-head.js" fetchpriority="LOW"></script>"#,
9480            "</head><body>",
9481            r#"<script type="module" src="/application.js" fetchpriority="low"></script>"#,
9482            r#"<script type="module" src="/low-cased.js" FETCHPRIORITY="LoW"></script>"#,
9483            r#"<script type="module" src="/critical.js" fetchpriority="high"></script>"#,
9484            r#"<script type="module" src="/ordinary.js"></script>"#,
9485            "</body>",
9486        );
9487        parser.parse("index.html", html).expect("parse");
9488        assert_eq!(
9489            parser.module_entry_srcs(),
9490            ["/streaming.js", "/critical.js", "/ordinary.js"],
9491            "low-priority application chunks must not be pulled into head preloads"
9492        );
9493        let records = parser.into_fragment_records();
9494        let raw = records["index.html"]
9495            .fragments
9496            .iter()
9497            .filter_map(|fragment| match fragment.fragment.as_ref() {
9498                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
9499                _ => None,
9500            })
9501            .collect::<String>();
9502        assert!(
9503            raw.contains(r#"src="/application.js" fetchpriority="low""#),
9504            "excluding preloads must preserve the authored script"
9505        );
9506    }
9507
9508    #[test]
9509    fn boundary_duplicate_name_errors() {
9510        let mut parser = HtmlParser::new();
9511        let html = concat!(
9512            "<body>",
9513            r#"<boundary name="dup"><p>1</p></boundary>"#,
9514            r#"<boundary name="dup"><p>2</p></boundary>"#,
9515            "</body>",
9516        );
9517        let err = parser
9518            .parse("index.html", html)
9519            .expect_err("a duplicate boundary name must error");
9520        let ParserError::Template(diag) = err else {
9521            panic!("expected ParserError::Template, got {err:?}");
9522        };
9523        assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
9524    }
9525
9526    #[test]
9527    fn boundary_nested_boundary_errors() {
9528        let mut parser = HtmlParser::new();
9529        let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
9530        let err = parser
9531            .parse("index.html", html)
9532            .expect_err("a nested boundary must error");
9533        let ParserError::Template(diag) = err else {
9534            panic!("expected ParserError::Template, got {err:?}");
9535        };
9536        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
9537    }
9538
9539    #[test]
9540    fn boundary_transitively_nested_through_component_errors() {
9541        let mut parser = HtmlParser::new();
9542        parser
9543            .component_registry
9544            .register_component(ComponentRegistration::new(
9545                "inner-panel",
9546                r#"<boundary name="inner"><p>x</p></boundary>"#,
9547                None,
9548                true,
9549            ))
9550            .expect("register");
9551        let err = parser
9552            .parse(
9553                "index.html",
9554                r#"<body><boundary name="outer"><inner-panel></inner-panel></boundary></body>"#,
9555            )
9556            .expect_err("transitively nested boundaries must error");
9557        let ParserError::Template(diag) = err else {
9558            panic!("expected ParserError::Template, got {err:?}");
9559        };
9560        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
9561        assert!(diag
9562            .help_text()
9563            .is_some_and(|help| help.contains("runtime branches")));
9564    }
9565
9566    #[test]
9567    fn boundary_inside_if_preserves_false_branch_structure() {
9568        let mut parser = HtmlParser::new();
9569        let html =
9570            r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
9571        parser.parse("index.html", html).expect("parse");
9572        let records = parser.into_fragment_records();
9573        let if_fragment_id = records["index.html"]
9574            .fragments
9575            .iter()
9576            .find_map(|fragment| match fragment.fragment.as_ref() {
9577                Some(Fragment::IfCond(if_cond)) => Some(if_cond.fragment_id.as_str()),
9578                _ => None,
9579            })
9580            .expect("if reference");
9581        let boundary = records[if_fragment_id]
9582            .fragments
9583            .iter()
9584            .find_map(|fragment| match fragment.fragment.as_ref() {
9585                Some(Fragment::Boundary(boundary)) => Some(boundary),
9586                _ => None,
9587            })
9588            .expect("boundary remains behind the runtime if");
9589        assert_eq!(boundary.owner_fragment_id, "index.html");
9590        assert!(!boundary.may_repeat);
9591        assert!(records["index.html"].contains_boundary);
9592        assert!(records[if_fragment_id].contains_boundary);
9593        assert_stream!(
9594            records,
9595            if_fragment_id,
9596            [
9597                boundary_matcher("x", 0),
9598                raw("<p>x</p>"),
9599                boundary_end_matcher(0),
9600            ]
9601        );
9602    }
9603
9604    #[test]
9605    fn boundary_directly_inside_for_is_rejected() {
9606        let mut parser = HtmlParser::new();
9607        let err = parser
9608            .parse(
9609                "index.html",
9610                r#"<body><for each="item in items"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></for></body>"#,
9611            )
9612            .expect_err("a boundary inside a repeat must error");
9613        let ParserError::Template(diag) = err else {
9614            panic!("expected ParserError::Template, got {err:?}");
9615        };
9616        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9617        assert!(
9618            diag.help_text()
9619                .is_some_and(|help| help.contains("wrap the whole <for>")),
9620            "{diag}"
9621        );
9622    }
9623
9624    #[test]
9625    fn boundary_under_if_inside_for_is_rejected() {
9626        let mut parser = HtmlParser::new();
9627        let err = parser
9628            .parse(
9629                "index.html",
9630                r#"<body><for each="item in items"><if condition="item.ready"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></if></for></body>"#,
9631            )
9632            .expect_err("a boundary behind a runtime branch in a repeat must error");
9633        let ParserError::Template(diag) = err else {
9634            panic!("expected ParserError::Template, got {err:?}");
9635        };
9636        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9637    }
9638
9639    #[test]
9640    fn component_boundary_used_inside_for_is_rejected() {
9641        let mut parser = HtmlParser::new();
9642        parser
9643            .component_registry
9644            .register_component(ComponentRegistration::new(
9645                "row-item",
9646                r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9647                None,
9648                true,
9649            ))
9650            .expect("register");
9651        let err = parser
9652            .parse(
9653                "index.html",
9654                r#"<body><for each="item in items"><row-item></row-item></for></body>"#,
9655            )
9656            .expect_err("a component-local boundary reached from a repeat must error");
9657        let ParserError::Template(diag) = err else {
9658            panic!("expected ParserError::Template, got {err:?}");
9659        };
9660        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9661        // The diagnostic names both ends of the chain: the repeat that would
9662        // execute it and the template that declares it.
9663        assert!(diag.to_string().contains("row-ready"), "{diag}");
9664        assert_eq!(diag.component_name(), Some("index.html"));
9665        assert!(diag.to_string().contains("item in items"), "{diag}");
9666    }
9667
9668    #[test]
9669    fn transitive_repeat_boundary_diagnostic_uses_lowest_repeat_site() {
9670        for _ in 0..20 {
9671            let mut parser = HtmlParser::new();
9672            for (tag, template) in [
9673                (
9674                    "left-list",
9675                    r#"<for each="a in xs"><row-card></row-card></for>"#,
9676                ),
9677                (
9678                    "right-list",
9679                    r#"<for each="b in ys"><row-card></row-card></for>"#,
9680                ),
9681                (
9682                    "row-card",
9683                    r#"<boundary name="row-ready"><p>row</p></boundary>"#,
9684                ),
9685            ] {
9686                parser
9687                    .component_registry
9688                    .register_component(ComponentRegistration::new(tag, template, None, true))
9689                    .expect("register");
9690            }
9691            let err = parser
9692                .parse(
9693                    "index.html",
9694                    r#"<body><left-list></left-list><right-list></right-list></body>"#,
9695                )
9696                .expect_err("both repeats reach the same boundary declaration");
9697            let ParserError::Template(diag) = err else {
9698                panic!("expected ParserError::Template, got {err:?}");
9699            };
9700            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9701            assert_eq!(diag.component_name(), Some("left-list"));
9702            assert!(diag.to_string().contains("a in xs"), "{diag}");
9703        }
9704    }
9705
9706    #[test]
9707    fn route_boundary_reached_from_for_is_rejected() {
9708        let mut parser = HtmlParser::with_options(DomStrategy::Light);
9709        parser
9710            .component_registry
9711            .register_component(ComponentRegistration::new(
9712                "row-page",
9713                r#"<boundary name="row-ready"><p>row</p></boundary>"#,
9714                None,
9715                true,
9716            ))
9717            .expect("register");
9718        let err = parser
9719            .parse(
9720                "index.html",
9721                r#"<body><for each="item in items"><route path="/" component="row-page"></route></for></body>"#,
9722            )
9723            .expect_err("a route-mounted boundary inside a repeat must error");
9724        let ParserError::Template(diag) = err else {
9725            panic!("expected ParserError::Template, got {err:?}");
9726        };
9727        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9728    }
9729
9730    #[test]
9731    fn outlet_mounted_boundary_reached_from_for_is_rejected() {
9732        // The child route renders at the parent component's <outlet />, which
9733        // the repeat body reaches through <shell-page>. That edge is a runtime
9734        // mount with no lexical nesting, so only the mount analysis catches it.
9735        let mut parser = HtmlParser::with_options(DomStrategy::Light);
9736        for (tag, template) in [
9737            (
9738                "shell-page",
9739                r#"<div><for each="item in items"><outlet></outlet></for></div>"#,
9740            ),
9741            (
9742                "child-page",
9743                r#"<boundary name="child-ready"><p>child</p></boundary>"#,
9744            ),
9745        ] {
9746            parser
9747                .component_registry
9748                .register_component(ComponentRegistration::new(tag, template, None, true))
9749                .expect("register");
9750        }
9751        let err = parser
9752            .parse(
9753                "index.html",
9754                r#"<body><route path="/" component="shell-page"><route path="child" component="child-page"></route></route></body>"#,
9755            )
9756            .expect_err("an outlet-mounted boundary inside a repeat must error");
9757        let ParserError::Template(diag) = err else {
9758            panic!("expected ParserError::Template, got {err:?}");
9759        };
9760        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9761        assert!(diag.to_string().contains("child-ready"), "{diag}");
9762    }
9763
9764    #[test]
9765    fn for_inside_one_boundary_is_allowed_and_stays_atomic() {
9766        // The whole finite list is one atomic checkpoint, so the repeat body
9767        // record must not be marked as carrying a boundary of its own.
9768        let mut parser = HtmlParser::new();
9769        parser
9770            .parse(
9771                "index.html",
9772                r#"<body><boundary name="feed"><for each="item in items"><p>{{item.label}}</p></for></boundary></body>"#,
9773            )
9774            .expect("a repeat inside one boundary parses");
9775        let records = parser.into_fragment_records();
9776        assert!(records["index.html"].contains_boundary);
9777        assert!(
9778            !records["for-1"].contains_boundary,
9779            "the repeat body itself declares no boundary"
9780        );
9781        let boundary = records["index.html"]
9782            .fragments
9783            .iter()
9784            .find_map(|fragment| match fragment.fragment.as_ref() {
9785                Some(Fragment::Boundary(boundary)) => Some(boundary),
9786                _ => None,
9787            })
9788            .expect("boundary");
9789        assert!(!boundary.may_repeat);
9790    }
9791
9792    #[test]
9793    fn boundary_after_for_is_allowed() {
9794        let mut parser = HtmlParser::new();
9795        parser
9796            .parse(
9797                "index.html",
9798                r#"<body><for each="item in items"><p>{{item.label}}</p></for><boundary name="tail"><p>tail</p></boundary></body>"#,
9799            )
9800            .expect("a boundary following a repeat parses");
9801        let records = parser.into_fragment_records();
9802        assert!(records["index.html"].contains_boundary);
9803        assert!(!records["for-1"].contains_boundary);
9804    }
9805
9806    #[test]
9807    fn boundary_free_for_still_renders_without_boundary_metadata() {
9808        let mut parser = HtmlParser::new();
9809        parser
9810            .parse(
9811                "index.html",
9812                r#"<body><for each="item in items"><p>{{item.label}}</p></for></body>"#,
9813            )
9814            .expect("an ordinary repeat parses");
9815        let records = parser.into_fragment_records();
9816        assert!(!records["index.html"].contains_boundary);
9817        assert!(!records["for-1"].contains_boundary);
9818    }
9819
9820    #[test]
9821    fn empty_boundary_key_is_still_rejected() {
9822        let mut parser = HtmlParser::new();
9823        let err = parser
9824            .parse(
9825                "invalid.html",
9826                r#"<body><boundary name="x" key=""><p>x</p></boundary></body>"#,
9827            )
9828            .expect_err("an empty key must error");
9829        let ParserError::Template(diag) = err else {
9830            panic!("expected ParserError::Template, got {err:?}");
9831        };
9832        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_KEY));
9833    }
9834
9835    #[test]
9836    fn boundary_inside_reusable_component_is_component_local() {
9837        let mut parser = HtmlParser::new();
9838        parser
9839            .component_registry
9840            .register_component(ComponentRegistration::new(
9841                "my-widget",
9842                r#"<boundary name="x"><p>x</p></boundary>"#,
9843                None,
9844                true,
9845            ))
9846            .expect("register");
9847
9848        parser
9849            .parse(
9850                "index.html",
9851                r#"<body><my-widget></my-widget><boundary name="x"><p>entry</p></boundary></body>"#,
9852            )
9853            .expect("component-local boundary parses");
9854        let records = parser.into_fragment_records();
9855        let boundary = records["my-widget"]
9856            .fragments
9857            .iter()
9858            .find_map(|fragment| match fragment.fragment.as_ref() {
9859                Some(Fragment::Boundary(boundary)) => Some(boundary),
9860                _ => None,
9861            })
9862            .expect("component boundary");
9863        assert_eq!(boundary.owner_fragment_id, "my-widget");
9864        assert_eq!(boundary.name, "x");
9865        assert!(records["my-widget"].contains_boundary);
9866        assert!(records["index.html"].contains_boundary);
9867        let owners: HashSet<&str> = records
9868            .values()
9869            .flat_map(|list| list.fragments.iter())
9870            .filter_map(|fragment| match fragment.fragment.as_ref() {
9871                Some(Fragment::Boundary(boundary)) if boundary.name == "x" => {
9872                    Some(boundary.owner_fragment_id.as_str())
9873                }
9874                _ => None,
9875            })
9876            .collect();
9877        assert_eq!(owners, HashSet::from(["index.html", "my-widget"]));
9878    }
9879
9880    #[test]
9881    fn component_boundary_repeated_from_static_callsites_requires_a_key() {
9882        // `<for>` can no longer produce multiple occurrences, but two static
9883        // callsites of the same boundary-bearing component still can, so the
9884        // declaration keeps its key requirement.
9885        let mut parser = HtmlParser::new();
9886        parser
9887            .component_registry
9888            .register_component(ComponentRegistration::new(
9889                "row-item",
9890                r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9891                None,
9892                true,
9893            ))
9894            .expect("register");
9895        let err = parser
9896            .parse(
9897                "index.html",
9898                r#"<body><row-item></row-item><row-item></row-item></body>"#,
9899            )
9900            .expect_err("a repeated declaration without a key must error");
9901        let ParserError::Template(diag) = err else {
9902            panic!("expected ParserError::Template, got {err:?}");
9903        };
9904        assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_KEY));
9905        assert_eq!(diag.component_name(), Some("row-item"));
9906
9907        let mut parser = HtmlParser::new();
9908        parser
9909            .component_registry
9910            .register_component(ComponentRegistration::new(
9911                "row-item",
9912                r#"<boundary name="row-ready" key="{{rowId}}"><span>{{label}}</span></boundary>"#,
9913                None,
9914                true,
9915            ))
9916            .expect("register");
9917        parser
9918            .parse(
9919                "index.html",
9920                r#"<body><row-item row-id="a"></row-item><row-item row-id="b"></row-item></body>"#,
9921            )
9922            .expect("a keyed repeated declaration parses");
9923        let records = parser.into_fragment_records();
9924        let boundary = records["row-item"]
9925            .fragments
9926            .iter()
9927            .find_map(|fragment| match fragment.fragment.as_ref() {
9928                Some(Fragment::Boundary(boundary)) => Some(boundary),
9929                _ => None,
9930            })
9931            .expect("component boundary");
9932        assert!(boundary.may_repeat);
9933        assert_eq!(boundary.key.as_deref(), Some("{{rowId}}"));
9934    }
9935
9936    #[test]
9937    fn component_boundary_once_per_independent_entry_is_not_repeatable() {
9938        let mut parser = HtmlParser::new();
9939        parser
9940            .component_registry
9941            .register_component(ComponentRegistration::new(
9942                "shared-panel",
9943                r#"<boundary name="ready"><span>x</span></boundary>"#,
9944                None,
9945                true,
9946            ))
9947            .expect("register");
9948        for entry in ["first.html", "second.html"] {
9949            parser
9950                .parse(entry, "<body><shared-panel></shared-panel></body>")
9951                .expect("entry parses");
9952        }
9953        let records = parser.into_fragment_records();
9954        let boundary = records["shared-panel"]
9955            .fragments
9956            .iter()
9957            .find_map(|fragment| match fragment.fragment.as_ref() {
9958                Some(Fragment::Boundary(boundary)) => Some(boundary),
9959                _ => None,
9960            })
9961            .expect("component boundary");
9962        assert!(!boundary.may_repeat);
9963    }
9964
9965    #[test]
9966    fn boundary_inside_component_host_content_errors() {
9967        let mut parser = HtmlParser::new();
9968        parser
9969            .component_registry
9970            .register_component(ComponentRegistration::new(
9971                "my-widget",
9972                "<div>content</div>",
9973                None,
9974                true,
9975            ))
9976            .expect("register");
9977
9978        let err = parser
9979            .parse(
9980                "index.html",
9981                r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
9982            )
9983            .expect_err("a boundary inside component host content must error");
9984        let ParserError::Template(diag) = err else {
9985            panic!("expected ParserError::Template, got {err:?}");
9986        };
9987        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
9988        assert!(
9989            diag.to_string().contains("component host content"),
9990            "{diag}"
9991        );
9992    }
9993
9994    #[test]
9995    fn boundary_inside_raw_or_inert_html_context_errors() {
9996        for (element, expected_scope) in [
9997            ("textarea", "<textarea> text content"),
9998            ("title", "<title> text content"),
9999            ("script", "<script> raw-text content"),
10000            ("xmp", "<xmp> raw-text content"),
10001            ("iframe", "<iframe> raw-text content"),
10002            ("noembed", "<noembed> raw-text content"),
10003            ("noframes", "<noframes> raw-text content"),
10004            ("noscript", "<noscript> inert content"),
10005            ("plaintext", "<plaintext> text content"),
10006            ("template", "<template> inert content"),
10007        ] {
10008            let mut parser = HtmlParser::new();
10009            let html = format!(
10010                r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
10011            );
10012            let err = parser
10013                .parse("index.html", &html)
10014                .expect_err("a boundary in raw or inert content must error");
10015            let ParserError::Template(diag) = err else {
10016                panic!("expected ParserError::Template, got {err:?}");
10017            };
10018            assert_eq!(
10019                diag.error_code(),
10020                Some(codes::BOUNDARY_CROSSES_SCOPE),
10021                "unexpected code for <{element}>"
10022            );
10023            assert!(
10024                diag.to_string().contains(expected_scope),
10025                "unexpected scope for <{element}>: {diag}"
10026            );
10027        }
10028    }
10029
10030    #[test]
10031    fn route_contained_boundary_has_runtime_content_fragment() {
10032        let mut parser = HtmlParser::with_options(DomStrategy::Light);
10033        parser
10034            .component_registry
10035            .register_component(ComponentRegistration::new(
10036                "home-page",
10037                "<h1>Home</h1>",
10038                None,
10039                true,
10040            ))
10041            .expect("register");
10042        parser
10043            .parse(
10044                "index.html",
10045                r#"<route path="/" component="home-page"><boundary name="route-ready"><aside>Ready</aside></boundary></route>"#,
10046            )
10047            .expect("route-contained boundary parses");
10048        let records = parser.into_fragment_records();
10049        assert!(records["index.html"].contains_boundary);
10050        let route = records["index.html"]
10051            .fragments
10052            .iter()
10053            .find_map(|fragment| match fragment.fragment.as_ref() {
10054                Some(Fragment::Route(route)) => Some(route),
10055                _ => None,
10056            })
10057            .expect("route");
10058        assert!(!route.content_fragment_id.is_empty());
10059        let boundary = records[&route.content_fragment_id]
10060            .fragments
10061            .iter()
10062            .find_map(|fragment| match fragment.fragment.as_ref() {
10063                Some(Fragment::Boundary(boundary)) => Some(boundary),
10064                _ => None,
10065            })
10066            .expect("route boundary");
10067        assert_eq!(boundary.owner_fragment_id, "index.html");
10068        assert_eq!(boundary.name, "route-ready");
10069        assert_stream!(
10070            records,
10071            route.content_fragment_id.as_str(),
10072            [
10073                boundary_matcher("route-ready", 0),
10074                raw("<aside>Ready</aside>"),
10075                boundary_end_matcher(0),
10076            ]
10077        );
10078    }
10079
10080    #[test]
10081    fn route_boundary_nested_in_ignored_markup_errors() {
10082        let mut parser = HtmlParser::new();
10083        let err = parser
10084            .parse(
10085                "index.html",
10086                r#"<route path="/" component="home-page"><div><boundary name="late"><p>x</p></boundary></div></route>"#,
10087            )
10088            .expect_err("route boundaries must be direct children");
10089        let ParserError::Template(diag) = err else {
10090            panic!("expected ParserError::Template, got {err:?}");
10091        };
10092        assert_eq!(
10093            diag.error_code(),
10094            Some(codes::INVALID_ROUTE_BOUNDARY_PLACEMENT)
10095        );
10096    }
10097
10098    #[test]
10099    fn boundary_must_be_inside_an_open_body() {
10100        for html in [
10101            r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
10102            r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
10103        ] {
10104            let mut parser = HtmlParser::new();
10105            let err = parser
10106                .parse("index.html", html)
10107                .expect_err("a boundary outside body must error");
10108            let ParserError::Template(diag) = err else {
10109                panic!("expected ParserError::Template, got {err:?}");
10110            };
10111            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
10112            assert_eq!(
10113                diag.help_text(),
10114                Some("move the <boundary> between the opening <body> and its matching </body>")
10115            );
10116        }
10117
10118        let mut parser = HtmlParser::new();
10119        parser
10120            .parse(
10121                "index.html",
10122                r#"<BODY><boundary name="inside"></boundary></BODY>"#,
10123            )
10124            .expect("mixed-case native body keeps the boundary inside an open body");
10125    }
10126
10127    #[test]
10128    fn boundary_inside_foster_parenting_context_errors() {
10129        // Each of these insertion modes moves an unknown element (the
10130        // generated <webui-hydrate> sentinel) out of the subtree, which would
10131        // split it from its payload script and halt hydration at runtime.
10132        for html in [
10133            r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
10134            r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
10135            r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
10136            r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
10137            r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
10138            r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
10139            r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
10140            r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
10141            // Mixed case must be rejected identically: HTML tag names are
10142            // case-insensitive, so the browser foster-parents these too.
10143            r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
10144        ] {
10145            let mut parser = HtmlParser::new();
10146            let err = parser
10147                .parse("index.html", html)
10148                .expect_err("a boundary in a foster-parenting context must error");
10149            let ParserError::Template(diag) = err else {
10150                panic!("expected ParserError::Template, got {err:?}");
10151            };
10152            assert_eq!(
10153                diag.error_code(),
10154                Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
10155                "unexpected code for {html}"
10156            );
10157            assert!(
10158                diag.help_text()
10159                    .is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
10160                "help should suggest a safe placement for {html}"
10161            );
10162        }
10163    }
10164
10165    #[test]
10166    fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
10167        // <td>/<th>/<caption> switch back to "in body" rules, so an unknown
10168        // element is *not* foster-parented and the boundary is safe even
10169        // though <table>/<tr> ancestors are hostile.
10170        for html in [
10171            r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
10172            r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
10173            r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
10174            // Wrapping the whole table is the other documented fix.
10175            r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
10176            // A <td> barrier resets the depth, an inner table raises it again,
10177            // and closing that inner table must restore the barrier's zero —
10178            // not the outer table's non-zero depth.
10179            concat!(
10180                "<body><table><tbody><tr><td>",
10181                "<table><tbody><tr><td>inner</td></tr></tbody></table>",
10182                r#"<boundary name="after-inner"><p>x</p></boundary>"#,
10183                "</td></tr></tbody></table></body>",
10184            ),
10185            // Void and self-closing elements must not leak restore ops.
10186            r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
10187        ] {
10188            let mut parser = HtmlParser::new();
10189            parser
10190                .parse("index.html", html)
10191                .unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
10192        }
10193    }
10194
10195    #[test]
10196    fn foster_context_depth_is_restored_after_leaving_a_table() {
10197        // Regression guard for the save/restore op: a boundary *after* a table
10198        // must not inherit the table's hostile depth.
10199        let mut parser = HtmlParser::new();
10200        parser
10201            .parse(
10202                "index.html",
10203                concat!(
10204                    "<body>",
10205                    "<table><tbody><tr><td>x</td></tr></tbody></table>",
10206                    r#"<boundary name="after-table"><p>y</p></boundary>"#,
10207                    "</body>",
10208                ),
10209            )
10210            .expect("a boundary after a closed table must be accepted");
10211    }
10212
10213    #[test]
10214    fn authored_raw_bindings_cannot_forge_structural_signals() {
10215        let html = concat!(
10216            "<html><head></head><body>",
10217            "{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
10218            "{{{boundary_start:0}}}{{{boundary_end:0}}}",
10219            "{{{streaming_root:forged}}}",
10220            "</body></html>",
10221        );
10222        let (fragments, _) = parse_and_get_fragments(html);
10223        let raw_signals: Vec<&str> = fragments
10224            .iter()
10225            .filter_map(|fragment| match fragment.fragment.as_ref() {
10226                Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
10227                _ => None,
10228            })
10229            .collect();
10230
10231        assert_eq!(
10232            raw_signals,
10233            [
10234                "}}}webui:head_start",
10235                "}}}webui:head_end",
10236                "}}}webui:body_start",
10237                "head_start",
10238                "head_end",
10239                "body_start",
10240                "body_end",
10241                "boundary_start:0",
10242                "boundary_end:0",
10243                "streaming_root:forged",
10244                "}}}webui:body_end",
10245            ]
10246        );
10247    }
10248
10249    #[test]
10250    fn authored_webui_hydrate_errors_at_top_level() {
10251        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
10252            let mut parser = HtmlParser::new();
10253            let html = format!("<div><{tag}></{tag}></div>");
10254            let err = parser
10255                .parse("index.html", &html)
10256                .expect_err("an authored <webui-hydrate> must error in any ASCII casing");
10257            let ParserError::Template(diag) = err else {
10258                panic!("expected ParserError::Template, got {err:?}");
10259            };
10260            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
10261        }
10262    }
10263
10264    #[test]
10265    fn authored_webui_hydrate_errors_inside_route() {
10266        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
10267            let mut parser = HtmlParser::new();
10268            let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
10269            let err = parser
10270                .parse("index.html", &html)
10271                .expect_err("an authored <webui-hydrate> inside <route> must error");
10272            let ParserError::Template(diag) = err else {
10273                panic!("expected ParserError::Template, got {err:?}");
10274            };
10275            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
10276        }
10277    }
10278
10279    #[test]
10280    fn boundary_directive_remains_case_sensitive() {
10281        let (fragments, _) =
10282            parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
10283
10284        assert_fragments!(
10285            fragments,
10286            [raw(
10287                r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
10288            ),]
10289        );
10290    }
10291
10292    #[test]
10293    fn templates_without_webui_boundary_are_unaffected() {
10294        // Regression: ordinary templates with no `<boundary>` directive
10295        // must parse exactly as before — no boundary signals, no behavior
10296        // change for the non-streaming path.
10297        let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
10298        let (fragments, _) = parse_and_get_fragments(html);
10299
10300        assert_fragments!(
10301            fragments,
10302            [
10303                raw("<div><my-counter"),
10304                attr("count", "count"),
10305                raw("></my-counter></div>"),
10306            ]
10307        );
10308    }
10309
10310    #[test]
10311    fn test_fail_with_invalid_markup() {
10312        // Port of: 'should fail with invalid markup'
10313        let mut parser = HtmlParser::new();
10314        let result = parser.parse("index.html", "<div><span></div></span>");
10315
10316        assert!(matches!(result, Err(ParserError::Template(diag)) if
10317            diag.to_string().contains("unclosed <span> tag")
10318        ));
10319    }
10320
10321    #[test]
10322    fn test_complex_raw_text_page() {
10323        // Port of: 'should process a complex raw text page with DOCTYPE,
10324        // meta tags, styles, and scripts'
10325        let html = concat!(
10326            "<!DOCTYPE html>",
10327            "<html lang=\"en\">",
10328            "<head>",
10329            "<meta charset=\"utf-8\">",
10330            "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
10331            "<title>Complex Page</title>",
10332            "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
10333            "<link rel=\"stylesheet\" href=\"styles.css\">",
10334            "</head>",
10335            "<body>",
10336            "<h1>Hello World</h1>",
10337            "<script type=\"module\" src=\"./app.js\"></script>",
10338            "</body>",
10339            "</html>",
10340        );
10341        let (fragments, _) = parse_and_get_fragments(html);
10342
10343        // Should have: raw(DOCTYPE+<head>), head_start, raw(head content),
10344        // head_end, raw(</head><body>), body_start, raw(body content),
10345        // body_end, raw(</body></html>)
10346        assert!(
10347            fragments.len() >= 9,
10348            "Expected at least 9 fragments, got {}",
10349            fragments.len()
10350        );
10351
10352        // First fragment: DOCTYPE through the opening <head> tag
10353        assert!(
10354            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10355                raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
10356            "First fragment should contain DOCTYPE and end with <head>, got: {:?}",
10357            fragments[0]
10358        );
10359
10360        // head_start signal
10361        assert!(
10362            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
10363                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
10364            "Second fragment should be head_start signal"
10365        );
10366
10367        // Head content (meta, title, style, link)
10368        assert!(
10369            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10370                raw.value.contains("<meta charset=\"utf-8\">") &&
10371                raw.value.contains("<meta name=\"viewport\"") &&
10372                raw.value.contains("<title>Complex Page</title>") &&
10373                raw.value.contains("<style>") &&
10374                raw.value.contains("body { margin: 0; padding: 0; }")),
10375            "Third fragment should contain all head content, got: {:?}",
10376            fragments[2]
10377        );
10378
10379        // head_end signal
10380        assert!(
10381            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
10382                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
10383            "Fourth fragment should be head_end signal"
10384        );
10385
10386        // </head><body>
10387        assert!(
10388            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10389                raw.value.contains("</head>") && raw.value.ends_with("<body>")),
10390            "Fifth fragment should contain </head><body>"
10391        );
10392
10393        // body_start signal
10394        assert!(
10395            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
10396                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
10397            "Sixth fragment should be body_start signal"
10398        );
10399
10400        // Body content (h1 and script)
10401        assert!(
10402            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10403                raw.value.contains("<h1>Hello World</h1>") &&
10404                raw.value.contains("<script")),
10405            "Seventh fragment should contain body content"
10406        );
10407
10408        // body_end signal
10409        assert!(
10410            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
10411                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
10412            "Eighth fragment should be body_end signal"
10413        );
10414
10415        // Closing tags
10416        assert!(
10417            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10418                raw.value.contains("</body>") && raw.value.contains("</html>")),
10419            "Ninth fragment should contain closing tags"
10420        );
10421    }
10422
10423    // --- Binding count tests (with mock plugin) ---
10424
10425    /// Mock plugin that records the binding attribute count for each element.
10426    struct BindingCountPlugin {
10427        counts: Vec<u32>,
10428    }
10429
10430    impl BindingCountPlugin {
10431        fn new() -> Self {
10432            Self { counts: Vec::new() }
10433        }
10434    }
10435
10436    impl crate::plugin::ParserPlugin for BindingCountPlugin {
10437        fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
10438            let attr_name = context.name;
10439            if attr_name.starts_with('@') || attr_name == "f-ref" {
10440                AttributeAction::SkipAndCountBinding
10441            } else {
10442                AttributeAction::Keep
10443            }
10444        }
10445
10446        fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
10447            let binding_attribute_count = context.binding_count;
10448            self.counts.push(binding_attribute_count);
10449            if binding_attribute_count > 0 {
10450                Some(binding_attribute_count.to_le_bytes().to_vec())
10451            } else {
10452                None
10453            }
10454        }
10455    }
10456
10457    struct TemplateCapturePlugin {
10458        template: Option<String>,
10459        uses_shadow_dom: bool,
10460    }
10461
10462    impl TemplateCapturePlugin {
10463        fn new() -> Self {
10464            Self {
10465                template: None,
10466                uses_shadow_dom: false,
10467            }
10468        }
10469    }
10470
10471    impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
10472        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
10473            self.template = Some(context.template.to_string());
10474            self.uses_shadow_dom = context.uses_shadow_dom;
10475            Ok(())
10476        }
10477
10478        fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
10479            let attr_name = context.name;
10480            if attr_name.starts_with('@') || attr_name == "f-ref" {
10481                AttributeAction::SkipAndCountBinding
10482            } else {
10483                AttributeAction::Keep
10484            }
10485        }
10486
10487        fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
10488            match self.template {
10489                Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
10490                    crate::plugin::ComponentTemplateArtifact::template(
10491                        "todo-app".to_string(),
10492                        template,
10493                        self.uses_shadow_dom,
10494                    ),
10495                ])),
10496                None => Ok(ParserPluginArtifacts::None),
10497            }
10498        }
10499    }
10500
10501    #[test]
10502    fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
10503        let mut parser = HtmlParser::with_plugin_options(
10504            Box::new(TemplateCapturePlugin::new()),
10505            CssStrategy::Link,
10506        );
10507        parser
10508            .component_registry_mut()
10509            .register_component(ComponentRegistration::new(
10510                "todo-app",
10511                r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
10512                Some(":host { display: block; }"),
10513             true,))
10514            .expect("register todo-app");
10515
10516        parser
10517            .parse("index.html", "<todo-app></todo-app>")
10518            .expect("parse failed");
10519
10520        let artifacts = parser.take_plugin_artifacts().expect("artifacts");
10521        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10522            panic!("expected captured component template");
10523        };
10524        let template = &templates[0].template;
10525
10526        assert!(
10527            template.contains(r#"shadowrootmode="open""#),
10528            "parser must pass dev-authored root template attrs to plugins, got: {template}"
10529        );
10530        assert!(
10531            template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
10532            "parser must pass dev-authored root event attrs to plugins, got: {template}"
10533        );
10534        assert!(
10535            template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
10536            "parser must pass dev-authored root event attrs to plugins, got: {template}"
10537        );
10538        assert!(
10539            template.contains(r#"f-ref="{root}""#),
10540            "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
10541        );
10542        assert!(
10543            !template.contains("shadowrootadoptedstylesheets"),
10544            "link CSS must not add CSS module adoption attrs, got: {template}"
10545        );
10546    }
10547
10548    #[test]
10549    fn test_component_static_attrs_not_counted_as_bindings() {
10550        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10551        parser
10552            .component_registry
10553            .register_component(ComponentRegistration::new(
10554                "my-btn",
10555                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10556                None,
10557                true,
10558            ))
10559            .expect("register");
10560
10561        // All attributes are static — binding count should be 0
10562        let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
10563        let result = parser.parse("index.html", html);
10564        assert!(result.is_ok());
10565
10566        let records = parser.into_fragment_records();
10567
10568        // No Plugin fragment should appear (binding count = 0)
10569        let frags = &records["index.html"].fragments;
10570        let plugin_count = frags
10571            .iter()
10572            .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
10573            .count();
10574        assert_eq!(
10575            plugin_count, 0,
10576            "Static-only component attributes should not emit a Plugin fragment"
10577        );
10578    }
10579
10580    #[test]
10581    fn test_component_dynamic_attr_counted_as_binding() {
10582        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10583        parser
10584            .component_registry
10585            .register_component(ComponentRegistration::new(
10586                "my-btn",
10587                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10588                None,
10589                true,
10590            ))
10591            .expect("register");
10592
10593        // One dynamic attribute ({{...}}) — binding count should be 1
10594        let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
10595        let result = parser.parse("index.html", html);
10596        assert!(result.is_ok());
10597
10598        let records = parser.into_fragment_records();
10599        let frags = &records["index.html"].fragments;
10600
10601        // Exactly one Plugin fragment with count = 1
10602        let plugin_frags: Vec<_> = frags
10603            .iter()
10604            .filter_map(|f| match f.fragment.as_ref() {
10605                Some(Fragment::Plugin(p)) => Some(&p.data),
10606                _ => None,
10607            })
10608            .collect();
10609        assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
10610        let count = u32::from_le_bytes([
10611            plugin_frags[0][0],
10612            plugin_frags[0][1],
10613            plugin_frags[0][2],
10614            plugin_frags[0][3],
10615        ]);
10616        assert_eq!(count, 1, "Binding attribute count should be 1");
10617    }
10618
10619    #[test]
10620    fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
10621        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10622        parser
10623            .component_registry
10624            .register_component(ComponentRegistration::new(
10625                "my-btn",
10626                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10627                None,
10628                true,
10629            ))
10630            .expect("register");
10631
10632        // 2 static, 1 dynamic, 1 skipped-with-plugin (@click) — only dynamic + skipped counted
10633        let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
10634        let result = parser.parse("index.html", html);
10635        assert!(result.is_ok());
10636
10637        let records = parser.into_fragment_records();
10638        let frags = &records["index.html"].fragments;
10639
10640        let plugin_frags: Vec<_> = frags
10641            .iter()
10642            .filter_map(|f| match f.fragment.as_ref() {
10643                Some(Fragment::Plugin(p)) => Some(&p.data),
10644                _ => None,
10645            })
10646            .collect();
10647        assert_eq!(plugin_frags.len(), 1);
10648        let count = u32::from_le_bytes([
10649            plugin_frags[0][0],
10650            plugin_frags[0][1],
10651            plugin_frags[0][2],
10652            plugin_frags[0][3],
10653        ]);
10654        // 1 dynamic (appearance) + 1 skipped-but-counted (@click) = 2
10655        assert_eq!(
10656            count, 2,
10657            "Binding count should include dynamic + plugin-skipped attrs, not static"
10658        );
10659    }
10660
10661    #[test]
10662    fn test_component_only_skipped_plugin_attrs_counted() {
10663        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10664        parser
10665            .component_registry
10666            .register_component(ComponentRegistration::new(
10667                "my-btn",
10668                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10669                None,
10670                true,
10671            ))
10672            .expect("register");
10673
10674        // Only plugin-skipped attrs (@click, f-ref) plus static — only skipped counted
10675        let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
10676        let result = parser.parse("index.html", html);
10677        assert!(result.is_ok());
10678
10679        let records = parser.into_fragment_records();
10680        let frags = &records["index.html"].fragments;
10681
10682        let plugin_frags: Vec<_> = frags
10683            .iter()
10684            .filter_map(|f| match f.fragment.as_ref() {
10685                Some(Fragment::Plugin(p)) => Some(&p.data),
10686                _ => None,
10687            })
10688            .collect();
10689        assert_eq!(plugin_frags.len(), 1);
10690        let count = u32::from_le_bytes([
10691            plugin_frags[0][0],
10692            plugin_frags[0][1],
10693            plugin_frags[0][2],
10694            plugin_frags[0][3],
10695        ]);
10696        // @click + f-ref = 2, title is static = not counted
10697        assert_eq!(
10698            count, 2,
10699            "Only plugin-skipped attrs should be counted, not static title"
10700        );
10701    }
10702
10703    // ── Comment stripping tests ──────────────────────────────────────
10704
10705    #[test]
10706    fn test_comment_handlebars_signal_is_stripped() {
10707        let mut parser = HtmlParser::new();
10708        let html = "<!--{{tokens}}-->";
10709        parser.parse("test.html", html).expect("parse failed");
10710        let records = parser.into_fragment_records();
10711
10712        assert_stream!(records, "test.html", []);
10713    }
10714
10715    #[test]
10716    fn test_comment_triple_brace_raw_signal_is_stripped() {
10717        let mut parser = HtmlParser::new();
10718        let html = "<!--{{{tokens}}}-->";
10719        parser.parse("test.html", html).expect("parse failed");
10720        let records = parser.into_fragment_records();
10721
10722        assert_stream!(records, "test.html", []);
10723    }
10724
10725    #[test]
10726    fn test_comment_regular_stripped() {
10727        let mut parser = HtmlParser::new();
10728        let html = "<!-- regular comment -->";
10729        parser.parse("test.html", html).expect("parse failed");
10730        let records = parser.into_fragment_records();
10731
10732        assert_stream!(records, "test.html", []);
10733    }
10734
10735    #[test]
10736    fn test_comment_dotted_signal_is_stripped() {
10737        let mut parser = HtmlParser::new();
10738        let html = "<!--{{tokens.light}}-->";
10739        parser.parse("test.html", html).expect("parse failed");
10740        let records = parser.into_fragment_records();
10741
10742        assert_stream!(records, "test.html", []);
10743    }
10744
10745    #[test]
10746    fn test_comment_arbitrary_identifier_is_stripped() {
10747        let mut parser = HtmlParser::new();
10748        let html = "<!--{{someOtherBinding}}-->";
10749        parser.parse("test.html", html).expect("parse failed");
10750        let records = parser.into_fragment_records();
10751
10752        assert_stream!(records, "test.html", []);
10753    }
10754
10755    #[test]
10756    fn test_comment_with_surrounding_content_is_stripped() {
10757        let mut parser = HtmlParser::new();
10758        let html = "<div><!--{{tokens}}--></div>";
10759        parser.parse("test.html", html).expect("parse failed");
10760        let records = parser.into_fragment_records();
10761
10762        assert_stream!(records, "test.html", [raw("<div></div>")]);
10763    }
10764
10765    #[test]
10766    fn test_component_template_unterminated_comment_returns_error() {
10767        let mut parser = HtmlParser::new();
10768        parser
10769            .component_registry_mut()
10770            .register_component(ComponentRegistration::new(
10771                "x-bad",
10772                "<span>ok</span><!-- missing close",
10773                None,
10774                true,
10775            ))
10776            .expect("register failed");
10777
10778        let result = parser.parse("index.html", "<x-bad></x-bad>");
10779
10780        assert!(matches!(result, Err(ParserError::Template(diag)) if
10781            diag.to_string().contains("unterminated HTML comment")
10782        ));
10783    }
10784
10785    #[test]
10786    fn test_webui_plugin_strips_component_comments_before_metadata() {
10787        let mut parser =
10788            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10789        parser
10790            .component_registry_mut()
10791            .register_component(ComponentRegistration::new(
10792                "x-bleed",
10793                r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
10794                None,
10795                true,
10796            ))
10797            .expect("register failed");
10798
10799        parser
10800            .parse("index.html", "<x-bleed></x-bleed>")
10801            .expect("parse failed");
10802
10803        let artifacts = parser.take_plugin_artifacts().unwrap();
10804        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10805            panic!("expected component templates");
10806        };
10807        let template = &templates[0].template_json;
10808        assert!(template.contains("<div>hello</div>"));
10809        assert!(!template.contains("{{path}}"));
10810        assert!(!template.contains("@click"));
10811        assert!(!template.contains("?hidden"));
10812        assert!(!template.contains("-->"));
10813    }
10814
10815    #[test]
10816    fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
10817        let mut parser =
10818            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10819        parser
10820            .component_registry_mut()
10821            .register_component(ComponentRegistration::new(
10822                "x-style",
10823                "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
10824                None,
10825                true,
10826            ))
10827            .expect("register failed");
10828
10829        parser
10830            .parse("index.html", "<x-style></x-style>")
10831            .expect("parse failed");
10832
10833        let artifacts = parser.take_plugin_artifacts().unwrap();
10834        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10835            panic!("expected component templates");
10836        };
10837        let template = &templates[0].template_json;
10838        assert!(template.contains(".x { color: red; }"));
10839        assert!(!template.contains("ignored"));
10840        assert!(!template.contains("//"));
10841    }
10842
10843    // ── Token collection tests ───────────────────────────────────────
10844
10845    #[test]
10846    fn test_tokens_from_style_tag() {
10847        let mut parser = HtmlParser::new();
10848        let html = r#"<style>
10849            .btn { color: var(--colorPrimary); background: var(--bgColor); }
10850        </style>"#;
10851        parser.parse("test.html", html).expect("parse failed");
10852        let tokens = parser.take_tokens();
10853
10854        assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
10855    }
10856
10857    #[test]
10858    fn test_tokens_from_malformed_style_error_on_unclosed_var() {
10859        let mut parser = HtmlParser::new();
10860        let html = r#"<style>
10861            .bad { color: var(--dangling; }
10862            .ok { color: var(--valid); }
10863        </style>"#;
10864        let result = parser.parse("test.html", html);
10865
10866        assert!(matches!(result, Err(ParserError::Template(diag)) if
10867            diag.error_code() == Some(codes::INVALID_CSS)
10868                && diag.to_string().contains("Unterminated CSS var() call")
10869        ));
10870    }
10871
10872    #[test]
10873    fn test_tokens_from_component_css() {
10874        let mut parser = HtmlParser::new();
10875        parser
10876            .component_registry_mut()
10877            .register_component(ComponentRegistration::new(
10878                "my-button",
10879                "<button>Click</button>",
10880                Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
10881                true,
10882            ))
10883            .expect("register failed");
10884
10885        let html = "<my-button></my-button>";
10886        parser.parse("test.html", html).expect("parse failed");
10887        let tokens = parser.take_tokens();
10888
10889        assert_eq!(tokens, vec!["borderWidth", "textColor"]);
10890    }
10891
10892    #[test]
10893    fn test_token_requirements_preserve_fallback_chains() {
10894        let mut parser = HtmlParser::new();
10895        parser
10896            .component_registry_mut()
10897            .register_component(ComponentRegistration::new(
10898                "my-card",
10899                "<div>Card</div>",
10900                Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
10901                true,
10902            ))
10903            .expect("register failed");
10904
10905        parser
10906            .parse("test.html", "<my-card></my-card>")
10907            .expect("parse failed");
10908        let analysis = parser.token_analysis();
10909
10910        assert_eq!(analysis.fallback_chains.len(), 1);
10911        assert_eq!(
10912            analysis.fallback_chains[0].tokens,
10913            vec!["token-a", "token-b", "token-c"]
10914        );
10915        assert!(!analysis.fallback_chains[0].has_literal_fallback);
10916    }
10917
10918    #[test]
10919    fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
10920        let mut parser = HtmlParser::new();
10921        parser
10922            .component_registry_mut()
10923            .register_component(ComponentRegistration::new(
10924                "my-card",
10925                "<div>Card</div>",
10926                Some(
10927                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10928                ),
10929             true,))
10930            .expect("register failed");
10931
10932        parser
10933            .parse("test.html", "<my-card></my-card>")
10934            .expect("parse failed");
10935        let analysis = parser.token_analysis();
10936
10937        assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
10938        assert_eq!(analysis.fallback_chains.len(), 1);
10939        assert_eq!(
10940            analysis.fallback_chains[0].tokens,
10941            vec!["token-b", "token-c"]
10942        );
10943    }
10944
10945    #[test]
10946    fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
10947        let mut parser = HtmlParser::new();
10948        parser
10949            .component_registry_mut()
10950            .register_component(ComponentRegistration::new(
10951                "my-card",
10952                "<div>Card</div>",
10953                Some(
10954                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10955                ),
10956             true,))
10957            .expect("register failed");
10958        parser
10959            .parse("test.html", "<my-card></my-card>")
10960            .expect("parse failed");
10961
10962        let analysis = parser.token_analysis();
10963        let theme = webui_tokens::TokenFile {
10964            themes: HashMap::from([(
10965                "light".to_string(),
10966                HashMap::from([("token-b".to_string(), "green".to_string())]),
10967            )]),
10968        };
10969
10970        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
10971            panic!("missing --token-c in the theme must fail parser validation");
10972        };
10973        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
10974        // The help is concise: a typo suggestion plus the local-definition
10975        // escape hatch — it does not restate "add --token-c to theme".
10976        let help = diagnostic.help_text().expect("help text");
10977        assert!(help.contains("did you mean --token-b?"), "help: {help}");
10978        assert!(help.contains("define it locally"), "help: {help}");
10979        assert!(!help.contains("add --token"), "help: {help}");
10980    }
10981
10982    #[test]
10983    fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
10984        let mut parser = HtmlParser::new();
10985        parser
10986            .component_registry_mut()
10987            .register_component(ComponentRegistration::new(
10988                "my-card",
10989                "<div>Card</div>",
10990                Some(":host { color: var(--brand, #000); }"),
10991                true,
10992            ))
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        // The token is still hoisted so the runtime resolves it when a theme
11000        // does provide it.
11001        assert_eq!(analysis.protocol_tokens, vec!["brand"]);
11002
11003        // A theme without `--brand` must NOT fail the build: the CSS literal
11004        // fallback (`#000`) already provides a value.
11005        let theme = webui_tokens::TokenFile {
11006            themes: HashMap::from([("light".to_string(), HashMap::new())]),
11007        };
11008        analysis
11009            .validate_theme_tokens(&theme)
11010            .expect("literal fallback must exempt --brand from theme validation");
11011    }
11012
11013    #[test]
11014    fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
11015        let mut parser = HtmlParser::new();
11016        parser
11017            .component_registry_mut()
11018            .register_component(ComponentRegistration::new(
11019                "my-card",
11020                "<div>Card</div>",
11021                // One usage has a literal fallback, the other does not. The
11022                // bare `var(--brand)` makes the token genuinely required.
11023                Some(":host { color: var(--brand, #000); background: var(--brand); }"),
11024                true,
11025            ))
11026            .expect("register failed");
11027        parser
11028            .parse("test.html", "<my-card></my-card>")
11029            .expect("parse failed");
11030
11031        let analysis = parser.token_analysis();
11032        let theme = webui_tokens::TokenFile {
11033            themes: HashMap::from([("light".to_string(), HashMap::new())]),
11034        };
11035
11036        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
11037            panic!("a bare var(--brand) usage must require --brand in the theme");
11038        };
11039        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
11040        assert!(diagnostic.to_string().contains("--brand"));
11041    }
11042
11043    #[test]
11044    fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
11045        let mut parser = HtmlParser::new();
11046        parser
11047            .component_registry_mut()
11048            .register_component(ComponentRegistration::new(
11049                "my-card",
11050                "<div>Card</div>",
11051                Some(
11052                    ":host { \
11053                       color: var(--colr-brand, #000); \
11054                       border: var(--present, 1px); \
11055                       margin: var(--required); \
11056                     }",
11057                ),
11058                true,
11059            ))
11060            .expect("register failed");
11061        parser
11062            .parse("test.html", "<my-card></my-card>")
11063            .expect("parse failed");
11064
11065        let analysis = parser.token_analysis();
11066        let theme = webui_tokens::TokenFile {
11067            themes: HashMap::from([(
11068                "light".to_string(),
11069                HashMap::from([
11070                    ("present".to_string(), "2px".to_string()),
11071                    ("required".to_string(), "8px".to_string()),
11072                ]),
11073            )]),
11074        };
11075
11076        // `colr-brand`: literal-only and absent from every theme → warned.
11077        // `present`: literal-only but defined in the theme → not warned.
11078        // `required`: has no literal fallback → a validation concern, not a warning.
11079        assert_eq!(
11080            analysis.unthemed_literal_fallback_tokens(&theme),
11081            vec!["colr-brand".to_string()]
11082        );
11083    }
11084
11085    #[test]
11086    fn test_theme_token_error_reports_location_and_suggestion() {
11087        let mut parser = HtmlParser::new();
11088        parser
11089            .component_registry_mut()
11090            .register_component(ComponentRegistration::new(
11091                "my-card",
11092                "<div>Card</div>",
11093                ":host {\n  color: var(--color-neutral-2000);\n}".into(),
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                "dark".to_string(),
11105                HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
11106            )]),
11107        };
11108
11109        let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
11110            panic!("missing --color-neutral-2000 must fail validation");
11111        };
11112        assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
11113        // File + line:column, like other authoring diagnostics.
11114        let location = diag.location().expect("a source location");
11115        assert!(
11116            location.contains("my-card.css:2:14"),
11117            "location: {location}"
11118        );
11119        // Snippet shows the offending CSS line.
11120        assert!(
11121            diag.snippet_text()
11122                .is_some_and(|s| s.contains("--color-neutral-2000")),
11123            "snippet: {:?}",
11124            diag.snippet_text()
11125        );
11126        // Did-you-mean from the theme's own tokens.
11127        assert!(
11128            diag.help_text()
11129                .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
11130            "help: {:?}",
11131            diag.help_text()
11132        );
11133    }
11134
11135    #[test]
11136    fn test_theme_token_warning_reports_location_and_suggestion() {
11137        let mut parser = HtmlParser::new();
11138        parser
11139            .component_registry_mut()
11140            .register_component(ComponentRegistration::new(
11141                "my-card",
11142                "<div>Card</div>",
11143                ":host {\n  color: var(--colr-brand, #000);\n}".into(),
11144                true,
11145            ))
11146            .expect("register failed");
11147        parser
11148            .parse("test.html", "<my-card></my-card>")
11149            .expect("parse failed");
11150
11151        let analysis = parser.token_analysis();
11152        let theme = webui_tokens::TokenFile {
11153            themes: HashMap::from([(
11154                "light".to_string(),
11155                HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
11156            )]),
11157        };
11158
11159        let warnings = analysis.theme_token_warnings(&theme);
11160        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
11161        let body = warnings[0].body();
11162        assert!(body.contains("my-card.css:2:"), "warning: {body}");
11163        assert!(body.contains("--colr-brand"), "warning: {body}");
11164        assert!(
11165            body.contains("did you mean --color-brand?"),
11166            "warning: {body}"
11167        );
11168    }
11169
11170    #[test]
11171    fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
11172        let mut parser = HtmlParser::new();
11173        let result =
11174            parser
11175                .component_registry_mut()
11176                .register_component(ComponentRegistration::new(
11177                    "my-card",
11178                    "<div>Card</div>",
11179                    Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
11180                    true,
11181                ));
11182
11183        assert!(matches!(result, Err(ParserError::Css(message)) if
11184            message.contains("Unterminated CSS var() call")
11185        ));
11186    }
11187
11188    #[test]
11189    fn test_tokens_merged_from_style_and_components() {
11190        let mut parser = HtmlParser::new();
11191        parser
11192            .component_registry_mut()
11193            .register_component(ComponentRegistration::new(
11194                "my-widget",
11195                "<div>Widget</div>",
11196                Some(".w { padding: var(--spacingM); }"),
11197                true,
11198            ))
11199            .expect("register failed");
11200
11201        let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
11202        parser.parse("test.html", html).expect("parse failed");
11203        let tokens = parser.take_tokens();
11204
11205        assert_eq!(tokens, vec!["spacingM", "textColor"]);
11206    }
11207
11208    #[test]
11209    fn test_tokens_deduplicated() {
11210        let mut parser = HtmlParser::new();
11211        parser
11212            .component_registry_mut()
11213            .register_component(ComponentRegistration::new(
11214                "my-btn",
11215                "<button>B</button>",
11216                Some(".b { color: var(--shared); }"),
11217                true,
11218            ))
11219            .expect("register failed");
11220
11221        let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
11222        parser.parse("test.html", html).expect("parse failed");
11223        let tokens = parser.take_tokens();
11224
11225        assert_eq!(tokens, vec!["shared"]);
11226    }
11227
11228    #[test]
11229    fn test_tokens_empty_when_no_vars() {
11230        let mut parser = HtmlParser::new();
11231        let html = "<div>Hello</div>";
11232        parser.parse("test.html", html).expect("parse failed");
11233        let tokens = parser.take_tokens();
11234
11235        assert!(tokens.is_empty());
11236    }
11237
11238    #[test]
11239    fn test_tokens_exclude_locally_defined_in_component() {
11240        let mut parser = HtmlParser::new();
11241        parser
11242            .component_registry_mut()
11243            .register_component(ComponentRegistration::new(
11244                "my-card",
11245                "<div>Card</div>",
11246                Some(":host { --local: 5px; width: var(--external); }"),
11247                true,
11248            ))
11249            .expect("register failed");
11250
11251        let html = "<my-card></my-card>";
11252        parser.parse("test.html", html).expect("parse failed");
11253        let tokens = parser.take_tokens();
11254
11255        assert_eq!(tokens, vec!["external"]);
11256    }
11257
11258    #[test]
11259    fn test_tokens_exclude_entry_root_definitions() {
11260        let mut parser = HtmlParser::new();
11261        parser
11262            .component_registry_mut()
11263            .register_component(ComponentRegistration::new(
11264                "my-btn",
11265                "<button>B</button>",
11266                Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
11267                true,
11268            ))
11269            .expect("register failed");
11270
11271        // Entry HTML defines --color-primary and --radius-m in :root
11272        // Components use them — they should NOT appear in hoisted tokens
11273        let html = r#"<style>
11274            :root {
11275                --color-primary: #0078d4;
11276                --radius-m: 6px;
11277            }
11278            body { color: var(--color-primary); }
11279        </style>
11280        <my-btn></my-btn>"#;
11281        parser.parse("test.html", html).expect("parse failed");
11282        let tokens = parser.take_tokens();
11283
11284        // Both tokens are defined in entry :root, so neither should be hoisted
11285        assert!(
11286            tokens.is_empty(),
11287            "Tokens defined in entry :root should be excluded: {tokens:?}"
11288        );
11289    }
11290
11291    #[test]
11292    fn test_tokens_entry_defs_exclude_but_external_kept() {
11293        let mut parser = HtmlParser::new();
11294        parser
11295            .component_registry_mut()
11296            .register_component(ComponentRegistration::new(
11297                "my-card",
11298                "<div>Card</div>",
11299                Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
11300                true,
11301            ))
11302            .expect("register failed");
11303
11304        // Entry defines --color-primary but NOT --external-spacing
11305        let html = r#"<style>
11306            :root { --color-primary: #0078d4; }
11307        </style>
11308        <my-card></my-card>"#;
11309        parser.parse("test.html", html).expect("parse failed");
11310        let tokens = parser.take_tokens();
11311
11312        // Only --external-spacing should be hoisted (not defined in entry)
11313        assert_eq!(tokens, vec!["external-spacing"]);
11314    }
11315
11316    // ── Route parsing tests ─────────────────────────────────────────────
11317
11318    #[test]
11319    fn test_parse_simple_route() {
11320        let mut parser = HtmlParser::new();
11321        let html = r#"<route path="/profile" component="profile-page" exact />"#;
11322        parser.parse("test.html", html).expect("parse failed");
11323
11324        // Routes are emitted as Fragment::Route
11325        let records = parser.into_fragment_records();
11326        let frags = &records["test.html"].fragments;
11327        assert_eq!(frags.len(), 1);
11328        match frags[0].fragment.as_ref() {
11329            Some(web_ui_fragment::Fragment::Route(r)) => {
11330                assert_eq!(r.path, "/profile");
11331                assert_eq!(r.fragment_id, "profile-page");
11332                assert!(r.exact);
11333            }
11334            other => panic!("expected Fragment::Route, got {:?}", other),
11335        }
11336    }
11337
11338    #[test]
11339    fn test_parse_route_with_params() {
11340        let mut parser = HtmlParser::new();
11341        let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
11342        parser.parse("test.html", html).expect("parse failed");
11343
11344        // Route is emitted as Fragment::Route with correct path
11345        let records = parser.into_fragment_records();
11346        let frags = &records["test.html"].fragments;
11347        match frags[0].fragment.as_ref() {
11348            Some(web_ui_fragment::Fragment::Route(r)) => {
11349                assert_eq!(r.path, "/profile/:id/view/:section");
11350            }
11351            other => panic!("expected Fragment::Route, got {:?}", other),
11352        }
11353    }
11354
11355    #[test]
11356    fn test_parse_route_requires_component() {
11357        let mut parser = HtmlParser::new();
11358        let html = r#"<route path="/old" />"#;
11359        let result = parser.parse("test.html", html);
11360        assert!(result.is_err());
11361    }
11362
11363    #[test]
11364    fn test_parse_multiple_routes() {
11365        let mut parser = HtmlParser::new();
11366        let html = r#"<route path="/" component="app-layout" />
11367            <route path="/dashboard" component="dash-page" exact />
11368            <route path="/contacts" component="contacts-page" exact />"#;
11369        parser.parse("test.html", html).expect("parse failed");
11370
11371        let records = parser.into_fragment_records();
11372        let frags = &records["test.html"].fragments;
11373        assert_eq!(frags.len(), 3);
11374        // All should be Fragment::Route
11375        for frag in frags {
11376            assert!(matches!(
11377                frag.fragment.as_ref(),
11378                Some(web_ui_fragment::Fragment::Route(_))
11379            ));
11380        }
11381    }
11382
11383    #[test]
11384    fn test_parse_route_requires_component_with_body() {
11385        let mut parser = HtmlParser::new();
11386        let html = r#"<route path="/error">
11387            <div class="error"><h1>Not Found</h1></div>
11388        </route>"#;
11389        let result = parser.parse("test.html", html);
11390        assert!(
11391            result.is_err(),
11392            "Route without component attribute should fail"
11393        );
11394    }
11395
11396    #[test]
11397    fn test_parse_multiple_routes_with_fragments() {
11398        let mut parser = HtmlParser::new();
11399        let html = r#"<route path="/" component="home-page" exact />
11400            <route path="/about" component="about-page" exact />
11401            <route path="/contact/:id" component="contact-page" />"#;
11402        parser.parse("test.html", html).expect("parse failed");
11403
11404        let records = parser.into_fragment_records();
11405        let frags = &records["test.html"].fragments;
11406        assert_eq!(frags.len(), 3);
11407
11408        // Verify individual routes
11409        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
11410            assert_eq!(r.path, "/");
11411        }
11412        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
11413            assert_eq!(r.path, "/about");
11414        }
11415        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
11416            assert_eq!(r.path, "/contact/:id");
11417        }
11418    }
11419
11420    #[test]
11421    fn test_parse_route_with_query_allowlist() {
11422        let mut parser = HtmlParser::new();
11423        let html =
11424            r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
11425        parser.parse("test.html", html).expect("parse failed");
11426
11427        let records = parser.into_fragment_records();
11428        let frags = &records["test.html"].fragments;
11429        assert_eq!(frags.len(), 1);
11430        match frags[0].fragment.as_ref() {
11431            Some(web_ui_fragment::Fragment::Route(r)) => {
11432                assert_eq!(r.path, "/compose");
11433                assert_eq!(r.fragment_id, "compose-page");
11434                assert!(r.exact);
11435                assert_eq!(r.allowed_query, "action,to,subject");
11436            }
11437            other => panic!("expected Fragment::Route, got {:?}", other),
11438        }
11439    }
11440
11441    #[test]
11442    fn test_parse_route_without_query_has_empty_allowed_query() {
11443        let mut parser = HtmlParser::new();
11444        let html = r#"<route path="/profile" component="profile-page" exact />"#;
11445        parser.parse("test.html", html).expect("parse failed");
11446
11447        let records = parser.into_fragment_records();
11448        let frags = &records["test.html"].fragments;
11449        match frags[0].fragment.as_ref() {
11450            Some(web_ui_fragment::Fragment::Route(r)) => {
11451                assert!(r.allowed_query.is_empty());
11452            }
11453            other => panic!("expected Fragment::Route, got {:?}", other),
11454        }
11455    }
11456
11457    #[test]
11458    fn test_route_body_rejects_malformed_non_route_html() {
11459        let mut parser = HtmlParser::new();
11460        let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
11461        let result = parser.parse("test.html", html);
11462
11463        assert!(matches!(result, Err(ParserError::Template(diag)) if
11464            diag.to_string().contains("unclosed <span> tag")
11465        ));
11466    }
11467
11468    #[test]
11469    fn test_route_body_rejects_malformed_style_css() {
11470        let mut parser = HtmlParser::new();
11471        let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
11472        let result = parser.parse("test.html", html);
11473
11474        assert!(matches!(result, Err(ParserError::Template(diag)) if
11475            diag.error_code() == Some(codes::INVALID_CSS)
11476                && diag.to_string().contains("Unterminated CSS var() call")
11477        ));
11478    }
11479
11480    #[test]
11481    fn test_outlet_not_captured_by_for_loop() {
11482        let mut parser = HtmlParser::new();
11483        let html = r#"<template shadowrootmode="open">
11484  <ul>
11485    <for each="item in items">
11486      <li>{{item.name}}</li>
11487    </for>
11488  </ul>
11489  <main>
11490    <outlet />
11491  </main>
11492</template>"#;
11493        parser.parse("comp.html", html).expect("parse failed");
11494
11495        let records = parser.into_fragment_records();
11496        let frags = &records["comp.html"].fragments;
11497
11498        // The outlet should be a top-level fragment, NOT inside the for-loop's body
11499        let outlet_count = frags
11500            .iter()
11501            .filter(|f| {
11502                matches!(
11503                    f.fragment.as_ref(),
11504                    Some(web_ui_fragment::Fragment::Outlet(_))
11505                )
11506            })
11507            .count();
11508        assert_eq!(
11509            outlet_count, 1,
11510            "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
11511        );
11512
11513        // Verify outlet comes AFTER the raw "</ul>" text
11514        let outlet_idx = frags
11515            .iter()
11516            .position(|f| {
11517                matches!(
11518                    f.fragment.as_ref(),
11519                    Some(web_ui_fragment::Fragment::Outlet(_))
11520                )
11521            })
11522            .expect("no outlet found");
11523        let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
11524            Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
11525            _ => false,
11526        });
11527        if let Some(ul_idx) = close_ul_idx {
11528            assert!(
11529                outlet_idx > ul_idx,
11530                "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
11531            );
11532        }
11533    }
11534
11535    #[test]
11536    fn test_outlet_position_after_for_not_inside() {
11537        let mut parser = HtmlParser::new();
11538        let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
11539        parser.parse("test.html", html).expect("parse failed");
11540
11541        let records = parser.into_fragment_records();
11542        let frags = &records["test.html"].fragments;
11543
11544        // Outlet should be its own fragment at top level
11545        let has_outlet = frags.iter().any(|f| {
11546            matches!(
11547                f.fragment.as_ref(),
11548                Some(web_ui_fragment::Fragment::Outlet(_))
11549            )
11550        });
11551        assert!(
11552            has_outlet,
11553            "outlet should be in top-level fragments: {frags:?}"
11554        );
11555
11556        // The for-loop body should NOT contain the outlet
11557        let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
11558            Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
11559            _ => None,
11560        });
11561        if let Some(id) = for_id {
11562            let for_frags = &records[&id].fragments;
11563            let outlet_in_for = for_frags.iter().any(|f| {
11564                matches!(
11565                    f.fragment.as_ref(),
11566                    Some(web_ui_fragment::Fragment::Outlet(_))
11567                )
11568            });
11569            assert!(
11570                !outlet_in_for,
11571                "outlet should NOT be inside for-loop body: {for_frags:?}"
11572            );
11573        }
11574    }
11575
11576    #[test]
11577    fn multiple_outlets_in_one_fragment_warn() {
11578        let mut parser = HtmlParser::new();
11579        let html = r#"<main><outlet /></main><aside><outlet /></aside>"#;
11580        parser.parse("test.html", html).expect("parse failed");
11581
11582        let warnings = parser.take_warnings();
11583        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
11584        assert_eq!(warnings[0].severity(), Severity::Warning);
11585        assert_eq!(warnings[0].error_code(), Some(codes::MULTIPLE_OUTLETS));
11586        assert_eq!(warnings[0].component_name(), Some("test.html"));
11587        assert!(warnings[0].help_text().is_some());
11588    }
11589
11590    #[test]
11591    fn multiple_outlets_across_nested_directives_warn() {
11592        for html in [
11593            r#"<if condition="show"><outlet /></if><outlet />"#,
11594            r#"<for each="item in items"><outlet /></for><outlet />"#,
11595        ] {
11596            let mut parser = HtmlParser::new();
11597            parser.parse("test.html", html).expect("parse failed");
11598
11599            let warnings = parser.take_warnings();
11600            assert_eq!(warnings.len(), 1, "warnings for {html}: {warnings:?}");
11601            assert_eq!(warnings[0].error_code(), Some(codes::MULTIPLE_OUTLETS));
11602        }
11603    }
11604
11605    #[test]
11606    fn test_style_element_with_handlebars_signal_comment_emits_signal() {
11607        let mut parser = HtmlParser::new();
11608        let html = r#"<html><head><style>
11609:root {
11610    /*{{{tokens.light}}}*/
11611}
11612</style></head><body></body></html>"#;
11613
11614        let result = parser.parse("test.html", html);
11615        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11616        let fragment_records = parser.into_fragment_records();
11617
11618        assert_stream!(
11619            fragment_records,
11620            "test.html",
11621            [
11622                raw("<html><head>"),
11623                structural_matcher("head_start"),
11624                raw("<style>\n:root {\n    "),
11625                raw_text_signal("tokens.light", true),
11626                raw("\n}\n</style>"),
11627                structural_matcher("head_end"),
11628                raw("</head><body>"),
11629                structural_matcher("body_start"),
11630                structural_matcher("body_end"),
11631                raw("</body></html>"),
11632            ]
11633        );
11634    }
11635
11636    #[test]
11637    fn test_style_comment_signal_with_spaces_emits_signal() {
11638        let mut parser = HtmlParser::new();
11639        let html = r#"<html><head><style>
11640:root {
11641    /* {{{tokens.light}}} */
11642}
11643</style></head><body></body></html>"#;
11644
11645        let result = parser.parse("test.html", html);
11646        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11647        let fragment_records = parser.into_fragment_records();
11648
11649        assert_stream!(
11650            fragment_records,
11651            "test.html",
11652            [
11653                raw("<html><head>"),
11654                structural_matcher("head_start"),
11655                raw("<style>\n:root {\n    "),
11656                raw_text_signal("tokens.light", true),
11657                raw("\n}\n</style>"),
11658                structural_matcher("head_end"),
11659                raw("</head><body>"),
11660                structural_matcher("body_start"),
11661                structural_matcher("body_end"),
11662                raw("</body></html>"),
11663            ]
11664        );
11665    }
11666
11667    #[test]
11668    fn test_style_comment_signal_double_brace_emits_signal() {
11669        let mut parser = HtmlParser::new();
11670        let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
11671
11672        let result = parser.parse("test.html", html);
11673        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11674        let fragment_records = parser.into_fragment_records();
11675
11676        assert_stream!(
11677            fragment_records,
11678            "test.html",
11679            [
11680                raw("<html><head>"),
11681                structural_matcher("head_start"),
11682                raw("<style>"),
11683                raw_text_signal("themeCss", false),
11684                raw("</style>"),
11685                structural_matcher("head_end"),
11686                raw("</head><body>"),
11687                structural_matcher("body_start"),
11688                structural_matcher("body_end"),
11689                raw("</body></html>"),
11690            ]
11691        );
11692    }
11693
11694    #[test]
11695    fn test_style_comment_with_extra_text_is_stripped() {
11696        let mut parser = HtmlParser::new();
11697        let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
11698
11699        let result = parser.parse("test.html", html);
11700        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11701        let fragment_records = parser.into_fragment_records();
11702
11703        assert_stream!(
11704            fragment_records,
11705            "test.html",
11706            [
11707                raw("<html><head>"),
11708                structural_matcher("head_start"),
11709                raw("<style></style>"),
11710                structural_matcher("head_end"),
11711                raw("</head><body>"),
11712                structural_matcher("body_start"),
11713                structural_matcher("body_end"),
11714                raw("</body></html>"),
11715            ]
11716        );
11717    }
11718
11719    #[test]
11720    fn test_style_comment_with_multiple_signals_is_stripped() {
11721        let mut parser = HtmlParser::new();
11722        let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
11723
11724        let result = parser.parse("test.html", html);
11725        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11726        let fragment_records = parser.into_fragment_records();
11727
11728        assert_stream!(
11729            fragment_records,
11730            "test.html",
11731            [
11732                raw("<html><head>"),
11733                structural_matcher("head_start"),
11734                raw("<style></style>"),
11735                structural_matcher("head_end"),
11736                raw("</head><body>"),
11737                structural_matcher("body_start"),
11738                structural_matcher("body_end"),
11739                raw("</body></html>"),
11740            ]
11741        );
11742    }
11743
11744    #[test]
11745    fn test_style_bare_handlebars_stays_raw() {
11746        let mut parser = HtmlParser::new();
11747        // Handlebars outside a CSS comment must NOT be parsed as signals.
11748        let html =
11749            "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
11750
11751        let result = parser.parse("test.html", html);
11752        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11753        let fragment_records = parser.into_fragment_records();
11754
11755        assert_stream!(
11756            fragment_records,
11757            "test.html",
11758            [
11759                raw("<html><head>"),
11760                structural_matcher("head_start"),
11761                raw("<style>body { color: {{textColor}}; }</style>"),
11762                structural_matcher("head_end"),
11763                raw("</head><body>"),
11764                structural_matcher("body_start"),
11765                structural_matcher("body_end"),
11766                raw("</body></html>"),
11767            ]
11768        );
11769    }
11770
11771    #[test]
11772    fn test_style_mixed_css_and_comment_signal_emits_signal() {
11773        let mut parser = HtmlParser::new();
11774        let html = r#"<html><head><style>
11775  .a { color: red; }
11776  /*{{themeCss}}*/
11777  .b { color: blue; }
11778</style></head><body></body></html>"#;
11779
11780        let result = parser.parse("test.html", html);
11781        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11782        let fragment_records = parser.into_fragment_records();
11783
11784        assert_stream!(
11785            fragment_records,
11786            "test.html",
11787            [
11788                raw("<html><head>"),
11789                structural_matcher("head_start"),
11790                raw("<style>\n  .a { color: red; }\n  "),
11791                raw_text_signal("themeCss", false),
11792                raw("\n  .b { color: blue; }\n</style>"),
11793                structural_matcher("head_end"),
11794                raw("</head><body>"),
11795                structural_matcher("body_start"),
11796                structural_matcher("body_end"),
11797                raw("</body></html>"),
11798            ]
11799        );
11800    }
11801
11802    #[test]
11803    fn test_style_legal_comment_preserved_inline_by_default() {
11804        let mut parser = HtmlParser::new();
11805        let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
11806
11807        parser.parse("test.html", html).expect("parse failed");
11808        let fragment_records = parser.into_fragment_records();
11809
11810        assert_stream!(
11811            fragment_records,
11812            "test.html",
11813            [
11814                raw("<html><head>"),
11815                structural_matcher("head_start"),
11816                raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
11817                structural_matcher("head_end"),
11818                raw("</head><body>"),
11819                structural_matcher("body_start"),
11820                structural_matcher("body_end"),
11821                raw("</body></html>"),
11822            ]
11823        );
11824    }
11825
11826    #[test]
11827    fn test_style_legal_comments_none_strips_legal_comment() {
11828        let mut parser = HtmlParser::with_options(ParserOptions {
11829            legal_comments: LegalComments::None,
11830            ..ParserOptions::default()
11831        });
11832        let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
11833
11834        parser.parse("test.html", html).expect("parse failed");
11835        let fragment_records = parser.into_fragment_records();
11836
11837        assert_stream!(
11838            fragment_records,
11839            "test.html",
11840            [
11841                raw("<html><head>"),
11842                structural_matcher("head_start"),
11843                raw("<style> .x { color: red; }</style>"),
11844                structural_matcher("head_end"),
11845                raw("</head><body>"),
11846                structural_matcher("body_start"),
11847                structural_matcher("body_end"),
11848                raw("</body></html>"),
11849            ]
11850        );
11851    }
11852
11853    #[test]
11854    fn test_style_element_plain_css_unchanged() {
11855        let mut parser = HtmlParser::new();
11856        let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
11857
11858        let result = parser.parse("test.html", html);
11859        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11860        let fragment_records = parser.into_fragment_records();
11861
11862        // Plain CSS with no handlebars should remain as a single raw fragment.
11863        assert_stream!(
11864            fragment_records,
11865            "test.html",
11866            [
11867                raw("<html><head>"),
11868                structural_matcher("head_start"),
11869                raw("<style>body { margin: 0; }</style>"),
11870                structural_matcher("head_end"),
11871                raw("</head><body>"),
11872                structural_matcher("body_start"),
11873                structural_matcher("body_end"),
11874                raw("</body></html>"),
11875            ]
11876        );
11877    }
11878
11879    #[test]
11880    fn test_unclosed_style_element_returns_error() {
11881        let mut parser = HtmlParser::new();
11882        let result = parser.parse("index.html", "<style>.x { color: red; ");
11883        assert!(matches!(result, Err(ParserError::Template(diag)) if
11884            diag.to_string().contains("unclosed <style> tag")
11885        ));
11886    }
11887
11888    #[test]
11889    fn test_text_with_binding_after_void_element() {
11890        // Text nodes containing {{binding}} after void elements like <br>
11891        // must be preserved. Regression test for content being dropped.
11892        let (fragments, _) = parse_and_get_fragments(
11893            "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
11894        );
11895        assert_fragments!(
11896            fragments,
11897            [
11898                raw("Part A: "),
11899                signal("value1"),
11900                raw("<button>Click</button><br>Part B: "),
11901                signal("value2"),
11902                raw("<button>Click2</button>"),
11903            ]
11904        );
11905    }
11906}