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;
20pub mod plugin;
21mod route_parser;
22mod scoped_visits;
23mod suggest;
24
25pub use asset_filename::{
26    AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
27};
28pub use component_registry::{Component, ComponentRegistration, ComponentRegistry};
29pub use condition_parser::ConditionParser;
30pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
31pub use css_parser::CssParser;
32pub use diagnostic::{codes, Diagnostic, Severity};
33pub use error::{ParserError, Result};
34pub use handlebars_parser::HandlebarsParser;
35pub use webui_tokens::CssFallbackChain;
36
37use crate::component_policy::{
38    parse_component_render_policy, ComponentRenderPolicy, HYDRATE_ATTR as COMPONENT_HYDRATE_ATTR,
39    RENDER_ATTR as COMPONENT_RENDER_ATTR,
40    RESERVE_BLOCK_SIZE_ATTR as COMPONENT_RESERVE_BLOCK_SIZE_ATTR,
41};
42use crate::html_parser::{self as html, Attrs, Element, Event, Walker};
43use crate::plugin::{
44    AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
45    ElementStartContext, FragmentContext, ParserPlugin, ParserPluginArtifacts,
46};
47use std::collections::{HashMap, HashSet};
48use std::ops::Range;
49use webui_protocol::{
50    web_ui_fragment, web_ui_fragment::Fragment, BoundaryPhase, ConditionExpr, FragmentList,
51    WebUIFragment, WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentBoundary,
52    WebUiFragmentRoute,
53};
54
55/// Maximum template size accepted by the parser.
56///
57/// Build-time parsing should never need to ingest arbitrarily large single
58/// templates. This cap prevents accidental or malicious OOM-scale inputs while
59/// staying far above normal component/page sizes.
60const MAX_TEMPLATE_BYTES: usize = 16 * 1024 * 1024;
61
62/// Maximum nested HTML element depth accepted by semantic parsing.
63///
64/// The scanner itself is iterative, but semantic fragment construction still
65/// enters child ranges. This limit keeps pathological nesting from exhausting
66/// the Rust call stack while preserving generous headroom for real templates.
67const MAX_TEMPLATE_DEPTH: usize = 512;
68
69/// Prefix for compiler-owned structural signals.
70///
71/// `}}}` cannot occur in a parsed double- or triple-brace expression: it closes
72/// the binding first. This keeps transport structure distinct from every
73/// authored state key without changing the protobuf schema.
74const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
75
76fn structural_signal(value: impl AsRef<str>) -> WebUIFragment {
77    WebUIFragment::signal(
78        format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
79        true,
80    )
81}
82
83/// How an element affects the HTML insertion mode that decides whether an
84/// unknown element (the generated `<webui-hydrate>` sentinel) is
85/// foster-parented away from its payload script.
86#[derive(Clone, Copy, PartialEq, Eq)]
87enum FosterContext {
88    /// Does not change the current insertion mode.
89    Transparent,
90    /// "in table"/"in table body"/"in row"/"in column group"/"in select":
91    /// unknown elements are foster-parented out of this subtree.
92    Hostile,
93    /// `<td>`/`<th>`/`<caption>` return to "in body" rules, so a boundary
94    /// inside them is safe even though an ancestor is hostile.
95    Barrier,
96}
97
98/// Classify `name` for foster-parenting purposes.
99///
100/// Dispatching on length first means the common non-table element (`div`,
101/// `span`, `p`, `section`, …) costs one integer compare and a fallthrough,
102/// keeping this off the parser's measured hot path.
103#[inline]
104fn foster_context_of(name: &str) -> FosterContext {
105    match name.len() {
106        2 => {
107            if name.eq_ignore_ascii_case("tr") {
108                FosterContext::Hostile
109            } else if name.eq_ignore_ascii_case("td") || name.eq_ignore_ascii_case("th") {
110                FosterContext::Barrier
111            } else {
112                FosterContext::Transparent
113            }
114        }
115        5 => {
116            if name.eq_ignore_ascii_case("table")
117                || name.eq_ignore_ascii_case("tbody")
118                || name.eq_ignore_ascii_case("thead")
119                || name.eq_ignore_ascii_case("tfoot")
120            {
121                FosterContext::Hostile
122            } else {
123                FosterContext::Transparent
124            }
125        }
126        6 => {
127            if name.eq_ignore_ascii_case("select") {
128                FosterContext::Hostile
129            } else {
130                FosterContext::Transparent
131            }
132        }
133        7 => {
134            if name.eq_ignore_ascii_case("caption") {
135                FosterContext::Barrier
136            } else {
137                FosterContext::Transparent
138            }
139        }
140        8 => {
141            if name.eq_ignore_ascii_case("optgroup") || name.eq_ignore_ascii_case("colgroup") {
142                FosterContext::Hostile
143            } else {
144                FosterContext::Transparent
145            }
146        }
147        _ => FosterContext::Transparent,
148    }
149}
150
151/// Return the browser parsing context that prevents a nested boundary sentinel
152/// from becoming an active custom element.
153#[inline]
154fn boundary_parent_scope(name: &str) -> Option<&'static str> {
155    match name.len() {
156        3 if name.eq_ignore_ascii_case("xmp") => Some("<xmp> raw-text content"),
157        5 if name.eq_ignore_ascii_case("title") => Some("<title> text content"),
158        6 if name.eq_ignore_ascii_case("iframe") => Some("<iframe> raw-text content"),
159        6 if name.eq_ignore_ascii_case("script") => Some("<script> raw-text content"),
160        7 if name.eq_ignore_ascii_case("noembed") => Some("<noembed> raw-text content"),
161        8 if name.eq_ignore_ascii_case("template") => Some("<template> inert content"),
162        8 if name.eq_ignore_ascii_case("textarea") => Some("<textarea> text content"),
163        8 if name.eq_ignore_ascii_case("noframes") => Some("<noframes> raw-text content"),
164        8 if name.eq_ignore_ascii_case("noscript") => Some("<noscript> inert content"),
165        9 if name.eq_ignore_ascii_case("plaintext") => Some("<plaintext> text content"),
166        _ => None,
167    }
168}
169
170#[inline]
171fn is_html_text_only_scope(scope: Option<&str>) -> bool {
172    // Explicit allowlist of the raw-text/RCDATA scopes returned by
173    // `boundary_parent_scope`, so a new scope value must be deliberately
174    // added here to become text-only rather than silently defaulting to it.
175    // `<template> inert content` and "component host content" are
176    // intentionally excluded: both parse normal (escaped) child content.
177    matches!(
178        scope,
179        Some(
180            "<xmp> raw-text content"
181                | "<title> text content"
182                | "<iframe> raw-text content"
183                | "<script> raw-text content"
184                | "<noembed> raw-text content"
185                | "<textarea> text content"
186                | "<noframes> raw-text content"
187                | "<noscript> inert content"
188                | "<plaintext> text content"
189        )
190    )
191}
192
193/// Strategy for how component CSS is delivered in rendered output.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
195#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
196pub enum CssStrategy {
197    /// Emit `<link rel="stylesheet" href="component.css">` tags (default).
198    #[default]
199    Link,
200    /// Embed CSS content in `<style>` tags within the component.
201    Style,
202    /// Register each component's CSS module as a `<script type="importmap">`
203    /// data-URI entry. The client runtime applies the registered sheet via
204    /// the document's adopted stylesheets.
205    Module,
206}
207
208impl std::fmt::Display for CssStrategy {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        match self {
211            CssStrategy::Link => write!(f, "link"),
212            CssStrategy::Style => write!(f, "style"),
213            CssStrategy::Module => write!(f, "module"),
214        }
215    }
216}
217
218impl std::str::FromStr for CssStrategy {
219    type Err = String;
220
221    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
222        match s {
223            "link" => Ok(CssStrategy::Link),
224            "style" => Ok(CssStrategy::Style),
225            "module" => Ok(CssStrategy::Module),
226            other => Err(format!(
227                "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
228            )),
229        }
230    }
231}
232
233/// Default DOM strategy for components without an authored declarative Shadow root.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
236pub enum DomStrategy {
237    /// Wrap unwrapped component content in an open declarative Shadow root.
238    #[default]
239    Shadow,
240    /// Render unwrapped component content directly in Light DOM.
241    Light,
242}
243
244impl std::fmt::Display for DomStrategy {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        match self {
247            Self::Shadow => write!(f, "shadow"),
248            Self::Light => write!(f, "light"),
249        }
250    }
251}
252
253impl std::str::FromStr for DomStrategy {
254    type Err = String;
255
256    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
257        match value {
258            "shadow" => Ok(Self::Shadow),
259            "light" => Ok(Self::Light),
260            other => Err(format!(
261                "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
262            )),
263        }
264    }
265}
266
267/// Strategy for preserving legal comments in generated output.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
269#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
270pub enum LegalComments {
271    /// Strip every HTML and CSS comment.
272    None,
273    /// Preserve legal CSS comments inline and strip all other comments.
274    #[default]
275    Inline,
276}
277
278impl std::fmt::Display for LegalComments {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        match self {
281            LegalComments::None => write!(f, "none"),
282            LegalComments::Inline => write!(f, "inline"),
283        }
284    }
285}
286
287impl std::str::FromStr for LegalComments {
288    type Err = String;
289
290    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
291        match s {
292            "none" => Ok(LegalComments::None),
293            "inline" => Ok(LegalComments::Inline),
294            other => Err(format!(
295                "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
296            )),
297        }
298    }
299}
300
301/// Build/output options that affect parser-generated component templates.
302#[derive(Debug, Clone, Default)]
303pub struct ParserOptions {
304    /// Strategy for how component CSS is delivered.
305    pub css_strategy: CssStrategy,
306    /// Fallback DOM strategy for components without an authored Shadow root.
307    pub dom_strategy: DomStrategy,
308    /// Link-mode CSS filename/href options.
309    pub css_link_options: CssLinkOptions,
310    /// Legal comment preservation strategy.
311    pub legal_comments: LegalComments,
312}
313
314impl ParserOptions {
315    /// Create parser output options.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`ParserError::Css`] when Link-mode CSS link options are invalid.
320    pub fn try_new(
321        css_strategy: CssStrategy,
322        dom_strategy: DomStrategy,
323        css_file_name_template: &str,
324        css_public_base: Option<&str>,
325        legal_comments: LegalComments,
326    ) -> Result<Self> {
327        let css_link_options = if css_strategy == CssStrategy::Link {
328            CssLinkOptions::try_new(
329                css_file_name_template.to_string(),
330                css_public_base.map(std::string::ToString::to_string),
331            )?
332        } else {
333            CssLinkOptions::default()
334        };
335
336        Ok(Self {
337            css_strategy,
338            dom_strategy,
339            css_link_options,
340            legal_comments,
341        })
342    }
343}
344
345impl From<DomStrategy> for ParserOptions {
346    fn from(dom_strategy: DomStrategy) -> Self {
347        Self {
348            dom_strategy,
349            ..Self::default()
350        }
351    }
352}
353
354impl From<CssStrategy> for ParserOptions {
355    fn from(css_strategy: CssStrategy) -> Self {
356        Self {
357            css_strategy,
358            ..Self::default()
359        }
360    }
361}
362
363impl From<(CssStrategy, DomStrategy)> for ParserOptions {
364    fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
365        Self {
366            css_strategy,
367            dom_strategy,
368            ..Self::default()
369        }
370    }
371}
372
373/// Framework plugin to load for build-time and render-time processing.
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
376pub enum Plugin {
377    /// Deprecated compatibility alias for the FAST 2 hydration plugin.
378    #[cfg_attr(feature = "cli", value(name = "fast"))]
379    Fast,
380    /// Deprecated FAST 2 hydration plugin with legacy marker output.
381    #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
382    FastV2,
383    /// FAST 3 hydration plugin with compact marker output.
384    #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
385    FastV3,
386    /// WebUI plugin — full component model with shadow DOM support.
387    #[cfg_attr(feature = "cli", value(name = "webui"))]
388    WebUI,
389}
390
391impl std::fmt::Display for Plugin {
392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393        match self {
394            Plugin::Fast => write!(f, "fast"),
395            Plugin::FastV2 => write!(f, "fast-v2"),
396            Plugin::FastV3 => write!(f, "fast-v3"),
397            Plugin::WebUI => write!(f, "webui"),
398        }
399    }
400}
401
402impl std::str::FromStr for Plugin {
403    type Err = String;
404
405    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
406        match s {
407            "fast" => Ok(Plugin::Fast),
408            "fast-v2" => Ok(Plugin::FastV2),
409            "fast-v3" => Ok(Plugin::FastV3),
410            "webui" => Ok(Plugin::WebUI),
411            other => Err(format!(
412                "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
413            )),
414        }
415    }
416}
417
418/// Counter for generating unique fragment IDs.
419struct FragmentIdCounter {
420    /// Map of counter types to their current values.
421    counters: HashMap<String, usize>,
422}
423
424impl FragmentIdCounter {
425    /// Create a new fragment ID counter.
426    fn new() -> Self {
427        Self {
428            counters: HashMap::new(),
429        }
430    }
431
432    /// Generate a unique fragment ID.
433    fn next_id(&mut self, prefix: &str) -> String {
434        let count = self.counters.entry(prefix.to_string()).or_insert(0);
435        *count += 1;
436        format!("{}-{}", prefix, count)
437    }
438
439    fn has_generated(&self, id: &str) -> bool {
440        let Some((prefix, number)) = id.rsplit_once('-') else {
441            return false;
442        };
443        self.counters.get(prefix).is_some_and(|count| {
444            number
445                .parse::<usize>()
446                .is_ok_and(|number| number > 0 && number <= *count)
447        })
448    }
449}
450
451#[derive(Default)]
452struct NamedForLoops {
453    entries: HashMap<String, NamedFor>,
454}
455
456struct NamedFor {
457    item: String,
458    offset: usize,
459    defined: bool,
460}
461
462impl NamedForLoops {
463    fn register(&mut self, owner: &str, id: &str, item: &str, site: (bool, usize)) -> Result<()> {
464        let (definition, offset) = site;
465        if let Some(entry) = self.entries.get_mut(id) {
466            if definition && entry.defined {
467                return Err(named_for_error(
468                    owner,
469                    codes::DUPLICATE_FOR_ID,
470                    "duplicate named <for> definition",
471                    id,
472                    "define the body once per file and use a self-closing <for id=\"...\" each=\"...\" /> to reuse it",
473                )
474                .into());
475            }
476            if entry.item != item {
477                return Err(named_for_error(
478                    owner,
479                    codes::INCOMPATIBLE_FOR_ITEM,
480                    "inconsistent item variable for named <for>",
481                    id,
482                    &format!(
483                        "use each=\"{} in collection\" at every reference to this id",
484                        entry.item
485                    ),
486                )
487                .into());
488            }
489            entry.defined |= definition;
490        } else {
491            self.entries.insert(
492                id.to_string(),
493                NamedFor {
494                    item: item.to_string(),
495                    offset,
496                    defined: definition,
497                },
498            );
499        }
500        Ok(())
501    }
502
503    fn validate(&self, owner: &str, source: &str) -> Result<()> {
504        let missing = self
505            .entries
506            .iter()
507            .filter(|(_, entry)| !entry.defined)
508            .min_by_key(|(_, entry)| entry.offset);
509        if let Some((id, entry)) = missing {
510            return Err(named_for_error(
511                owner,
512                codes::UNKNOWN_FOR_ID,
513                "unknown named <for> definition",
514                id,
515                "add a <for> with this id and a body in the same file; definitions in other files are not visible",
516            )
517            .at_offset(source, entry.offset)
518            .into());
519        }
520        Ok(())
521    }
522}
523
524fn parse_for_each<'a>(owner: &str, each: &'a str) -> Result<(&'a str, &'a str)> {
525    let mut parts = each.split_whitespace();
526    let (Some(item), Some("in"), Some(collection), None) =
527        (parts.next(), parts.next(), parts.next(), parts.next())
528    else {
529        return Err(invalid_for_each(owner, each, codes::INVALID_FOR_EACH));
530    };
531    let allowed = |value: &str| {
532        value.bytes().all(|byte| {
533            byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-' || byte == b'.'
534        })
535    };
536    if !allowed(item) || !allowed(collection) {
537        return Err(invalid_for_each(owner, each, codes::INVALID_FOR_IDENTIFIER));
538    }
539    Ok((item, collection))
540}
541
542fn validate_for_id<'a>(owner: &str, tag: &html::Tag<'a>) -> Result<Option<&'a str>> {
543    let mut id = None;
544    let mut id_count = 0;
545    for attr in tag.attrs() {
546        match attr.name {
547            "template" => return Err(unsupported_for_template(owner)),
548            "id" => {
549                id = Some(attr.value.unwrap_or_default());
550                id_count += 1;
551            }
552            _ => {}
553        }
554    }
555    let Some(id) = id else {
556        return Ok(None);
557    };
558    if id.is_empty()
559        || !id
560            .bytes()
561            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
562        || id_count != 1
563    {
564        return Err(named_for_error(
565            owner,
566            codes::INVALID_FOR_ID,
567            "invalid <for> id",
568            id,
569            "use one non-empty static id containing letters, digits, '_' or '-'",
570        )
571        .into());
572    }
573    Ok(Some(id))
574}
575
576fn strip_condition_braces(value: &str) -> &str {
577    let value = value.trim();
578    value
579        .strip_prefix("{{")
580        .and_then(|value| value.strip_suffix("}}"))
581        .map_or(value, str::trim)
582}
583
584#[cold]
585#[inline(never)]
586fn locate_for_error(error: ParserError, element: &Element<'_>) -> ParserError {
587    match error {
588        ParserError::Template(diagnostic) => (*diagnostic)
589            .at_offset(element.source(), element.start)
590            .into(),
591        other => other,
592    }
593}
594
595#[cold]
596#[inline(never)]
597fn invalid_for_each(owner: &str, each: &str, code: &'static str) -> ParserError {
598    Diagnostic::error("invalid <for> each expression")
599        .code(code)
600        .component(owner)
601        .element("for")
602        .snippet(format!("each=\"{each}\""))
603        .help("use each=\"item in collection\" without braces; item and collection names may use only letters, digits, '_', '-', and '.'")
604        .into()
605}
606
607#[cold]
608#[inline(never)]
609fn unsupported_for_template(owner: &str) -> ParserError {
610    Diagnostic::error("unsupported <for> template attribute")
611        .code(codes::INVALID_FOR_ID)
612        .component(owner)
613        .element("for")
614        .snippet("template")
615        .help("replace template with id on the loop definition and every reference; named loops are local to their file")
616        .into()
617}
618
619#[cold]
620#[inline(never)]
621fn named_for_error(
622    owner: &str,
623    code: &'static str,
624    title: &str,
625    id: &str,
626    help: &str,
627) -> Diagnostic {
628    Diagnostic::error(title)
629        .code(code)
630        .component(owner)
631        .element("for")
632        .snippet(format!("id=\"{id}\""))
633        .help(help)
634}
635
636struct ParseContext {
637    fragments: Vec<WebUIFragment>,
638    raw_buffer: String,
639}
640
641struct PendingBoundary {
642    declaration_id: u32,
643}
644
645struct BoundaryGraphEdge {
646    target: String,
647}
648
649/// A `<for>` callsite, used to name the offending repeat in a diagnostic.
650struct RepeatSite<'a> {
651    owner: &'a str,
652    item: &'a str,
653    collection: &'a str,
654}
655
656/// A boundary declaration a `<for>` repeat body can reach.
657struct RepeatBoundaryViolation {
658    repeat_owner: String,
659    each: String,
660    boundary_owner: String,
661    boundary_name: String,
662}
663
664/// Push every record a route subtree renders into `targets`.
665fn push_route_records<'a>(targets: &mut Vec<&'a str>, root: &'a WebUiFragmentRoute) {
666    let mut pending = vec![root];
667    while let Some(route) = pending.pop() {
668        targets.push(route.fragment_id.as_str());
669        targets.push(route.content_fragment_id.as_str());
670        targets.push(route.pending_component.as_str());
671        targets.push(route.error_component.as_str());
672        pending.extend(route.children.iter());
673    }
674}
675
676/// Push every record `list` renders through a lexical child edge.
677fn push_child_records<'a>(list: &'a FragmentList, targets: &mut Vec<&'a str>) {
678    for fragment in &list.fragments {
679        match fragment.fragment.as_ref() {
680            Some(Fragment::Component(component)) => targets.push(&component.fragment_id),
681            Some(Fragment::IfCond(if_cond)) => targets.push(&if_cond.fragment_id),
682            Some(Fragment::ForLoop(for_loop)) => targets.push(&for_loop.fragment_id),
683            Some(Fragment::Route(route)) => push_route_records(targets, route),
684            _ => {}
685        }
686    }
687}
688
689#[derive(Default)]
690struct FragmentCssTokens {
691    definitions: Vec<String>,
692    fallback_chains: Vec<CssFallbackChain>,
693}
694
695/// CSS token analysis produced from the parsed template/component graph.
696#[derive(Debug, Clone, PartialEq, Eq)]
697pub struct CssTokenAnalysis {
698    /// Sorted, deduplicated token candidates stored in the protocol.
699    pub protocol_tokens: Vec<String>,
700    /// Fallback chains that still need theme/literal coverage after local and
701    /// ancestor custom-property definitions are considered.
702    pub fallback_chains: Vec<CssFallbackChain>,
703    /// Source location of each unresolved token's first `var()` usage, keyed by
704    /// token name. Used to point theme-validation diagnostics at the offending
705    /// CSS. Build-time only; never serialized.
706    pub(crate) token_sites: HashMap<String, TokenSite>,
707}
708
709/// Where an unresolved CSS token is first referenced, for diagnostics.
710///
711/// `owner` is a file-like label (`my-card.css`, or the entry file for inline
712/// `<style>`). `position`/`snippet` are populated for component CSS where the
713/// source is a standalone file; inline styles record the owner only.
714#[derive(Debug, Clone, PartialEq, Eq)]
715pub(crate) struct TokenSite {
716    owner: String,
717    position: Option<(usize, usize)>,
718    snippet: Option<String>,
719}
720
721enum ParseOp<'a> {
722    Parse {
723        range: Range<usize>,
724        depth: usize,
725    },
726    EmitClose(&'a str),
727    EndComponent(&'a str),
728    EndHead(&'a str),
729    EndBody(&'a str),
730    CompleteBoundary {
731        boundary: PendingBoundary,
732    },
733    /// Restore [`HtmlParser::foster_context_depth`] to the saved value when
734    /// leaving an element that changed it.
735    ///
736    /// Save/restore of the single overwritten value keeps ancestor tracking
737    /// allocation-free: no ancestor stack is retained.
738    RestoreFosterDepth(u32),
739    /// Restore the enclosing component/raw/inert context after its child range
740    /// has been parsed.
741    RestoreBoundaryParentScope(Option<&'static str>),
742    CompleteFor {
743        parent: ParseContext,
744        item: String,
745        collection: String,
746        fragment_id: String,
747        named_definition: bool,
748        previous_for_depth: usize,
749    },
750    CompleteIf {
751        parent: ParseContext,
752        condition: ConditionExpr,
753        fragment_id: String,
754    },
755}
756
757enum TokenGraphOp<'a> {
758    EnterFragment(&'a str),
759    ExitFragment(&'a str),
760    EnterComponent(&'a str),
761    EnterRoute(&'a WebUiFragmentRoute),
762    ExitDefinitions(&'a [String]),
763}
764
765impl Default for HtmlParser {
766    fn default() -> Self {
767        Self::new()
768    }
769}
770
771/// Parser for WebUI directives.
772pub struct HtmlParser {
773    /// CSS parser.
774    css_parser: CssParser,
775
776    /// Fragment ID counter.
777    id_counter: FragmentIdCounter,
778
779    /// Condition parser for parsing conditions in directives.
780    condition_parser: ConditionParser,
781
782    /// Handlebars parser for parsing handlebars expressions.
783    handlebars_parser: HandlebarsParser,
784
785    /// Component registry for WebUI components.
786    component_registry: ComponentRegistry,
787
788    /// Map of fragment IDs to their fragments
789    fragment_records: WebUIFragmentRecords,
790
791    /// Buffer for accumulating raw content
792    raw_buffer: String,
793
794    /// Parser output options for generated component templates.
795    options: ParserOptions,
796
797    /// Optional parser plugin for framework-specific behavior.
798    plugin: Option<Box<dyn ParserPlugin>>,
799
800    /// Static component behavior copied from the plugin at construction.
801    component_processing: ComponentProcessing,
802
803    /// Declarative Shadow DOM analysis resolved once per component tag.
804    component_dom_analyses: HashMap<String, ComponentDomAnalysis>,
805
806    /// Top-level fragments parsed by callers. Token graph traversal starts
807    /// from these roots after parsing completes.
808    token_roots: Vec<String>,
809
810    /// CSS custom property definitions and fallback chains from inline
811    /// `<style>` tags, keyed by owning fragment id.
812    fragment_css_tokens: HashMap<String, FragmentCssTokens>,
813
814    /// Fragment IDs currently being parsed, used to reject recursive component
815    /// references before they can recurse through template parsing.
816    in_progress_fragments: HashSet<String>,
817
818    /// The fragment ID (entry file or component tag) currently being parsed.
819    /// Used to name the owning template in authoring [`Diagnostic`]s.
820    current_fragment_id: String,
821
822    loop_owner_indices: HashMap<String, usize>,
823    named_for_loops: NamedForLoops,
824    named_for_fragment_ids: HashSet<String>,
825
826    /// Next protocol-wide compile-time boundary declaration identity.
827    next_boundary_declaration_id: u32,
828
829    /// Authored boundary names keyed by their owning entry/component template.
830    boundary_names_by_owner: HashMap<String, HashSet<String>>,
831
832    /// `true` while parsing is inside an open `<boundary>`, used to
833    /// reject a nested boundary.
834    in_boundary: bool,
835
836    /// Number of boundary scopes surrounding the current reusable-template
837    /// callsite. Kept separate from lexical `in_boundary` so component syntax
838    /// is analyzed independently while island-owned module entries stay
839    /// excluded from critical preloads.
840    boundary_ancestor_depth: usize,
841
842    /// Number of currently open native `<body>` elements. A depth rather than
843    /// a boolean preserves the parent state while recursively parsing a
844    /// component template.
845    body_depth: usize,
846
847    /// Number of lexical `<for>` scopes in the current template. Boundaries in
848    /// such scopes can occur repeatedly and therefore require an authored key.
849    for_depth: usize,
850
851    /// Number of runtime route-content scopes currently being parsed.
852    route_depth: usize,
853
854    /// Number of enclosing elements whose HTML insertion mode would
855    /// foster-parent an unknown element such as the generated
856    /// `<webui-hydrate>` sentinel.
857    ///
858    /// Non-zero means a `<boundary>` here would be split from its
859    /// payload script by the browser's parser, so it must be rejected at build
860    /// time. `<td>`/`<th>`/`<caption>` reset it to `0` because those switch
861    /// back to "in body" insertion rules.
862    foster_context_depth: u32,
863
864    /// Enclosing component host content or native raw/inert HTML context.
865    ///
866    /// A generated sentinel in any of these contexts is either text or lives
867    /// in an inert fragment, so it cannot commit a streaming boundary.
868    boundary_parent_scope: Option<&'static str>,
869
870    /// `src` of every authored `<script type="module">` outside any
871    /// `<boundary>`, in document order, from the current top-level parse.
872    ///
873    /// These are the page's critical module entries. The build resolves each
874    /// against the projection manifest to emit `<link rel="modulepreload">`
875    /// for the shared chunks they statically import, which the browser's
876    /// preload scanner cannot discover on its own.
877    ///
878    /// Scripts *inside* a boundary are deliberately excluded: an island loader
879    /// is meant to be requested only when the parser reaches its chunk, so
880    /// preloading it would undo the deferral the author asked for.
881    /// Explicit `fetchpriority="low"` modules are excluded for the same reason.
882    module_entry_srcs: Vec<String>,
883}
884
885struct BuiltComponentTemplate {
886    ssr: String,
887    artifact: Option<String>,
888    uses_shadow_dom: bool,
889    style: Option<OwnedComponentStyle>,
890}
891
892/// Owned form of [`plugin::ComponentStyleDelivery`], held for the lifetime of a
893/// built component template so plugin context can borrow from it.
894enum OwnedComponentStyle {
895    Link(String),
896    Inline(String),
897    Adopted(String),
898}
899
900#[derive(Clone, Copy)]
901struct ComponentStyleInjection<'a> {
902    css_snippet: Option<&'a str>,
903    adopted_specifier: Option<&'a str>,
904}
905
906#[derive(Clone, Copy)]
907struct ComponentTemplateMode {
908    preserve_runtime_attrs: bool,
909    policy_wrapper: bool,
910    // Emit inline CSS after bindings for clients that scan braces.
911    styles_at_end: bool,
912}
913
914impl BuiltComponentTemplate {
915    fn artifact(&self) -> &str {
916        self.artifact.as_deref().unwrap_or(&self.ssr)
917    }
918
919    fn plugin_context<'a>(&'a self, component: &'a Component) -> ComponentBuildContext<'a> {
920        ComponentBuildContext {
921            component,
922            template: self.artifact(),
923            uses_shadow_dom: self.uses_shadow_dom,
924            style: self.style.as_ref().map(|style| match style {
925                OwnedComponentStyle::Link(href) => plugin::ComponentStyleDelivery::Link { href },
926                OwnedComponentStyle::Inline(css) => plugin::ComponentStyleDelivery::Inline { css },
927                OwnedComponentStyle::Adopted(specifier) => {
928                    plugin::ComponentStyleDelivery::Adopted { specifier }
929                }
930            }),
931        }
932    }
933}
934
935#[derive(Debug, Clone, Copy)]
936struct ComponentDomAnalysis {
937    uses_shadow_dom: bool,
938    /// Byte range of the authored declarative Shadow DOM root, when present.
939    authored_shadow_root: Option<(usize, usize)>,
940    /// Content range of a sole bare `<template>` that explicitly selects Light
941    /// DOM.
942    authored_light_root: Option<(usize, usize)>,
943}
944
945struct ShadowRootModeOccurrence<'a> {
946    element_start: usize,
947    element_end: usize,
948    element_name: &'a str,
949    has_closing_tag: bool,
950    dynamically_bound: bool,
951    attr_offset: usize,
952    value: Option<&'a str>,
953}
954
955#[cfg(test)]
956fn analyze_component_dom(
957    tag_name: &str,
958    source: &str,
959    fallback: DomStrategy,
960) -> Result<ComponentDomAnalysis> {
961    analyze_component_dom_with_diagnostic_source(tag_name, source, fallback, None)
962}
963
964fn analyze_component_dom_with_diagnostic_source(
965    tag_name: &str,
966    source: &str,
967    fallback: DomStrategy,
968    diagnostic_source: Option<&str>,
969) -> Result<ComponentDomAnalysis> {
970    let mut ranges = Vec::with_capacity(8);
971    ranges.push((0..source.len(), true));
972    let mut top_level_element_count = 0usize;
973    let mut top_level_root_start = 0usize;
974    let mut has_top_level_content = false;
975    let mut bare_template_root = None;
976    let mut shadow_mode = None;
977    let mut slot_offset = None;
978
979    while let Some((range, top_level)) = ranges.pop() {
980        for event in Walker::new_range(source, range.start, range.end) {
981            match event {
982                Event::Text(text) if top_level && !text.trim().is_empty() => {
983                    has_top_level_content = true;
984                }
985                Event::Declaration(_) if top_level => {
986                    has_top_level_content = true;
987                }
988                Event::Element(element) => {
989                    if top_level {
990                        top_level_element_count += 1;
991                        top_level_root_start = element.start;
992                        bare_template_root = (element.name().eq_ignore_ascii_case("template")
993                            && !element.self_closing()
994                            && element.attrs().next().is_none())
995                        .then_some((element.content_start, element.content_end));
996                    }
997
998                    for attr in element.attrs() {
999                        let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1000                        if attr_name.eq_ignore_ascii_case("shadowrootmode") {
1001                            if shadow_mode.is_some() {
1002                                return Err(invalid_shadow_root_mode_error(
1003                                    tag_name,
1004                                    DiagnosticSite::new(
1005                                        source,
1006                                        diagnostic_source,
1007                                        element.start + attr.raw_range.start,
1008                                    ),
1009                                    "a component may declare only one `shadowrootmode`",
1010                                    "keep one `shadowrootmode=\"open\"` attribute on the sole top-level `<template>`",
1011                                ));
1012                            }
1013                            shadow_mode = Some(ShadowRootModeOccurrence {
1014                                element_start: element.start,
1015                                element_end: element.close_end(),
1016                                element_name: element.name(),
1017                                has_closing_tag: !element.self_closing()
1018                                    && element.close_end() > element.content_end(),
1019                                dynamically_bound: attr.name != attr_name,
1020                                attr_offset: element.start + attr.raw_range.start,
1021                                value: attr.value,
1022                            });
1023                        }
1024                    }
1025
1026                    if slot_offset.is_none() && element.name().eq_ignore_ascii_case("slot") {
1027                        slot_offset = Some(element.start);
1028                    }
1029
1030                    let name = element.name();
1031                    let raw_text = name.eq_ignore_ascii_case("script")
1032                        || name.eq_ignore_ascii_case("style")
1033                        || name.eq_ignore_ascii_case("textarea")
1034                        || name.eq_ignore_ascii_case("title");
1035                    if element.content_end() > element.inner().start && !raw_text {
1036                        ranges.push((element.inner(), false));
1037                    }
1038                }
1039                Event::Text(_)
1040                | Event::Comment(_)
1041                | Event::Declaration(_)
1042                | Event::ClosingTag(_) => {}
1043            }
1044        }
1045    }
1046
1047    let authored_shadow_root = match shadow_mode {
1048        Some(mode) => {
1049            let value = mode.value;
1050            if mode.dynamically_bound {
1051                return Err(invalid_shadow_root_mode_error(
1052                    tag_name,
1053                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1054                    "`shadowrootmode` must be a static attribute",
1055                    "replace the binding with `shadowrootmode=\"open\"` on the sole top-level `<template>`",
1056                ));
1057            }
1058            if value.is_some_and(|value| value.eq_ignore_ascii_case("closed")) {
1059                return Err(invalid_shadow_root_mode_error(
1060                    tag_name,
1061                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1062                    "`shadowrootmode=\"closed\"` is not supported",
1063                    "use `shadowrootmode=\"open\"`; WebUI requires an open root for hydration and style delivery",
1064                ));
1065            }
1066            if !value.is_some_and(|value| value.eq_ignore_ascii_case("open")) {
1067                return Err(invalid_shadow_root_mode_error(
1068                    tag_name,
1069                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1070                    "`shadowrootmode` must be `open`",
1071                    "set the attribute to `shadowrootmode=\"open\"`",
1072                ));
1073            }
1074            if top_level_element_count != 1
1075                || has_top_level_content
1076                || mode.element_start != top_level_root_start
1077                || !mode.element_name.eq_ignore_ascii_case("template")
1078                || !mode.has_closing_tag
1079            {
1080                return Err(invalid_shadow_root_mode_error(
1081                    tag_name,
1082                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1083                    "the declarative Shadow DOM wrapper must be the sole top-level element",
1084                    "wrap the complete component in one top-level `<template shadowrootmode=\"open\">`",
1085                ));
1086            }
1087            Some((mode.element_start, mode.element_end))
1088        }
1089        None => None,
1090    };
1091
1092    let authored_light_root =
1093        if authored_shadow_root.is_none() && top_level_element_count == 1 && !has_top_level_content
1094        {
1095            bare_template_root
1096        } else {
1097            None
1098        };
1099    let uses_shadow_dom = authored_shadow_root.is_some()
1100        || (authored_light_root.is_none() && fallback == DomStrategy::Shadow);
1101    if !uses_shadow_dom {
1102        if let Some(offset) = slot_offset {
1103            return Err(light_dom_slot_error(
1104                tag_name,
1105                DiagnosticSite::new(source, diagnostic_source, offset),
1106            ));
1107        }
1108    }
1109
1110    Ok(ComponentDomAnalysis {
1111        uses_shadow_dom,
1112        authored_shadow_root,
1113        authored_light_root,
1114    })
1115}
1116
1117#[cold]
1118#[inline(never)]
1119fn invalid_shadow_root_mode_error(
1120    tag_name: &str,
1121    site: DiagnosticSite<'_>,
1122    title: &str,
1123    help: &str,
1124) -> ParserError {
1125    let (source, offset) = site.resolve(DiagnosticTarget::Attribute("shadowrootmode"));
1126    Diagnostic::error(title)
1127        .code(codes::INVALID_SHADOW_ROOT_MODE)
1128        .component(tag_name)
1129        .at_offset(source, offset)
1130        .snippet(source_line_snippet(source, offset))
1131        .help(help)
1132        .into()
1133}
1134
1135#[cold]
1136#[inline(never)]
1137fn light_dom_slot_error(tag_name: &str, site: DiagnosticSite<'_>) -> ParserError {
1138    let (source, offset) = site.resolve(DiagnosticTarget::Element("slot"));
1139    Diagnostic::error("native `<slot>` projection requires Shadow DOM")
1140        .code(codes::LIGHT_DOM_SLOT)
1141        .component(tag_name)
1142        .element("slot")
1143        .at_offset(source, offset)
1144        .snippet(source_line_snippet(source, offset))
1145        .help(
1146            "wrap the complete component template in `<template shadowrootmode=\"open\">` to use `<slot>`",
1147        )
1148        .into()
1149}
1150
1151#[derive(Clone, Copy)]
1152struct DiagnosticSite<'a> {
1153    source: &'a str,
1154    authored_source: Option<&'a str>,
1155    offset: usize,
1156}
1157
1158impl<'a> DiagnosticSite<'a> {
1159    fn new(source: &'a str, authored_source: Option<&'a str>, offset: usize) -> Self {
1160        Self {
1161            source,
1162            authored_source,
1163            offset,
1164        }
1165    }
1166
1167    fn resolve(self, target: DiagnosticTarget) -> (&'a str, usize) {
1168        let Some(authored_source) = self.authored_source else {
1169            return (self.source, self.offset);
1170        };
1171        let transformed_offsets = diagnostic_target_offsets(self.source, target);
1172        let occurrence = transformed_offsets
1173            .iter()
1174            .position(|offset| *offset == self.offset)
1175            .unwrap_or(0);
1176        let authored_offsets = diagnostic_target_offsets(authored_source, target);
1177        authored_offsets
1178            .get(occurrence)
1179            .copied()
1180            .map_or((self.source, self.offset), |offset| {
1181                (authored_source, offset)
1182            })
1183    }
1184}
1185
1186#[derive(Clone, Copy)]
1187enum DiagnosticTarget {
1188    Element(&'static str),
1189    Attribute(&'static str),
1190}
1191
1192#[cold]
1193#[inline(never)]
1194fn diagnostic_target_offsets(source: &str, target: DiagnosticTarget) -> Vec<usize> {
1195    let mut offsets = Vec::new();
1196    let mut ranges = Vec::with_capacity(4);
1197    ranges.push(0..source.len());
1198
1199    while let Some(range) = ranges.pop() {
1200        for event in Walker::new_range(source, range.start, range.end) {
1201            let Event::Element(element) = event else {
1202                continue;
1203            };
1204            match target {
1205                DiagnosticTarget::Element(name) if element.name().eq_ignore_ascii_case(name) => {
1206                    offsets.push(element.start);
1207                }
1208                DiagnosticTarget::Attribute(name) => {
1209                    for attr in element.attrs() {
1210                        let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1211                        if attr_name.eq_ignore_ascii_case(name) {
1212                            offsets.push(element.start + attr.raw_range.start);
1213                        }
1214                    }
1215                }
1216                DiagnosticTarget::Element(_) => {}
1217            }
1218
1219            let name = element.name();
1220            let raw_text = name.eq_ignore_ascii_case("script")
1221                || name.eq_ignore_ascii_case("style")
1222                || name.eq_ignore_ascii_case("textarea")
1223                || name.eq_ignore_ascii_case("title");
1224            if element.content_end() > element.inner().start && !raw_text {
1225                ranges.push(element.inner());
1226            }
1227        }
1228    }
1229    offsets.sort_unstable();
1230    offsets
1231}
1232
1233struct ComponentTemplateInput<'a> {
1234    tag_name: &'a str,
1235    html: &'a str,
1236    artifact_html: Option<&'a str>,
1237    authored_html: Option<&'a str>,
1238    css_content: Option<&'a str>,
1239    artifact_needed: bool,
1240}
1241
1242fn add_token_definitions<'a>(
1243    definitions: &'a [String],
1244    available_counts: &mut HashMap<&'a str, usize>,
1245) {
1246    for definition in definitions {
1247        let count = available_counts.entry(definition.as_str()).or_insert(0);
1248        *count += 1;
1249    }
1250}
1251
1252fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<&str, usize>) {
1253    for definition in definitions {
1254        if let Some(count) = available_counts.get_mut(definition.as_str()) {
1255            if *count == 1 {
1256                available_counts.remove(definition.as_str());
1257            } else {
1258                *count -= 1;
1259            }
1260        }
1261    }
1262}
1263
1264/// Accumulators for the token-graph walk: the unresolved fallback chains and
1265/// the per-token source locations, grouped so walk helpers stay within the
1266/// argument-count budget.
1267#[derive(Default)]
1268struct UnresolvedTokens {
1269    chains: Vec<CssFallbackChain>,
1270    sites: HashMap<String, TokenSite>,
1271}
1272
1273fn record_unresolved_requirements(
1274    source: &[CssFallbackChain],
1275    available_counts: &HashMap<&str, usize>,
1276    owner: &str,
1277    css_source: Option<&str>,
1278    out: &mut UnresolvedTokens,
1279) {
1280    for requirement in source {
1281        let tokens: Vec<String> = requirement
1282            .tokens
1283            .iter()
1284            .filter(|token| !available_counts.contains_key(token.as_str()))
1285            .cloned()
1286            .collect();
1287        if tokens.is_empty() {
1288            continue;
1289        }
1290        for token in &tokens {
1291            if !out.sites.contains_key(token) {
1292                out.sites
1293                    .insert(token.clone(), token_site(owner, css_source, token));
1294            }
1295        }
1296        out.chains.push(CssFallbackChain {
1297            tokens,
1298            has_literal_fallback: requirement.has_literal_fallback,
1299        });
1300    }
1301}
1302
1303/// Build the [`TokenSite`] for `token`. When `css_source` is available (a
1304/// component's standalone CSS), the token's `var()` usage is located for a
1305/// precise `line:column` and snippet; otherwise only the `owner` is recorded.
1306fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
1307    let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
1308        Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
1309        None => (None, None),
1310    };
1311    TokenSite {
1312        owner: owner.to_string(),
1313        position,
1314        snippet,
1315    }
1316}
1317
1318/// Locate the first `var(--token)` **usage** (not a `--token:` definition) in
1319/// `css`, returning its 1-based `(line, column)` and a one-line snippet.
1320///
1321/// Iterative byte scan — no regex, no recursion. Cold-ish (build time, only for
1322/// unresolved tokens).
1323fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
1324    let bytes = css.as_bytes();
1325    let name = token.as_bytes();
1326    let mut index = 0;
1327    while index + 2 + name.len() <= bytes.len() {
1328        let is_prefixed = bytes[index] == b'-'
1329            && bytes[index + 1] == b'-'
1330            && bytes.get(index + 2..index + 2 + name.len()) == Some(name);
1331        if is_prefixed {
1332            let after = index + 2 + name.len();
1333            let is_exact_name = bytes
1334                .get(after)
1335                .is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
1336            if is_exact_name {
1337                let mut cursor = after;
1338                while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
1339                    cursor += 1;
1340                }
1341                // `--token:` is a definition, not the usage we want to point at.
1342                if bytes.get(cursor) != Some(&b':') {
1343                    let (line, column) = diagnostic::line_column(css, index);
1344                    return Some((line, column, source_line_snippet(css, index)));
1345                }
1346            }
1347        }
1348        index += 1;
1349    }
1350    None
1351}
1352
1353/// Extract the trimmed line containing byte `offset`, capped so minified input
1354/// does not produce a wall-of-text snippet.
1355fn source_line_snippet(source: &str, offset: usize) -> String {
1356    const MAX_SNIPPET: usize = 80;
1357    let start = source[..offset].rfind('\n').map_or(0, |n| n + 1);
1358    let end = source[offset..]
1359        .find('\n')
1360        .map_or(source.len(), |n| offset + n);
1361    let line = source[start..end].trim();
1362    if line.chars().count() > MAX_SNIPPET {
1363        let truncated: String = line.chars().take(MAX_SNIPPET).collect();
1364        format!("{truncated}…")
1365    } else {
1366        line.to_string()
1367    }
1368}
1369
1370/// File-like owner label for a component's standalone CSS
1371/// (`my-card` → `my-card.css`), used as the `--> owner:line:column` prefix.
1372fn css_owner_label(tag_name: &str) -> String {
1373    let mut label = String::with_capacity(tag_name.len() + 4);
1374    label.push_str(tag_name);
1375    label.push_str(".css");
1376    label
1377}
1378
1379impl CssTokenAnalysis {
1380    /// Validate the required CSS tokens against a loaded design-token theme.
1381    ///
1382    /// A token is *required* in every theme only when it appears in at least one
1383    /// unresolved `var()` chain with **no** literal CSS fallback. A chain such as
1384    /// `var(--x, 16px)` provides its own value, so `--x` stays in
1385    /// [`protocol_tokens`](Self::protocol_tokens) for runtime resolution (the
1386    /// theme value still wins when present) but does not fail the build when the
1387    /// theme omits it. The chain/theme policy lives in
1388    /// [`webui_tokens::validate_chain_tokens`]; this only adapts its
1389    /// [`webui_tokens::TokenError`] into a structured [`Diagnostic`].
1390    ///
1391    /// # Errors
1392    ///
1393    /// Returns [`ParserError::Template`] with a stable diagnostic code when a
1394    /// required token is missing from a theme. Theme token values are trusted and
1395    /// their transitive references are left to browser CSS semantics.
1396    pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
1397        webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
1398            .map_err(|source| self.theme_token_error(source, theme))
1399    }
1400
1401    /// CSS tokens used **only** with a literal `var()` fallback and defined in no
1402    /// theme — likely typos that callers may surface as non-fatal advisories.
1403    ///
1404    /// Thin wrapper over [`webui_tokens::unthemed_literal_fallback_tokens`].
1405    #[must_use]
1406    pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
1407        webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
1408    }
1409
1410    /// Structured, color-free advisories for CSS tokens used only with a
1411    /// literal `var()` fallback and defined in no theme — almost always typos.
1412    ///
1413    /// Returned as warning-severity [`Diagnostic`]s so hosts render them with
1414    /// the same location/snippet/`help:` layout as errors (the CLI colorizes;
1415    /// other hosts use the plain [`Diagnostic::body`]). Each carries the token's
1416    /// source location and a `did you mean --…?` suggestion.
1417    #[must_use]
1418    pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
1419        let mut warnings = Vec::new();
1420        for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
1421            let help = match closest_theme_token(&token, theme.themes.values()) {
1422                Some(suggestion) => {
1423                    format!("did you mean --{suggestion}? otherwise the literal fallback is used")
1424                }
1425                None => format!(
1426                    "define --{token} in the theme, or keep its literal fallback if intentional"
1427                ),
1428            };
1429            let diag = self.locate_diagnostic(
1430                Diagnostic::warning(format!("unthemed CSS token --{token}"))
1431                    .code(codes::UNTHEMED_TOKEN)
1432                    .help(help),
1433                &token,
1434            );
1435            warnings.push(diag);
1436        }
1437        warnings
1438    }
1439
1440    /// Adapt a [`webui_tokens::TokenError`] into a structured [`Diagnostic`],
1441    /// enriching it with the token's source location and a `did you mean …?`
1442    /// suggestion drawn from the theme's own tokens.
1443    #[cold]
1444    #[inline(never)]
1445    fn theme_token_error(
1446        &self,
1447        source: webui_tokens::TokenError,
1448        theme: &webui_tokens::TokenFile,
1449    ) -> ParserError {
1450        match source {
1451            webui_tokens::TokenError::MissingToken {
1452                theme: theme_name,
1453                token,
1454            } => {
1455                // The error title + snippet already say the token is missing
1456                // from the theme, so the help only adds what isn't obvious: the
1457                // likely-typo suggestion and the local-definition escape hatch.
1458                let help =
1459                    match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
1460                        Some(suggestion) => {
1461                            format!("did you mean --{suggestion}? otherwise define it locally")
1462                        }
1463                        None => {
1464                            "define it locally if it should not come from the theme".to_string()
1465                        }
1466                    };
1467                self.locate_diagnostic(
1468                    Diagnostic::error("missing theme token")
1469                        .code(codes::MISSING_THEME_TOKEN)
1470                        .help(help),
1471                    &token,
1472                )
1473                .into()
1474            }
1475            other => ParserError::Generic(format!("Theme token validation failed: {other}")),
1476        }
1477    }
1478
1479    /// Attach `token`'s recorded source location (owner, `line:column`, snippet)
1480    /// to `diag`. Falls back to a `--token` snippet when no location is known.
1481    fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
1482        let Some(site) = self.token_sites.get(token) else {
1483            return diag.snippet(format!("--{token}"));
1484        };
1485        let mut diag = diag.component(site.owner.clone());
1486        if let Some((line, column)) = site.position {
1487            diag = diag.position(line, column);
1488        }
1489        match &site.snippet {
1490            Some(snippet) => diag.snippet(snippet.clone()),
1491            None => diag.snippet(format!("--{token}")),
1492        }
1493    }
1494}
1495
1496/// Closest theme token to `target` by Levenshtein distance across the given
1497/// theme token maps, considering each theme's keys. Sorted for determinism.
1498#[cold]
1499#[inline(never)]
1500fn closest_theme_token<'a>(
1501    target: &str,
1502    themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
1503) -> Option<String> {
1504    let mut names: Vec<&str> = themes
1505        .flat_map(|theme| theme.keys().map(String::as_str))
1506        .collect();
1507    names.sort_unstable();
1508    names.dedup();
1509    crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
1510}
1511
1512impl HtmlParser {
1513    /// Create a new directive parser with default parser options.
1514    #[must_use]
1515    pub fn new() -> Self {
1516        Self::with_options(ParserOptions::default())
1517    }
1518
1519    /// Create a new directive parser with explicit parser options.
1520    #[must_use]
1521    pub fn with_options(options: impl Into<ParserOptions>) -> Self {
1522        let options = options.into();
1523
1524        Self {
1525            component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
1526            css_parser: CssParser::new(),
1527            id_counter: FragmentIdCounter::new(),
1528            condition_parser: ConditionParser::new(),
1529            handlebars_parser: HandlebarsParser::new(),
1530            raw_buffer: String::new(),
1531            fragment_records: WebUIFragmentRecords::new(),
1532            options,
1533            plugin: None,
1534            component_processing: ComponentProcessing::default(),
1535            component_dom_analyses: HashMap::new(),
1536            token_roots: Vec::new(),
1537            fragment_css_tokens: HashMap::new(),
1538            in_progress_fragments: HashSet::new(),
1539            current_fragment_id: String::new(),
1540            loop_owner_indices: HashMap::new(),
1541            named_for_loops: NamedForLoops::default(),
1542            named_for_fragment_ids: HashSet::new(),
1543            next_boundary_declaration_id: 0,
1544            boundary_names_by_owner: HashMap::new(),
1545            in_boundary: false,
1546            boundary_ancestor_depth: 0,
1547            body_depth: 0,
1548            for_depth: 0,
1549            route_depth: 0,
1550            foster_context_depth: 0,
1551            boundary_parent_scope: None,
1552            module_entry_srcs: Vec::new(),
1553        }
1554    }
1555
1556    /// Create a new parser with a plugin and default parser options.
1557    #[must_use]
1558    pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
1559        Self::with_plugin_options(plugin, ParserOptions::default())
1560    }
1561
1562    /// Create a new parser with a plugin and explicit parser options.
1563    #[must_use]
1564    pub fn with_plugin_options(
1565        mut plugin: Box<dyn ParserPlugin>,
1566        options: impl Into<ParserOptions>,
1567    ) -> Self {
1568        let mut p = Self::with_options(options);
1569        plugin.configure_parser(&p.options);
1570        let component_processing = plugin.component_processing();
1571        p.component_registry
1572            .set_component_source_transform(component_processing.source_transform);
1573        p.component_processing = component_processing;
1574        p.plugin = Some(plugin);
1575        p
1576    }
1577
1578    /// Get a mutable reference to the component registry.
1579    pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
1580        &mut self.component_registry
1581    }
1582
1583    /// Get a shared reference to the component registry.
1584    #[must_use]
1585    pub fn component_registry(&self) -> &ComponentRegistry {
1586        &self.component_registry
1587    }
1588
1589    pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
1590        std::mem::take(&mut self.fragment_records)
1591    }
1592
1593    /// Check if a fragment ID has been parsed (exists in the fragment records).
1594    pub fn has_fragment(&self, fragment_id: &str) -> bool {
1595        self.fragment_records.contains_key(fragment_id)
1596    }
1597
1598    /// Compute transitive boundary presence and conservative declaration
1599    /// repeatability for the complete parsed fragment graph, and reject every
1600    /// boundary a `<for>` repeat body can reach.
1601    ///
1602    /// This runs only after a top-level parse succeeds. Every analysis is
1603    /// iterative: presence propagates through reverse edges, repeat
1604    /// reachability is a multi-source forward walk, and occurrence counts use a
1605    /// two-value (once/many) fixed point. Cycles therefore converge without
1606    /// recursion and are conservatively classified as repeatable.
1607    #[cold]
1608    #[inline(never)]
1609    fn finalize_boundary_metadata(&mut self) -> Result<()> {
1610        if self.next_boundary_declaration_id != 0 {
1611            let (outgoing, direct_boundary_records) = self.boundary_graph();
1612            self.mark_boundary_records(&outgoing, direct_boundary_records);
1613            if let Some(violation) = self.find_repeat_reachable_boundary() {
1614                return Err(Self::repeat_reachable_boundary_error(&violation));
1615            }
1616            let occurrence_counts = self.boundary_occurrence_counts(&outgoing);
1617
1618            let mut unkeyed_repeat: Option<(u32, String, String)> = None;
1619            for (fragment_id, list) in &mut self.fragment_records {
1620                let repeated = occurrence_counts
1621                    .get(fragment_id)
1622                    .is_some_and(|count| *count > 1);
1623                if !repeated {
1624                    continue;
1625                }
1626                for fragment in &mut list.fragments {
1627                    if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_mut() {
1628                        if boundary.phase() == BoundaryPhase::Start {
1629                            boundary.may_repeat = true;
1630                            if boundary.key.is_none()
1631                                && unkeyed_repeat
1632                                    .as_ref()
1633                                    .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1634                            {
1635                                unkeyed_repeat = Some((
1636                                    boundary.declaration_id,
1637                                    boundary.owner_fragment_id.clone(),
1638                                    boundary.name.clone(),
1639                                ));
1640                            }
1641                        }
1642                    }
1643                }
1644            }
1645            if let Some((_, owner, name)) = unkeyed_repeat {
1646                return Err(Self::missing_boundary_key_error(&owner, &name));
1647            }
1648
1649            if let Some((owner, name)) = self.find_transitively_nested_boundary() {
1650                return Err(Self::transitively_nested_boundary_error(&owner, &name));
1651            }
1652        }
1653        self.finalize_component_span_signals();
1654        Ok(())
1655    }
1656
1657    fn finalize_component_span_signals(&mut self) {
1658        const START: &str = "streaming_span_start:";
1659        const END: &str = "streaming_span_end:";
1660
1661        let spanning: HashSet<String> = self
1662            .fragment_records
1663            .iter()
1664            .filter_map(|(id, list)| list.contains_boundary.then_some(id.clone()))
1665            .collect();
1666        for list in self.fragment_records.values_mut() {
1667            let mut normalized = Vec::with_capacity(list.fragments.len());
1668            for fragment in list.fragments.drain(..) {
1669                let remove = match fragment.fragment.as_ref() {
1670                    Some(Fragment::Signal(signal)) => signal
1671                        .value
1672                        .strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1673                        .and_then(|value| {
1674                            value
1675                                .strip_prefix(START)
1676                                .or_else(|| value.strip_prefix(END))
1677                        })
1678                        .is_some_and(|tag| !spanning.contains(tag)),
1679                    _ => false,
1680                };
1681                if remove {
1682                    continue;
1683                }
1684                if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
1685                    if let Some(WebUIFragment {
1686                        fragment: Some(Fragment::Raw(previous)),
1687                    }) = normalized.last_mut()
1688                    {
1689                        previous.value.push_str(&raw.value);
1690                        continue;
1691                    }
1692                }
1693                normalized.push(fragment);
1694            }
1695            list.fragments = normalized;
1696        }
1697    }
1698
1699    fn boundary_graph(&self) -> (HashMap<String, Vec<BoundaryGraphEdge>>, Vec<String>) {
1700        let mut outgoing: HashMap<String, Vec<BoundaryGraphEdge>> =
1701            HashMap::with_capacity(self.fragment_records.len());
1702        let mut direct_boundary_records = Vec::new();
1703
1704        for (fragment_id, list) in &self.fragment_records {
1705            let edges = outgoing.entry(fragment_id.clone()).or_default();
1706            for fragment in &list.fragments {
1707                match fragment.fragment.as_ref() {
1708                    Some(Fragment::Component(component)) => {
1709                        Self::push_boundary_edge(edges, &component.fragment_id);
1710                    }
1711                    Some(Fragment::ForLoop(for_loop)) => {
1712                        Self::push_boundary_edge(edges, &for_loop.fragment_id);
1713                    }
1714                    Some(Fragment::IfCond(if_cond)) => {
1715                        Self::push_boundary_edge(edges, &if_cond.fragment_id);
1716                    }
1717                    Some(Fragment::Boundary(boundary)) => {
1718                        if boundary.phase() == BoundaryPhase::Start {
1719                            direct_boundary_records.push(fragment_id.clone());
1720                        }
1721                    }
1722                    Some(Fragment::Route(route)) => {
1723                        Self::push_route_boundary_edges(edges, route);
1724                    }
1725                    _ => {}
1726                }
1727            }
1728        }
1729        (outgoing, direct_boundary_records)
1730    }
1731
1732    fn push_boundary_edge(edges: &mut Vec<BoundaryGraphEdge>, target: &str) {
1733        if !target.is_empty() {
1734            edges.push(BoundaryGraphEdge {
1735                target: target.to_string(),
1736            });
1737        }
1738    }
1739
1740    fn push_route_boundary_edges(edges: &mut Vec<BoundaryGraphEdge>, root: &WebUiFragmentRoute) {
1741        let mut pending = vec![root];
1742        while let Some(route) = pending.pop() {
1743            Self::push_boundary_edge(edges, &route.fragment_id);
1744            Self::push_boundary_edge(edges, &route.content_fragment_id);
1745            Self::push_boundary_edge(edges, &route.pending_component);
1746            Self::push_boundary_edge(edges, &route.error_component);
1747            pending.extend(route.children.iter());
1748        }
1749    }
1750
1751    fn mark_boundary_records(
1752        &mut self,
1753        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1754        direct_boundary_records: Vec<String>,
1755    ) {
1756        for list in self.fragment_records.values_mut() {
1757            list.contains_boundary = false;
1758        }
1759
1760        let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
1761        for (parent, edges) in outgoing {
1762            for edge in edges {
1763                reverse
1764                    .entry(edge.target.as_str())
1765                    .or_default()
1766                    .push(parent.as_str());
1767            }
1768        }
1769
1770        let mut marked = HashSet::with_capacity(self.fragment_records.len());
1771        let mut pending = direct_boundary_records;
1772        while let Some(fragment_id) = pending.pop() {
1773            if !marked.insert(fragment_id.clone()) {
1774                continue;
1775            }
1776            if let Some(list) = self.fragment_records.get_mut(&fragment_id) {
1777                list.contains_boundary = true;
1778            }
1779            if let Some(parents) = reverse.get(fragment_id.as_str()) {
1780                pending.extend(parents.iter().map(|parent| (*parent).to_string()));
1781            }
1782        }
1783    }
1784
1785    fn boundary_occurrence_counts(
1786        &self,
1787        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1788    ) -> HashMap<String, u8> {
1789        let mut incoming: HashMap<&str, Vec<&str>> = HashMap::new();
1790        for (parent, edges) in outgoing {
1791            for edge in edges {
1792                incoming
1793                    .entry(edge.target.as_str())
1794                    .or_default()
1795                    .push(parent.as_str());
1796            }
1797        }
1798
1799        let mut aggregate = HashMap::with_capacity(self.fragment_records.len());
1800        for root in &self.token_roots {
1801            if !self.fragment_records.contains_key(root) {
1802                continue;
1803            }
1804            let counts = self.boundary_occurrences_from_root(root.as_str(), outgoing, &incoming);
1805            for (fragment_id, count) in counts {
1806                let current = aggregate.entry(fragment_id).or_insert(0);
1807                *current = (*current).max(count);
1808            }
1809        }
1810        aggregate
1811    }
1812
1813    fn boundary_occurrences_from_root(
1814        &self,
1815        root: &str,
1816        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1817        incoming: &HashMap<&str, Vec<&str>>,
1818    ) -> HashMap<String, u8> {
1819        let mut counts: HashMap<String, u8> = HashMap::with_capacity(self.fragment_records.len());
1820        counts.insert(root.to_string(), 1);
1821        let mut pending = vec![root.to_string()];
1822        while let Some(parent) = pending.pop() {
1823            let Some(edges) = outgoing.get(&parent) else {
1824                continue;
1825            };
1826            for edge in edges {
1827                let next = Self::recompute_boundary_occurrences(
1828                    edge.target.as_str(),
1829                    root,
1830                    incoming,
1831                    &counts,
1832                );
1833                let current = counts.get(&edge.target).copied().unwrap_or(0);
1834                if next > current {
1835                    counts.insert(edge.target.clone(), next);
1836                    pending.push(edge.target.clone());
1837                }
1838            }
1839        }
1840        counts
1841    }
1842
1843    fn recompute_boundary_occurrences(
1844        fragment_id: &str,
1845        root: &str,
1846        incoming: &HashMap<&str, Vec<&str>>,
1847        counts: &HashMap<String, u8>,
1848    ) -> u8 {
1849        let mut count = u8::from(fragment_id == root);
1850        let Some(parents) = incoming.get(fragment_id) else {
1851            return count;
1852        };
1853        for parent in parents {
1854            let parent_count = counts.get(*parent).copied().unwrap_or(0);
1855            count = count.saturating_add(parent_count).min(2);
1856            if count == 2 {
1857                break;
1858            }
1859        }
1860        count
1861    }
1862
1863    /// Locate the lowest-numbered declaration a `<for>` repeat body reaches.
1864    ///
1865    /// Reachability is a single multi-source forward walk seeded with every
1866    /// repeat body record, so a graph with many loops still costs one pass. It
1867    /// follows the same component/condition/loop/route edges the presence
1868    /// analysis uses, plus the route-child records an enclosing route mounts at
1869    /// its component's `<outlet />` — the one runtime edge that has no
1870    /// lexical counterpart.
1871    fn find_repeat_reachable_boundary(&self) -> Option<RepeatBoundaryViolation> {
1872        let mut origin: HashMap<&str, &str> = HashMap::new();
1873        let mut pending: Vec<&str> = Vec::new();
1874        let mut repeats: HashMap<&str, RepeatSite<'_>> = HashMap::new();
1875        let mut seeds: Vec<(&str, RepeatSite<'_>)> = Vec::new();
1876
1877        for (owner, list) in &self.fragment_records {
1878            for fragment in &list.fragments {
1879                let Some(Fragment::ForLoop(for_loop)) = fragment.fragment.as_ref() else {
1880                    continue;
1881                };
1882                if for_loop.fragment_id.is_empty() {
1883                    continue;
1884                }
1885                let body = for_loop.fragment_id.as_str();
1886                seeds.push((
1887                    body,
1888                    RepeatSite {
1889                        owner: owner.as_str(),
1890                        item: for_loop.item.as_str(),
1891                        collection: for_loop.collection.as_str(),
1892                    },
1893                ));
1894            }
1895        }
1896        seeds.sort_unstable_by(|(left_body, left), (right_body, right)| {
1897            (left.owner, left.item, left.collection, *left_body).cmp(&(
1898                right.owner,
1899                right.item,
1900                right.collection,
1901                *right_body,
1902            ))
1903        });
1904        for (body, site) in seeds {
1905            if !origin.contains_key(body) {
1906                repeats.insert(body, site);
1907                origin.insert(body, body);
1908                pending.push(body);
1909            }
1910        }
1911        // `pending` is a LIFO stack. Reverse the sorted seeds so the lowest
1912        // repeat site owns every shared reachable record deterministically.
1913        pending.reverse();
1914        if pending.is_empty() {
1915            return None;
1916        }
1917
1918        let mounts = self.route_mount_targets();
1919        let mut found: Option<(u32, &str, &WebUiFragmentBoundary)> = None;
1920        let mut targets: Vec<&str> = Vec::new();
1921        while let Some(id) = pending.pop() {
1922            let Some(seed) = origin.get(id).copied() else {
1923                continue;
1924            };
1925            let Some(list) = self.fragment_records.get(id) else {
1926                continue;
1927            };
1928            for fragment in &list.fragments {
1929                if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
1930                    if boundary.phase() == BoundaryPhase::Start
1931                        && found
1932                            .as_ref()
1933                            .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1934                    {
1935                        found = Some((boundary.declaration_id, seed, boundary));
1936                    }
1937                }
1938            }
1939            targets.clear();
1940            push_child_records(list, &mut targets);
1941            if let Some(mounted) = mounts.get(id) {
1942                targets.extend(mounted.iter().copied());
1943            }
1944            for target in targets.drain(..) {
1945                if target.is_empty() {
1946                    continue;
1947                }
1948                if !origin.contains_key(target) {
1949                    origin.insert(target, seed);
1950                    pending.push(target);
1951                }
1952            }
1953        }
1954
1955        found.map(|(_, seed, boundary)| {
1956            let site = repeats.get(seed);
1957            RepeatBoundaryViolation {
1958                repeat_owner: site.map_or_else(String::new, |site| site.owner.to_string()),
1959                each: site.map_or_else(String::new, |site| {
1960                    format!("{} in {}", site.item, site.collection)
1961                }),
1962                boundary_owner: boundary.owner_fragment_id.clone(),
1963                boundary_name: boundary.name.clone(),
1964            }
1965        })
1966    }
1967
1968    /// Map every `<outlet />`-hosting record to the child-route records a
1969    /// route can mount there.
1970    ///
1971    /// `<outlet />` is the only render edge with no lexical counterpart: the
1972    /// mounted record is chosen from the enclosing route's children at request
1973    /// time. Resolving it needs the parent route's render closure, so the map
1974    /// is built once and reused by the repeat walk instead of being recomputed
1975    /// per outlet.
1976    fn route_mount_targets(&self) -> HashMap<&str, Vec<&str>> {
1977        let outlet_hosts = self.outlet_host_records();
1978        if outlet_hosts.is_empty() {
1979            return HashMap::new();
1980        }
1981
1982        let mut mounts: HashMap<&str, Vec<&str>> = HashMap::new();
1983        let mut children: Vec<&str> = Vec::new();
1984        let mut closure: HashSet<&str> = HashSet::new();
1985        let mut pending: Vec<&str> = Vec::new();
1986        let mut targets: Vec<&str> = Vec::new();
1987        for list in self.fragment_records.values() {
1988            for fragment in &list.fragments {
1989                let Some(Fragment::Route(root)) = fragment.fragment.as_ref() else {
1990                    continue;
1991                };
1992                let mut routes = vec![root];
1993                while let Some(route) = routes.pop() {
1994                    routes.extend(route.children.iter());
1995                    if route.children.is_empty() {
1996                        continue;
1997                    }
1998                    children.clear();
1999                    for child in &route.children {
2000                        push_route_records(&mut children, child);
2001                    }
2002                    closure.clear();
2003                    pending.clear();
2004                    for host in [
2005                        route.fragment_id.as_str(),
2006                        route.content_fragment_id.as_str(),
2007                    ] {
2008                        if !host.is_empty() && closure.insert(host) {
2009                            pending.push(host);
2010                        }
2011                    }
2012                    while let Some(id) = pending.pop() {
2013                        if outlet_hosts.contains(id) {
2014                            mounts
2015                                .entry(id)
2016                                .or_default()
2017                                .extend(children.iter().copied());
2018                        }
2019                        let Some(list) = self.fragment_records.get(id) else {
2020                            continue;
2021                        };
2022                        targets.clear();
2023                        push_child_records(list, &mut targets);
2024                        for target in targets.drain(..) {
2025                            if !target.is_empty() && closure.insert(target) {
2026                                pending.push(target);
2027                            }
2028                        }
2029                    }
2030                }
2031            }
2032        }
2033        mounts
2034    }
2035
2036    /// Records that render an `<outlet />`.
2037    fn outlet_host_records(&self) -> HashSet<&str> {
2038        self.fragment_records
2039            .iter()
2040            .filter(|(_, list)| {
2041                list.fragments
2042                    .iter()
2043                    .any(|fragment| matches!(fragment.fragment.as_ref(), Some(Fragment::Outlet(_))))
2044            })
2045            .map(|(id, _)| id.as_str())
2046            .collect()
2047    }
2048
2049    /// Locate the lowest-numbered declaration whose inline body reaches another
2050    /// boundary through a component, condition, loop, or route record.
2051    ///
2052    /// Lexical nesting is already rejected while parsing, so this only has to
2053    /// look at the child records referenced between a start and end marker.
2054    fn find_transitively_nested_boundary(&self) -> Option<(String, String)> {
2055        let mut nested: Option<(u32, &str, &str)> = None;
2056        for list in self.fragment_records.values() {
2057            let mut active: Option<&WebUiFragmentBoundary> = None;
2058            for fragment in &list.fragments {
2059                let Some(boundary) = active else {
2060                    if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
2061                        if boundary.phase() == BoundaryPhase::Start {
2062                            active = Some(boundary);
2063                        }
2064                    }
2065                    continue;
2066                };
2067                if let Some(Fragment::Boundary(marker)) = fragment.fragment.as_ref() {
2068                    if marker.phase() == BoundaryPhase::End {
2069                        active = None;
2070                    }
2071                    continue;
2072                }
2073                if !self.fragment_reaches_boundary(fragment) {
2074                    continue;
2075                }
2076                if nested
2077                    .as_ref()
2078                    .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
2079                {
2080                    nested = Some((
2081                        boundary.declaration_id,
2082                        boundary.owner_fragment_id.as_str(),
2083                        boundary.name.as_str(),
2084                    ));
2085                }
2086            }
2087        }
2088        nested.map(|(_, owner, name)| (owner.to_string(), name.to_string()))
2089    }
2090
2091    /// Whether a fragment inside a boundary body reaches a nested declaration
2092    /// through one of its child records.
2093    fn fragment_reaches_boundary(&self, fragment: &WebUIFragment) -> bool {
2094        let reaches = |id: &str| {
2095            !id.is_empty()
2096                && self
2097                    .fragment_records
2098                    .get(id)
2099                    .is_some_and(|list| list.contains_boundary)
2100        };
2101        match fragment.fragment.as_ref() {
2102            Some(Fragment::Component(component)) => reaches(&component.fragment_id),
2103            Some(Fragment::ForLoop(for_loop)) => reaches(&for_loop.fragment_id),
2104            Some(Fragment::IfCond(if_cond)) => reaches(&if_cond.fragment_id),
2105            Some(Fragment::Route(route)) => {
2106                let mut pending = vec![route];
2107                while let Some(current) = pending.pop() {
2108                    if reaches(&current.fragment_id)
2109                        || reaches(&current.content_fragment_id)
2110                        || reaches(&current.pending_component)
2111                        || reaches(&current.error_component)
2112                    {
2113                        return true;
2114                    }
2115                    pending.extend(current.children.iter());
2116                }
2117                false
2118            }
2119            _ => false,
2120        }
2121    }
2122
2123    #[cold]
2124    #[inline(never)]
2125    fn transitively_nested_boundary_error(owner: &str, name: &str) -> ParserError {
2126        Diagnostic::error(format!(
2127            "boundary \"{name}\" transitively contains another boundary"
2128        ))
2129        .code(codes::NESTED_BOUNDARY)
2130        .component(owner)
2131        .element("boundary")
2132        .snippet(format!("name=\"{name}\""))
2133        .help("remove the inner declaration or move it outside the enclosing boundary, including declarations reached through components and runtime branches")
2134        .into()
2135    }
2136
2137    /// Build the error for a boundary a `<for>` repeat body reaches through a
2138    /// component, condition, route, or outlet.
2139    #[cold]
2140    #[inline(never)]
2141    fn repeat_reachable_boundary_error(violation: &RepeatBoundaryViolation) -> ParserError {
2142        let RepeatBoundaryViolation {
2143            repeat_owner,
2144            each,
2145            boundary_owner,
2146            boundary_name,
2147        } = violation;
2148        Diagnostic::error(format!(
2149            "boundary \"{boundary_name}\" in {boundary_owner} is reachable from a <for> repeat body"
2150        ))
2151        .code(codes::BOUNDARY_IN_REPEAT)
2152        .component(repeat_owner)
2153        .element("for")
2154        .snippet(format!("each=\"{each}\""))
2155        .help(
2156            "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
2157             whole <for> in one <boundary> to render the list as a single independently paced \
2158             region — this includes declarations reached through components, runtime branches, \
2159             routes, and outlets",
2160        )
2161        .into()
2162    }
2163
2164    /// `src` of every authored `<script type="module">` outside any
2165    /// `<boundary>`, in document order, from the most recent top-level
2166    /// [`HtmlParser::parse`] call.
2167    ///
2168    /// These are the page's critical module entries — the ones whose shared
2169    /// chunks belong in `<link rel="modulepreload">`. Island loaders placed
2170    /// inside a boundary are excluded, because deferring them is the point.
2171    ///
2172    /// A `src` containing `{{` is skipped: it is a binding resolved per
2173    /// request, so no build-time artifact can be matched to it.
2174    #[must_use]
2175    pub fn module_entry_srcs(&self) -> &[String] {
2176        &self.module_entry_srcs
2177    }
2178
2179    /// Iterate component tags and whether each effectively uses Shadow DOM.
2180    ///
2181    /// This remains available independently of parser plugins so protocol
2182    /// builders can persist effective per-component ownership in plugin-free builds.
2183    pub fn component_shadow_dom_usage(&self) -> impl Iterator<Item = (&str, bool)> {
2184        self.component_dom_analyses
2185            .iter()
2186            .map(|(tag_name, analysis)| (tag_name.as_str(), analysis.uses_shadow_dom))
2187    }
2188
2189    /// Iterate non-eager component work-policy codes.
2190    pub fn component_work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
2191        self.component_registry.work_policies()
2192    }
2193
2194    /// Take any post-parse artifacts captured by the parser plugin.
2195    ///
2196    /// # Errors
2197    ///
2198    /// Returns [`ParserError::Template`] if a tracked component contains an
2199    /// invalid `@event` handler or a non-braced `w-ref` binding.
2200    pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
2201        self.plugin
2202            .take()
2203            .map_or(Ok(ParserPluginArtifacts::None), |plugin| plugin.finish())
2204    }
2205
2206    /// Take the accumulated CSS tokens as a sorted, deduplicated `Vec`.
2207    ///
2208    /// Convenience wrapper around [`HtmlParser::token_analysis`].
2209    #[must_use]
2210    pub fn take_tokens(&mut self) -> Vec<String> {
2211        self.token_analysis().protocol_tokens
2212    }
2213
2214    /// Analyze CSS token requirements from the parsed fragment/component graph.
2215    ///
2216    /// Each token candidate in a `var()` fallback chain is removed when covered
2217    /// by a same-block definition or an unconditional root/host default in the
2218    /// current fragment or an ancestor component/root. The returned list is
2219    /// sorted and deduplicated from the remaining unresolved candidates.
2220    #[must_use]
2221    pub fn token_analysis(&self) -> CssTokenAnalysis {
2222        let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
2223        let mut protocol_token_set = HashSet::new();
2224        for chain in &fallback_chains {
2225            for token in &chain.tokens {
2226                protocol_token_set.insert(token.as_str());
2227            }
2228        }
2229        let mut protocol_tokens: Vec<String> =
2230            protocol_token_set.into_iter().map(str::to_owned).collect();
2231        protocol_tokens.sort();
2232        CssTokenAnalysis {
2233            protocol_tokens,
2234            fallback_chains,
2235            token_sites,
2236        }
2237    }
2238
2239    fn collect_unresolved_fallback_chains(
2240        &self,
2241    ) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
2242        let mut out = UnresolvedTokens::default();
2243        let mut available_counts: HashMap<&str, usize> = HashMap::new();
2244        let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
2245        let mut active_fragments = HashSet::new();
2246        let mut visits =
2247            (!self.named_for_fragment_ids.is_empty()).then(scoped_visits::ScopedVisits::default);
2248        for root in self.token_roots.iter().rev() {
2249            ops.push(TokenGraphOp::EnterFragment(root.as_str()));
2250        }
2251
2252        while let Some(op) = ops.pop() {
2253            match op {
2254                TokenGraphOp::EnterFragment(fragment_id) => {
2255                    if let Some(visits) = &mut visits {
2256                        if active_fragments.contains(fragment_id)
2257                            || !visits.insert(fragment_id, available_counts.keys().copied())
2258                        {
2259                            continue;
2260                        }
2261                        active_fragments.insert(fragment_id);
2262                        ops.push(TokenGraphOp::ExitFragment(fragment_id));
2263                    }
2264                    self.enter_token_fragment(
2265                        fragment_id,
2266                        &mut available_counts,
2267                        &mut out,
2268                        &mut ops,
2269                    );
2270                }
2271                TokenGraphOp::ExitFragment(fragment_id) => {
2272                    active_fragments.remove(fragment_id);
2273                }
2274                TokenGraphOp::EnterComponent(tag_name) => {
2275                    self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
2276                }
2277                TokenGraphOp::EnterRoute(route) => {
2278                    self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
2279                }
2280                TokenGraphOp::ExitDefinitions(definitions) => {
2281                    remove_token_definitions(definitions, &mut available_counts);
2282                }
2283            }
2284        }
2285
2286        (out.chains, out.sites)
2287    }
2288
2289    fn enter_token_fragment<'a>(
2290        &'a self,
2291        fragment_id: &'a str,
2292        available_counts: &mut HashMap<&'a str, usize>,
2293        out: &mut UnresolvedTokens,
2294        ops: &mut Vec<TokenGraphOp<'a>>,
2295    ) {
2296        if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
2297            add_token_definitions(&css.definitions, available_counts);
2298            // Inline `<style>` tokens record their owning fragment (the entry
2299            // file). A fragment may carry several `<style>` blocks, so an
2300            // offset into one body is ambiguous — record the owner only.
2301            record_unresolved_requirements(
2302                &css.fallback_chains,
2303                available_counts,
2304                fragment_id,
2305                None,
2306                out,
2307            );
2308            ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
2309        }
2310
2311        let Some(fragments) = self.fragment_records.get(fragment_id) else {
2312            return;
2313        };
2314        for fragment in fragments.fragments.iter().rev() {
2315            match fragment.fragment.as_ref() {
2316                Some(web_ui_fragment::Fragment::Component(component)) => {
2317                    ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
2318                }
2319                Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
2320                    ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
2321                }
2322                Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
2323                    ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
2324                }
2325                Some(web_ui_fragment::Fragment::Route(route)) => {
2326                    ops.push(TokenGraphOp::EnterRoute(route));
2327                }
2328                _ => {}
2329            }
2330        }
2331    }
2332
2333    fn enter_token_component<'a>(
2334        &'a self,
2335        tag_name: &'a str,
2336        available_counts: &mut HashMap<&'a str, usize>,
2337        out: &mut UnresolvedTokens,
2338        ops: &mut Vec<TokenGraphOp<'a>>,
2339    ) {
2340        let Some(component) = self.component_registry.get(tag_name) else {
2341            return;
2342        };
2343        add_token_definitions(&component.css_definitions, available_counts);
2344        record_unresolved_requirements(
2345            &component.css_fallback_chains,
2346            available_counts,
2347            &css_owner_label(tag_name),
2348            self.component_registry.diagnostic_css_content(tag_name),
2349            out,
2350        );
2351        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2352        ops.push(TokenGraphOp::EnterFragment(tag_name));
2353    }
2354
2355    fn enter_token_route<'a>(
2356        &'a self,
2357        route: &'a WebUiFragmentRoute,
2358        available_counts: &mut HashMap<&'a str, usize>,
2359        out: &mut UnresolvedTokens,
2360        ops: &mut Vec<TokenGraphOp<'a>>,
2361    ) {
2362        if !route.content_fragment_id.is_empty() {
2363            ops.push(TokenGraphOp::EnterFragment(
2364                route.content_fragment_id.as_str(),
2365            ));
2366        }
2367        let Some(component) = self.component_registry.get(&route.fragment_id) else {
2368            return;
2369        };
2370        add_token_definitions(&component.css_definitions, available_counts);
2371        record_unresolved_requirements(
2372            &component.css_fallback_chains,
2373            available_counts,
2374            &css_owner_label(&route.fragment_id),
2375            self.component_registry
2376                .diagnostic_css_content(&route.fragment_id),
2377            out,
2378        );
2379        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2380        if !route.error_component.is_empty() {
2381            ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
2382        }
2383        if !route.pending_component.is_empty() {
2384            ops.push(TokenGraphOp::EnterComponent(
2385                route.pending_component.as_str(),
2386            ));
2387        }
2388        for child in route.children.iter().rev() {
2389            ops.push(TokenGraphOp::EnterRoute(child));
2390        }
2391        ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
2392    }
2393
2394    fn record_fragment_css_tokens(
2395        &mut self,
2396        definitions: HashSet<String>,
2397        fallback_chains: Vec<CssFallbackChain>,
2398    ) {
2399        let css = self
2400            .fragment_css_tokens
2401            .entry(self.current_fragment_id.clone())
2402            .or_default();
2403        css.definitions.extend(definitions);
2404        css.definitions.sort();
2405        css.definitions.dedup();
2406        css.fallback_chains.extend(fallback_chains);
2407    }
2408
2409    /// Parse HTML content to generate WebUI fragments.
2410    pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2411        let fragment_key = fragment_id.to_string();
2412        let is_token_root = self.in_progress_fragments.is_empty();
2413        if is_token_root {
2414            self.module_entry_srcs.clear();
2415        }
2416        self.boundary_names_by_owner.remove(&fragment_key);
2417        // Save the caller's fragment id and restore it before returning. A
2418        // component parse recurses through `enter_component_directive`
2419        // (`self.parse(child, …)`); without restoring, the parent would keep
2420        // parsing with `current_fragment_id` pointing at the child, so a later
2421        // diagnostic in the parent would be attributed to the wrong owner.
2422        let previous_fragment_id =
2423            std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
2424        if !self.in_progress_fragments.insert(fragment_key.clone()) {
2425            let err = self
2426                .authoring_error(
2427                    codes::RECURSIVE_TEMPLATE,
2428                    format!("recursive template reference while parsing <{fragment_id}>"),
2429                )
2430                .help(
2431                    "move the recursive usage behind runtime data, or split the component graph \
2432                     so templates do not reference themselves at build time",
2433                )
2434                .into();
2435            self.current_fragment_id = previous_fragment_id;
2436            return Err(err);
2437        }
2438        if is_token_root && !self.token_roots.contains(&fragment_key) {
2439            self.token_roots.push(fragment_key.clone());
2440        }
2441
2442        // Lexical directive/HTML context belongs to one authored template.
2443        // Component parsing may recurse while its callsite is inside a loop or
2444        // boundary, but those callsite properties are computed later by the
2445        // fragment graph analysis rather than leaking into the component's
2446        // authored syntax.
2447        let previous_in_boundary = std::mem::replace(&mut self.in_boundary, false);
2448        let previous_boundary_ancestor_depth = self.boundary_ancestor_depth;
2449        if previous_in_boundary {
2450            self.boundary_ancestor_depth += 1;
2451        }
2452        let previous_body_depth = std::mem::replace(&mut self.body_depth, 0);
2453        let previous_for_depth = std::mem::replace(&mut self.for_depth, 0);
2454        let previous_route_depth = std::mem::replace(&mut self.route_depth, 0);
2455        let previous_foster_depth = std::mem::replace(&mut self.foster_context_depth, 0);
2456        let previous_parent_scope = self.boundary_parent_scope.take();
2457        let previous_named_for_loops = std::mem::take(&mut self.named_for_loops);
2458        let owner_index = self.loop_owner_indices.len() + 1;
2459        self.loop_owner_indices
2460            .entry(fragment_key.clone())
2461            .or_insert(owner_index);
2462
2463        let mut result = self.parse_inner(fragment_id, html_content);
2464        if is_token_root && result.is_ok() {
2465            result = self.finalize_boundary_metadata();
2466        }
2467
2468        self.in_boundary = previous_in_boundary;
2469        self.boundary_ancestor_depth = previous_boundary_ancestor_depth;
2470        self.body_depth = previous_body_depth;
2471        self.for_depth = previous_for_depth;
2472        self.route_depth = previous_route_depth;
2473        self.foster_context_depth = previous_foster_depth;
2474        self.boundary_parent_scope = previous_parent_scope;
2475        self.named_for_loops = previous_named_for_loops;
2476        self.in_progress_fragments.remove(&fragment_key);
2477        self.current_fragment_id = previous_fragment_id;
2478        result
2479    }
2480
2481    fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2482        if html_content.len() > MAX_TEMPLATE_BYTES {
2483            return Err(ParserError::Html(format!(
2484                "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.",
2485                html_content.len()
2486            )));
2487        }
2488
2489        // Reset sub-fragments for new parse
2490        self.raw_buffer.clear();
2491        if let Some(ref mut plugin) = self.plugin {
2492            plugin.begin_fragment(FragmentContext { id: fragment_id });
2493        }
2494
2495        let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
2496        self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
2497        self.named_for_loops.validate(fragment_id, html_content)?;
2498
2499        self.flush_raw_buffer(&mut entry_fragment);
2500
2501        self.fragment_records.insert(
2502            fragment_id.to_string(),
2503            FragmentList {
2504                fragments: entry_fragment,
2505                contains_boundary: false,
2506            },
2507        );
2508
2509        Ok(())
2510    }
2511
2512    /// Add raw content to the buffer
2513    fn add_raw_fragment(&mut self, content: &str) {
2514        if !content.is_empty() {
2515            self.raw_buffer.push_str(content);
2516        }
2517    }
2518
2519    /// Add a for fragment, flushing raw buffer first
2520    fn add_for_fragment(
2521        &mut self,
2522        item: String,
2523        collection: String,
2524        fragment_id: String,
2525        fragments: &mut Vec<WebUIFragment>,
2526    ) {
2527        self.flush_raw_buffer(fragments);
2528        fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
2529    }
2530
2531    /// Add an if fragment, flushing raw buffer first
2532    fn add_if_fragment(
2533        &mut self,
2534        condition: ConditionExpr,
2535        fragment_id: String,
2536        fragments: &mut Vec<WebUIFragment>,
2537    ) {
2538        self.flush_raw_buffer(fragments);
2539        fragments.push(WebUIFragment::if_cond(condition, fragment_id));
2540    }
2541
2542    /// Add a non-raw fragment, flushing the raw buffer first if needed
2543    fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
2544        self.flush_raw_buffer(fragments);
2545        fragments.push(fragment);
2546    }
2547
2548    /// Flush the raw buffer into fragments if not empty
2549    fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
2550        if !self.raw_buffer.is_empty() {
2551            fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
2552        }
2553    }
2554
2555    /// Returns true when a text node should be emitted into the fragment stream.
2556    ///
2557    /// Pure formatting runs that contain line breaks are still dropped, but
2558    /// inline whitespace-only separators such as the spaces around `&gt;` in
2559    /// `{{sectionName}} &gt; {{topicName}}` must be preserved.
2560    fn should_emit_text_content(content: &str) -> bool {
2561        if content.is_empty() {
2562            return false;
2563        }
2564
2565        if !content.trim().is_empty() {
2566            return true;
2567        }
2568
2569        content.chars().all(char::is_whitespace)
2570            && !content.contains('\n')
2571            && !content.contains('\r')
2572    }
2573
2574    fn parse_range(
2575        &mut self,
2576        source: &str,
2577        range: Range<usize>,
2578        fragments: &mut Vec<WebUIFragment>,
2579        depth: usize,
2580    ) -> Result<()> {
2581        let mut ops = vec![ParseOp::Parse { range, depth }];
2582
2583        while let Some(op) = ops.pop() {
2584            match op {
2585                ParseOp::Parse { range, depth } => {
2586                    if depth > MAX_TEMPLATE_DEPTH {
2587                        return Err(self
2588                            .html_error(
2589                                codes::EXCESSIVE_NESTING,
2590                                format!(
2591                                    "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
2592                                ),
2593                                source,
2594                                range.start,
2595                            )
2596                            .help(
2597                                "split deeply nested markup into components, or reduce generated nesting",
2598                            )
2599                            .into());
2600                    }
2601
2602                    let end = range.end;
2603                    let mut index = range.start;
2604                    while index < end {
2605                        let remaining = &source[index..end];
2606                        if remaining.starts_with("<!--") {
2607                            let Some(close) = html::find_comment_close(remaining) else {
2608                                return Err(self
2609                                    .html_error(
2610                                        codes::UNTERMINATED_HTML_COMMENT,
2611                                        "unterminated HTML comment",
2612                                        source,
2613                                        index,
2614                                    )
2615                                    .help("close the comment with `-->`")
2616                                    .into());
2617                            };
2618                            index += close;
2619                            continue;
2620                        }
2621
2622                        if remaining.starts_with("<!") {
2623                            let Some(close) = html::find_declaration_close(remaining) else {
2624                                return Err(self
2625                                    .html_error(
2626                                        codes::UNTERMINATED_HTML_DECLARATION,
2627                                        "unterminated HTML declaration",
2628                                        source,
2629                                        index,
2630                                    )
2631                                    .help("close the declaration with `>`")
2632                                    .into());
2633                            };
2634                            self.add_raw_fragment(&remaining[..close]);
2635                            index += close;
2636                            continue;
2637                        }
2638
2639                        if remaining.starts_with('<') {
2640                            let Some(tag) = html::parse_tag(remaining) else {
2641                                return Err(self
2642                                    .html_error(
2643                                        codes::MALFORMED_HTML_TAG,
2644                                        "malformed HTML tag",
2645                                        source,
2646                                        index,
2647                                    )
2648                                    .help(
2649                                        "close the tag with `>`, or escape a literal `<` as `&lt;`",
2650                                    )
2651                                    .into());
2652                            };
2653                            if tag.closing {
2654                                return Err(self
2655                                    .html_error(
2656                                        codes::UNEXPECTED_CLOSING_TAG,
2657                                        format!("unexpected closing tag </{}>", tag.name),
2658                                        source,
2659                                        index,
2660                                    )
2661                                    .help("remove it, or add the matching opening tag before it")
2662                                    .into());
2663                            }
2664
2665                            let content_start = index + tag.close + 1;
2666                            let (content_end, close_end) = if tag.self_closing
2667                                || html::is_void_element(tag.name)
2668                            {
2669                                (content_start, content_start)
2670                            } else if let Some((close_start, close_end)) =
2671                                html::find_matching_end(remaining, tag.name, tag.close + 1)
2672                            {
2673                                (index + close_start, index + close_end)
2674                            } else {
2675                                return Err(self
2676                                    .html_error(
2677                                        codes::UNCLOSED_HTML_TAG,
2678                                        format!("unclosed <{}> tag", tag.name),
2679                                        source,
2680                                        index,
2681                                    )
2682                                    .help(format!(
2683                                        "add the matching </{}> closing tag, or make the element self-closing",
2684                                        tag.name
2685                                    ))
2686                                    .into());
2687                            };
2688
2689                            let element = Element {
2690                                source,
2691                                start: index,
2692                                tag,
2693                                content_start,
2694                                content_end,
2695                                close_end,
2696                            };
2697                            if close_end < end {
2698                                ops.push(ParseOp::Parse {
2699                                    range: close_end..end,
2700                                    depth,
2701                                });
2702                            }
2703
2704                            match element.name() {
2705                                "for" => {
2706                                    self.enter_for_directive(&element, fragments, depth, &mut ops)?;
2707                                }
2708                                "if" => {
2709                                    self.enter_if_directive(&element, fragments, depth, &mut ops)?;
2710                                }
2711                                "route" => {
2712                                    self.process_route_directive(&element, fragments, depth)?;
2713                                }
2714                                "outlet" => {
2715                                    self.flush_raw_buffer(fragments);
2716                                    fragments.push(WebUIFragment::outlet());
2717                                }
2718                                "boundary" => {
2719                                    self.enter_boundary_directive(
2720                                        &element, fragments, depth, &mut ops,
2721                                    )?;
2722                                }
2723                                name if name.eq_ignore_ascii_case("body") => {
2724                                    self.enter_body_element(&element, fragments, depth, &mut ops)?;
2725                                }
2726                                name if name.eq_ignore_ascii_case("head") => {
2727                                    self.enter_head_element(&element, fragments, depth, &mut ops)?;
2728                                }
2729                                name if name.eq_ignore_ascii_case("webui-hydrate") => {
2730                                    return Err(self.authored_webui_hydrate_error(&element));
2731                                }
2732                                name if name.eq_ignore_ascii_case("style") => {
2733                                    self.process_style_element(&element, fragments)?;
2734                                }
2735                                _ if self.component_registry.contains(element.name()) => {
2736                                    self.enter_component_directive(
2737                                        &element, fragments, depth, &mut ops,
2738                                    )?;
2739                                }
2740                                _ => {
2741                                    self.check_component_typo(&element)?;
2742                                    self.enter_regular_element(
2743                                        &element, fragments, depth, &mut ops,
2744                                    )?;
2745                                }
2746                            }
2747
2748                            break;
2749                        }
2750
2751                        let next = remaining.find('<').unwrap_or(remaining.len());
2752                        let text_end = if next == 0 {
2753                            remaining
2754                                .chars()
2755                                .next()
2756                                .map_or(remaining.len(), char::len_utf8)
2757                        } else {
2758                            next
2759                        };
2760                        self.process_text(&remaining[..text_end], fragments)?;
2761                        index += text_end;
2762                    }
2763                }
2764                ParseOp::EmitClose(name) => {
2765                    self.add_raw_fragment("</");
2766                    self.add_raw_fragment(name);
2767                    self.add_raw_fragment(">");
2768                }
2769                ParseOp::EndComponent(name) => {
2770                    self.add_raw_fragment("</");
2771                    self.add_raw_fragment(name);
2772                    self.add_raw_fragment(">");
2773                    self.flush_raw_buffer(fragments);
2774                    fragments.push(structural_signal(format!("streaming_span_end:{name}")));
2775                }
2776                ParseOp::EndHead(name) => {
2777                    self.flush_raw_buffer(fragments);
2778                    fragments.push(structural_signal("head_end"));
2779                    self.add_raw_fragment("</");
2780                    self.add_raw_fragment(name);
2781                    self.add_raw_fragment(">");
2782                }
2783                ParseOp::EndBody(name) => {
2784                    self.flush_raw_buffer(fragments);
2785                    fragments.push(structural_signal("body_end"));
2786                    self.add_raw_fragment("</");
2787                    self.add_raw_fragment(name);
2788                    self.add_raw_fragment(">");
2789                    self.body_depth = self.body_depth.saturating_sub(1);
2790                }
2791                ParseOp::CompleteBoundary { boundary } => {
2792                    self.flush_raw_buffer(fragments);
2793                    fragments.push(WebUIFragment::boundary_end(boundary.declaration_id));
2794                    self.in_boundary = false;
2795                }
2796                ParseOp::RestoreFosterDepth(previous) => {
2797                    self.foster_context_depth = previous;
2798                }
2799                ParseOp::RestoreBoundaryParentScope(previous) => {
2800                    self.boundary_parent_scope = previous;
2801                }
2802                ParseOp::CompleteFor {
2803                    parent,
2804                    item,
2805                    collection,
2806                    fragment_id,
2807                    named_definition,
2808                    previous_for_depth,
2809                } => {
2810                    self.for_depth = previous_for_depth;
2811                    self.flush_raw_buffer(fragments);
2812                    let for_fragment = std::mem::take(fragments);
2813                    *fragments = parent.fragments;
2814                    self.raw_buffer = parent.raw_buffer;
2815
2816                    if !for_fragment.is_empty() || named_definition {
2817                        self.fragment_records.insert(
2818                            fragment_id.clone(),
2819                            FragmentList {
2820                                fragments: for_fragment,
2821                                contains_boundary: false,
2822                            },
2823                        );
2824                    } else {
2825                        continue;
2826                    }
2827
2828                    self.add_for_fragment(item, collection, fragment_id, fragments);
2829                }
2830                ParseOp::CompleteIf {
2831                    parent,
2832                    condition,
2833                    fragment_id,
2834                } => {
2835                    self.flush_raw_buffer(fragments);
2836                    let if_fragment = std::mem::take(fragments);
2837                    *fragments = parent.fragments;
2838                    self.raw_buffer = parent.raw_buffer;
2839
2840                    self.fragment_records.insert(
2841                        fragment_id.clone(),
2842                        FragmentList {
2843                            fragments: if_fragment,
2844                            contains_boundary: false,
2845                        },
2846                    );
2847                    self.add_if_fragment(condition, fragment_id, fragments);
2848                }
2849            }
2850        }
2851
2852        Ok(())
2853    }
2854
2855    fn enter_regular_element<'a>(
2856        &mut self,
2857        element: &Element<'a>,
2858        fragments: &mut Vec<WebUIFragment>,
2859        depth: usize,
2860        ops: &mut Vec<ParseOp<'a>>,
2861    ) -> Result<()> {
2862        if !self.in_boundary
2863            && self.boundary_ancestor_depth == 0
2864            && element.name().eq_ignore_ascii_case("script")
2865        {
2866            self.record_module_entry(element);
2867        }
2868        self.add_raw_fragment("<");
2869        self.add_raw_fragment(element.name());
2870
2871        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2872        if let Some(ref mut p) = self.plugin {
2873            if let Some(data) = p.finish_opening_tag(ElementStartContext {
2874                tag_name: element.name(),
2875                binding_count,
2876            }) {
2877                self.add_fragment(WebUIFragment::plugin(data), fragments);
2878            }
2879        }
2880
2881        if element.self_closing() {
2882            self.add_raw_fragment("/>");
2883            return Ok(());
2884        }
2885
2886        self.add_raw_fragment(">");
2887        if element.name().eq_ignore_ascii_case("template")
2888            && element
2889                .attrs()
2890                .find(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
2891                .and_then(|attr| attr.value)
2892                .is_some_and(|mode| mode.eq_ignore_ascii_case("open"))
2893            && self
2894                .component_dom_analyses
2895                .get(&self.current_fragment_id)
2896                .is_some_and(|analysis| analysis.uses_shadow_dom)
2897        {
2898            // This compiler-owned no-DOM hook lets the server install the
2899            // complete tree-local style closure before hydratable children.
2900            // It is a protocol fragment rather than an HTML marker, so client
2901            // ordinal paths are unchanged.
2902            self.flush_raw_buffer(fragments);
2903            fragments.push(structural_signal(format!(
2904                "shadow_styles:{}",
2905                self.current_fragment_id
2906            )));
2907        }
2908        if !element.is_void() {
2909            if element.close_end() > element.content_end() {
2910                ops.push(ParseOp::EmitClose(element.name()));
2911            }
2912            self.enter_foster_context(element.name(), ops);
2913            self.enter_boundary_parent_scope(element, ops);
2914            ops.push(ParseOp::Parse {
2915                range: element.inner(),
2916                depth: depth + 1,
2917            });
2918        }
2919        Ok(())
2920    }
2921
2922    /// Record an authored `<script type="module" src="...">` as a critical
2923    /// module entry, so the build can preload the chunks it statically imports.
2924    ///
2925    /// Only bare, static `src` values qualify. A `{{binding}}` resolves per
2926    /// request and cannot be matched to a build artifact, and a script without
2927    /// `type="module"` has no ES module graph to preload. Both are skipped
2928    /// silently rather than diagnosed, because either is a perfectly valid
2929    /// thing to author — they just carry no preload information.
2930    ///
2931    /// The caller has already excluded scripts inside a `<boundary>`.
2932    fn record_module_entry(&mut self, element: &Element<'_>) {
2933        let Some(src) = element.attr("src") else {
2934            return;
2935        };
2936        if src.is_empty() || src.contains("{{") {
2937            return;
2938        }
2939        if element
2940            .attrs()
2941            .find(|attr| attr.name.eq_ignore_ascii_case("fetchpriority"))
2942            .and_then(|attr| attr.value)
2943            .is_some_and(|priority| priority.eq_ignore_ascii_case("low"))
2944        {
2945            return;
2946        }
2947        if !element
2948            .attr("type")
2949            .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module"))
2950        {
2951            return;
2952        }
2953        // Document order is meaningful and duplicates are near-impossible, so
2954        // this stays a `Vec` scan rather than paying for a set.
2955        if self.module_entry_srcs.iter().any(|seen| seen == src) {
2956            return;
2957        }
2958        self.module_entry_srcs.push(src.to_string());
2959    }
2960
2961    fn enter_head_element<'a>(
2962        &mut self,
2963        element: &Element<'a>,
2964        fragments: &mut Vec<WebUIFragment>,
2965        depth: usize,
2966        ops: &mut Vec<ParseOp<'a>>,
2967    ) -> Result<()> {
2968        if element.name() == "head" {
2969            // Preserve the lowercase legacy protocol shape exactly: authored
2970            // attributes on `<head>` were ignored and the opening was emitted
2971            // as the literal `<head>`.
2972            self.add_raw_fragment("<head>");
2973        } else {
2974            // origin/main treated mixed-case native tags as ordinary elements.
2975            // Keep their authored name and attributes while adding only the
2976            // separate case-insensitive streaming structure.
2977            self.add_raw_fragment("<");
2978            self.add_raw_fragment(element.name());
2979            let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2980            if let Some(ref mut p) = self.plugin {
2981                if let Some(data) = p.finish_opening_tag(ElementStartContext {
2982                    tag_name: element.name(),
2983                    binding_count,
2984                }) {
2985                    self.add_fragment(WebUIFragment::plugin(data), fragments);
2986                }
2987            }
2988            self.add_raw_fragment(">");
2989        }
2990        self.flush_raw_buffer(fragments);
2991        // Mirrors `body_start`: emitted immediately after the raw opening
2992        // `<head>` fragment and before any child content (including
2993        // authored async scripts), so `render_streaming` can preflight on it.
2994        // Raw signals unknown to the legacy (non-streaming) handler are
2995        // ignored there, so this is a no-op for existing output.
2996        fragments.push(structural_signal("head_start"));
2997        ops.push(ParseOp::EndHead(element.name()));
2998        ops.push(ParseOp::Parse {
2999            range: element.inner(),
3000            depth: depth + 1,
3001        });
3002        Ok(())
3003    }
3004
3005    fn enter_body_element<'a>(
3006        &mut self,
3007        element: &Element<'a>,
3008        fragments: &mut Vec<WebUIFragment>,
3009        depth: usize,
3010        ops: &mut Vec<ParseOp<'a>>,
3011    ) -> Result<()> {
3012        self.add_raw_fragment("<");
3013        self.add_raw_fragment(element.name());
3014        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
3015        if let Some(ref mut p) = self.plugin {
3016            if let Some(data) = p.finish_opening_tag(ElementStartContext {
3017                tag_name: element.name(),
3018                binding_count,
3019            }) {
3020                self.add_fragment(WebUIFragment::plugin(data), fragments);
3021            }
3022        }
3023        self.add_raw_fragment(">");
3024        self.flush_raw_buffer(fragments);
3025        fragments.push(structural_signal("body_start"));
3026        self.body_depth += 1;
3027        ops.push(ParseOp::EndBody(element.name()));
3028        ops.push(ParseOp::Parse {
3029            range: element.inner(),
3030            depth: depth + 1,
3031        });
3032        Ok(())
3033    }
3034
3035    /// Start an authoring [`Diagnostic`] for the template currently being
3036    /// parsed, naming the owning fragment (entry file or component tag) when
3037    /// known. `code` is the stable machine-readable [`code`](crate::diagnostic::codes).
3038    ///
3039    /// Marked `#[cold]`/`#[inline(never)]`: this only runs while *building* a
3040    /// build error, so keeping it out-of-line preserves hot parse-path layout.
3041    #[cold]
3042    #[inline(never)]
3043    fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
3044        let diag = Diagnostic::error(title).code(code);
3045        if self.current_fragment_id.is_empty() {
3046            diag
3047        } else {
3048            diag.component(self.current_fragment_id.clone())
3049        }
3050    }
3051
3052    /// Like [`HtmlParser::authoring_error`], but also records the source
3053    /// position (line/column) of `element` so the diagnostic can point at the
3054    /// exact spot in the template.
3055    #[cold]
3056    #[inline(never)]
3057    fn authoring_error_at(
3058        &self,
3059        code: &'static str,
3060        title: impl Into<String>,
3061        element: &Element<'_>,
3062    ) -> Diagnostic {
3063        self.authoring_error(code, title)
3064            .at_offset(element.source(), element.start)
3065    }
3066
3067    /// Build a positioned [`Diagnostic`] for a structural HTML well-formedness
3068    /// error (unclosed/malformed tag, unterminated comment, …), naming the
3069    /// owning fragment and the source position from a byte `offset`.
3070    #[cold]
3071    #[inline(never)]
3072    fn html_error(
3073        &self,
3074        code: &'static str,
3075        title: impl Into<String>,
3076        source: &str,
3077        offset: usize,
3078    ) -> Diagnostic {
3079        self.authoring_error(code, title).at_offset(source, offset)
3080    }
3081
3082    /// Build the `help:` line for an unknown component `<name>`.
3083    ///
3084    /// Offers the closest registered component as a "did you mean …?" fix when
3085    /// one is a near typo; otherwise falls back to the generic registration
3086    /// hint.
3087    #[cold]
3088    #[inline(never)]
3089    fn unknown_component_help(&self, name: &str) -> String {
3090        match suggest::closest_match(name, self.component_registry.names()) {
3091            Some(suggestion) => format!(
3092                "did you mean <{suggestion}>? otherwise register the component by adding a \
3093                 matching .html file"
3094            ),
3095            None => "register the component (add a matching .html file) or check the tag name \
3096                     for a typo"
3097                .to_string(),
3098        }
3099    }
3100
3101    /// Suggest a registered component that an unregistered custom-element `tag`
3102    /// likely mistypes.
3103    ///
3104    /// Only same-namespace candidates are considered — the text before the
3105    /// first `-` must match exactly — so a genuine third-party custom element
3106    /// (`<md-button>` when `<mp-button>` is registered) is never flagged, while
3107    /// an in-namespace slip (`<mp-buton>` → `<mp-button>`) is. Returns `None`
3108    /// for non-hyphenated (native) tags and when no near match exists.
3109    fn suggest_component(&self, tag: &str) -> Option<&str> {
3110        let (prefix, _) = tag.split_once('-')?;
3111        let same_namespace = self
3112            .component_registry
3113            .names()
3114            .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
3115        suggest::closest_match(tag, same_namespace)
3116    }
3117
3118    /// Error with a "did you mean …?" hint when `element` is an unregistered
3119    /// custom-element tag that closely matches a registered component in the
3120    /// same namespace. Genuine external custom elements pass through as raw
3121    /// HTML (returns `Ok`).
3122    fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
3123        if let Some(suggestion) = self.suggest_component(element.name()) {
3124            return Err(self
3125                .authoring_error_at(
3126                    codes::UNKNOWN_COMPONENT,
3127                    format!("unknown component <{}>", element.name()),
3128                    element,
3129                )
3130                .help(format!(
3131                    "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
3132                     .html file",
3133                    element.name()
3134                ))
3135                .into());
3136        }
3137        Ok(())
3138    }
3139
3140    /// Name of an attribute on `element` that looks like a typo of `expected`
3141    /// (close edit distance, not an exact match), if any. Used to suggest the
3142    /// intended directive attribute (e.g. `eahc` → `each`).
3143    #[cold]
3144    #[inline(never)]
3145    fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
3146        suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
3147            .filter(|&name| name != expected)
3148    }
3149
3150    /// Promote a standalone [`ParserError::Css`] into a structured authoring
3151    /// [`Diagnostic`] (code [`codes::INVALID_CSS`], owning fragment), so CSS
3152    /// mistakes render and serialize like every other authoring error. The
3153    /// message already carries the in-`<style>` line/column. Non-CSS errors
3154    /// pass through unchanged.
3155    #[cold]
3156    #[inline(never)]
3157    fn css_diagnostic(&self, err: ParserError) -> ParserError {
3158        match err {
3159            ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
3160            other => other,
3161        }
3162    }
3163
3164    /// Build the error for a `<for>` missing its `each` attribute (cold path).
3165    #[cold]
3166    #[inline(never)]
3167    fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
3168        let diag = self
3169            .authoring_error_at(
3170                codes::MISSING_FOR_EACH,
3171                "missing each attribute on <for>",
3172                element,
3173            )
3174            .element("for");
3175        match Self::attr_typo_suggestion(element, "each") {
3176            Some(typo) => diag.help(format!(
3177                "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
3178            )),
3179            None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
3180        }
3181        .into()
3182    }
3183
3184    fn enter_for_directive<'a>(
3185        &mut self,
3186        element: &Element<'a>,
3187        fragments: &mut Vec<WebUIFragment>,
3188        depth: usize,
3189        ops: &mut Vec<ParseOp<'a>>,
3190    ) -> Result<()> {
3191        let each = element
3192            .attr("each")
3193            .ok_or_else(|| self.for_each_missing_error(element))?;
3194        let (item, collection) = parse_for_each(&self.current_fragment_id, each)
3195            .map_err(|error| locate_for_error(error, element))?;
3196
3197        let named_id = validate_for_id(&self.current_fragment_id, &element.tag)
3198            .map_err(|error| locate_for_error(error, element))?;
3199        let fragment_id = if let Some(id) = named_id {
3200            self.named_for_loops
3201                .register(
3202                    &self.current_fragment_id,
3203                    id,
3204                    item,
3205                    (!element.self_closing(), element.start),
3206                )
3207                .map_err(|error| locate_for_error(error, element))?;
3208            let scoped_id = format!(
3209                "{id}-{}",
3210                self.loop_owner_indices[&self.current_fragment_id]
3211            );
3212            if !self.named_for_fragment_ids.contains(&scoped_id)
3213                && (self.id_counter.has_generated(&scoped_id)
3214                    || self.fragment_records.contains_key(&scoped_id)
3215                    || self.component_registry.contains(&scoped_id)
3216                    || self.in_progress_fragments.contains(&scoped_id))
3217            {
3218                return Err(self.for_id_collision_error(element, &scoped_id));
3219            }
3220            self.named_for_fragment_ids.insert(scoped_id.clone());
3221            scoped_id
3222        } else {
3223            self.next_fragment_id("for")
3224        };
3225        if named_id.is_some() && element.self_closing() {
3226            self.add_for_fragment(
3227                item.to_string(),
3228                collection.to_string(),
3229                fragment_id,
3230                fragments,
3231            );
3232            return Ok(());
3233        }
3234        let parent = ParseContext {
3235            fragments: std::mem::take(fragments),
3236            raw_buffer: std::mem::take(&mut self.raw_buffer),
3237        };
3238
3239        let previous_for_depth = self.for_depth;
3240        self.for_depth += 1;
3241        ops.push(ParseOp::CompleteFor {
3242            parent,
3243            item: item.to_string(),
3244            collection: collection.to_string(),
3245            fragment_id,
3246            named_definition: named_id.is_some(),
3247            previous_for_depth,
3248        });
3249        ops.push(ParseOp::Parse {
3250            range: element.inner(),
3251            depth: depth + 1,
3252        });
3253        Ok(())
3254    }
3255
3256    fn next_fragment_id(&mut self, prefix: &str) -> String {
3257        loop {
3258            let id = self.id_counter.next_id(prefix);
3259            if !self.named_for_fragment_ids.contains(&id) {
3260                return id;
3261            }
3262        }
3263    }
3264
3265    #[cold]
3266    #[inline(never)]
3267    fn for_id_collision_error(&self, element: &Element<'_>, id: &str) -> ParserError {
3268        self.authoring_error_at(
3269            codes::INVALID_FOR_ID,
3270            "named <for> id conflicts with an existing fragment",
3271            element,
3272        )
3273        .element("for")
3274        .help(format!(
3275            "choose a different id; the fragment name '{id}' is already in use"
3276        ))
3277        .into()
3278    }
3279
3280    /// Build the error for an `<if>` missing its `condition` attribute (cold
3281    /// path).
3282    #[cold]
3283    #[inline(never)]
3284    fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
3285        let diag = self
3286            .authoring_error_at(
3287                codes::MISSING_IF_CONDITION,
3288                "missing condition attribute on <if>",
3289                element,
3290            )
3291            .element("if");
3292        match Self::attr_typo_suggestion(element, "condition") {
3293            Some(typo) => diag.help(format!(
3294                "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
3295            )),
3296            None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
3297        }
3298        .into()
3299    }
3300
3301    /// Build the error for a malformed `<if condition>` expression (cold path).
3302    #[cold]
3303    #[inline(never)]
3304    fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
3305        self.authoring_error_at(
3306            codes::INVALID_IF_CONDITION,
3307            "invalid <if> condition expression",
3308            element,
3309        )
3310        .element("if")
3311        .snippet(format!("condition=\"{condition}\""))
3312        .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
3313        .into()
3314    }
3315
3316    fn enter_if_directive<'a>(
3317        &mut self,
3318        element: &Element<'a>,
3319        fragments: &mut Vec<WebUIFragment>,
3320        depth: usize,
3321        ops: &mut Vec<ParseOp<'a>>,
3322    ) -> Result<()> {
3323        let condition_str = element
3324            .attr("condition")
3325            .ok_or_else(|| self.if_condition_missing_error(element))?;
3326
3327        let condition = self
3328            .condition_parser
3329            .parse(strip_condition_braces(condition_str))
3330            .map_err(|_| self.if_condition_invalid_error(element, condition_str))?;
3331
3332        self.flush_raw_buffer(fragments);
3333        let parent = ParseContext {
3334            fragments: std::mem::take(fragments),
3335            raw_buffer: std::mem::take(&mut self.raw_buffer),
3336        };
3337        let fragment_id = self.next_fragment_id("if");
3338
3339        ops.push(ParseOp::CompleteIf {
3340            parent,
3341            condition,
3342            fragment_id,
3343        });
3344        ops.push(ParseOp::Parse {
3345            range: element.inner(),
3346            depth: depth + 1,
3347        });
3348        Ok(())
3349    }
3350
3351    /// Track whether `name` opens (or shields from) an HTML insertion mode
3352    /// that foster-parents unknown elements, pushing a restore op when the
3353    /// depth changes.
3354    ///
3355    /// Called for every regular element, so the common `Transparent` case
3356    /// does no work beyond the classification compare — no op is pushed and
3357    /// no field is written.
3358    #[inline]
3359    fn enter_foster_context<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
3360        match foster_context_of(name) {
3361            FosterContext::Transparent => {}
3362            FosterContext::Hostile => {
3363                ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3364                self.foster_context_depth += 1;
3365            }
3366            FosterContext::Barrier => {
3367                if self.foster_context_depth != 0 {
3368                    ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3369                    self.foster_context_depth = 0;
3370                }
3371            }
3372        }
3373    }
3374
3375    #[inline]
3376    fn enter_boundary_parent_scope<'a>(
3377        &mut self,
3378        element: &Element<'a>,
3379        ops: &mut Vec<ParseOp<'a>>,
3380    ) {
3381        if element.name().eq_ignore_ascii_case("template") && element.has_attr("shadowrootmode") {
3382            return;
3383        }
3384        let Some(scope) = boundary_parent_scope(element.name()) else {
3385            return;
3386        };
3387        ops.push(ParseOp::RestoreBoundaryParentScope(
3388            self.boundary_parent_scope,
3389        ));
3390        self.boundary_parent_scope = Some(scope);
3391    }
3392
3393    #[inline]
3394    fn enter_component_content_scope<'a>(&mut self, ops: &mut Vec<ParseOp<'a>>) {
3395        ops.push(ParseOp::RestoreBoundaryParentScope(
3396            self.boundary_parent_scope,
3397        ));
3398        self.boundary_parent_scope = Some("component host content");
3399    }
3400
3401    /// Enter a `<boundary name="…">` directive.
3402    ///
3403    /// `<boundary>` is a reserved, compile-time-only directive (see
3404    /// "Progressive Streaming Hydration" in `DESIGN.md`): it emits no wrapper
3405    /// element. Its children stay inline in the owner's record, bracketed by a
3406    /// [`BoundaryPhase::Start`]/[`BoundaryPhase::End`] fragment pair.
3407    ///
3408    /// # Errors
3409    ///
3410    /// Returns [`ParserError::Template`] when:
3411    /// - the boundary is nested inside another open boundary
3412    ///   ([`codes::NESTED_BOUNDARY`]);
3413    /// - the boundary is outside an open `<body>`
3414    ///   ([`codes::BOUNDARY_OUTSIDE_BODY`]);
3415    /// - the boundary sits in an HTML foster-parenting context such as
3416    ///   `<table>`/`<tbody>`/`<tr>`/`<select>`
3417    ///   ([`codes::BOUNDARY_IN_FOSTER_CONTEXT`]);
3418    /// - the boundary would cut through component host content or native
3419    ///   raw/inert content ([`codes::BOUNDARY_CROSSES_SCOPE`]);
3420    /// - `name` is missing/empty ([`codes::MISSING_BOUNDARY_NAME`]), dynamic
3421    ///   ([`codes::INVALID_BOUNDARY_NAME`]), or a duplicate within its owner
3422    ///   ([`codes::DUPLICATE_BOUNDARY_NAME`]).
3423    /// - the boundary sits inside a `<for>` repeat body
3424    ///   ([`codes::BOUNDARY_IN_REPEAT`]), or an authored key is malformed
3425    ///   ([`codes::INVALID_BOUNDARY_KEY`]).
3426    fn enter_boundary_directive<'a>(
3427        &mut self,
3428        element: &Element<'a>,
3429        fragments: &mut Vec<WebUIFragment>,
3430        depth: usize,
3431        ops: &mut Vec<ParseOp<'a>>,
3432    ) -> Result<()> {
3433        if let Some(scope) = self.boundary_parent_scope {
3434            return Err(self.boundary_scope_error(element, scope));
3435        }
3436        if self.in_boundary {
3437            return Err(self.nested_boundary_error(element));
3438        }
3439        if self.for_depth != 0 {
3440            return Err(self.boundary_in_repeat_error(element));
3441        }
3442        let owner_fragment_id = self.current_fragment_id.clone();
3443        let owner_is_component = self.component_registry.contains(&owner_fragment_id);
3444        if !owner_is_component && self.body_depth == 0 && self.route_depth == 0 {
3445            return Err(self.boundary_outside_body_error(element));
3446        }
3447        if self.foster_context_depth != 0 {
3448            return Err(self.boundary_in_foster_context_error(element));
3449        }
3450
3451        let name = self.validate_boundary_name(element)?;
3452        let inserted = self
3453            .boundary_names_by_owner
3454            .entry(owner_fragment_id.clone())
3455            .or_default()
3456            .insert(name.clone());
3457        if !inserted {
3458            return Err(self.duplicate_boundary_name_error(element, &name));
3459        }
3460        let key = self.validate_boundary_key(element)?;
3461        let declaration_id = self.allocate_boundary_declaration_id(element)?;
3462        self.in_boundary = true;
3463        self.add_fragment(
3464            WebUIFragment {
3465                fragment: Some(Fragment::Boundary(WebUiFragmentBoundary {
3466                    declaration_id,
3467                    owner_fragment_id,
3468                    name,
3469                    key,
3470                    may_repeat: false,
3471                    phase: BoundaryPhase::Start as i32,
3472                })),
3473            },
3474            fragments,
3475        );
3476
3477        ops.push(ParseOp::CompleteBoundary {
3478            boundary: PendingBoundary { declaration_id },
3479        });
3480        ops.push(ParseOp::Parse {
3481            range: element.inner(),
3482            depth: depth + 1,
3483        });
3484        Ok(())
3485    }
3486
3487    /// Validate `<boundary name>`: required, non-empty, and static (no
3488    /// `{{binding}}`).
3489    fn validate_boundary_name(&self, element: &Element<'_>) -> Result<String> {
3490        let name = element.attr("name").unwrap_or_default();
3491        if name.trim().is_empty() {
3492            return Err(self.missing_boundary_name_error(element));
3493        }
3494        if name.contains("{{") {
3495            return Err(self.invalid_boundary_name_error(element, name));
3496        }
3497        Ok(name.to_string())
3498    }
3499
3500    fn validate_boundary_key(&self, element: &Element<'_>) -> Result<Option<String>> {
3501        if !element.has_attr("key") {
3502            return Ok(None);
3503        }
3504        let Some(raw) = element.attr("key") else {
3505            return Err(self.invalid_boundary_key_error(element, ""));
3506        };
3507        let trimmed = raw.trim();
3508        let braced = trimmed.starts_with("{{") || trimmed.ends_with("}}");
3509        let valid = if braced {
3510            trimmed
3511                .strip_prefix("{{")
3512                .and_then(|value| value.strip_suffix("}}"))
3513                .filter(|value| !value.starts_with('{') && !value.ends_with('}'))
3514                .is_some_and(|value| !value.trim().is_empty())
3515        } else {
3516            !trimmed.is_empty()
3517        };
3518        if !valid {
3519            return Err(self.invalid_boundary_key_error(element, raw));
3520        }
3521        Ok(Some(raw.to_string()))
3522    }
3523
3524    fn allocate_boundary_declaration_id(&mut self, element: &Element<'_>) -> Result<u32> {
3525        let declaration_id = self.next_boundary_declaration_id;
3526        let Some(next) = declaration_id.checked_add(1) else {
3527            return Err(self.too_many_boundaries_error(element));
3528        };
3529        self.next_boundary_declaration_id = next;
3530        Ok(declaration_id)
3531    }
3532
3533    /// Build the error for a `<boundary>` missing its `name` attribute
3534    /// (cold path).
3535    #[cold]
3536    #[inline(never)]
3537    fn missing_boundary_name_error(&self, element: &Element<'_>) -> ParserError {
3538        self.authoring_error_at(
3539            codes::MISSING_BOUNDARY_NAME,
3540            "missing name attribute on <boundary>",
3541            element,
3542        )
3543        .element("boundary")
3544        .help("add a unique static name, e.g. <boundary name=\"counter-ready\">")
3545        .into()
3546    }
3547
3548    /// Build the error for a `<boundary name>` that is not a static
3549    /// string literal (cold path).
3550    #[cold]
3551    #[inline(never)]
3552    fn invalid_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3553        self.authoring_error_at(
3554            codes::INVALID_BOUNDARY_NAME,
3555            "invalid name attribute on <boundary>",
3556            element,
3557        )
3558        .element("boundary")
3559        .snippet(format!("name=\"{name}\""))
3560        .help("boundary names must be a static string literal, e.g. name=\"counter-ready\"")
3561        .into()
3562    }
3563
3564    /// Build the error for a duplicate `<boundary name>` within one owner.
3565    #[cold]
3566    #[inline(never)]
3567    fn duplicate_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3568        self.authoring_error_at(
3569            codes::DUPLICATE_BOUNDARY_NAME,
3570            format!("duplicate <boundary> name \"{name}\""),
3571            element,
3572        )
3573        .element("boundary")
3574        .help("boundary names must be unique within their entry or component template; rename one of the declarations")
3575        .into()
3576    }
3577
3578    #[cold]
3579    #[inline(never)]
3580    fn missing_boundary_key_error(owner: &str, name: &str) -> ParserError {
3581        Diagnostic::error(format!(
3582            "boundary \"{name}\" renders more than once and has no key"
3583        ))
3584        .code(codes::MISSING_BOUNDARY_KEY)
3585        .component(owner)
3586        .element("boundary")
3587        .snippet(format!("name=\"{name}\""))
3588        .help(
3589            "the owning template is rendered from more than one callsite, so every occurrence \
3590             needs a distinct key, e.g. key=\"{{sectionId}}\" — or render the declaration from a \
3591             single callsite",
3592        )
3593        .into()
3594    }
3595
3596    /// Build the error for a `<boundary>` authored directly inside `<for>`.
3597    #[cold]
3598    #[inline(never)]
3599    fn boundary_in_repeat_error(&self, element: &Element<'_>) -> ParserError {
3600        let name = element.attr("name").unwrap_or_default();
3601        self.authoring_error_at(
3602            codes::BOUNDARY_IN_REPEAT,
3603            "<boundary> is not valid inside a <for> repeat",
3604            element,
3605        )
3606        .element("boundary")
3607        .snippet(format!("name=\"{name}\""))
3608        .help(
3609            "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
3610             whole <for> in one <boundary> to render the list as a single independently paced \
3611             region",
3612        )
3613        .into()
3614    }
3615
3616    #[cold]
3617    #[inline(never)]
3618    fn invalid_boundary_key_error(&self, element: &Element<'_>, key: &str) -> ParserError {
3619        self.authoring_error_at(
3620            codes::INVALID_BOUNDARY_KEY,
3621            "invalid key expression on <boundary>",
3622            element,
3623        )
3624        .element("boundary")
3625        .snippet(format!("key=\"{key}\""))
3626        .help("use a non-empty expression such as key=\"item.id\" or key=\"{{item.id}}\"")
3627        .into()
3628    }
3629
3630    #[cold]
3631    #[inline(never)]
3632    fn too_many_boundaries_error(&self, element: &Element<'_>) -> ParserError {
3633        self.authoring_error_at(
3634            codes::TOO_MANY_BOUNDARIES,
3635            "too many boundary declarations",
3636            element,
3637        )
3638        .element("boundary")
3639        .help("split this build into smaller protocols so each declaration has a unique u32 ID")
3640        .into()
3641    }
3642
3643    /// Build the error for a `<boundary>` nested inside another open
3644    /// boundary (cold path).
3645    #[cold]
3646    #[inline(never)]
3647    fn nested_boundary_error(&self, element: &Element<'_>) -> ParserError {
3648        self.authoring_error_at(
3649            codes::NESTED_BOUNDARY,
3650            "nested <boundary> is not allowed",
3651            element,
3652        )
3653        .element("boundary")
3654        .help("boundaries cannot be nested; close the enclosing <boundary> before opening another")
3655        .into()
3656    }
3657
3658    /// Build the error for a `<boundary>` that would cut through a native
3659    /// raw/inert scope or component host content.
3660    #[cold]
3661    #[inline(never)]
3662    fn boundary_scope_error(&self, element: &Element<'_>, scope: &str) -> ParserError {
3663        self.authoring_error_at(
3664            codes::BOUNDARY_CROSSES_SCOPE,
3665            format!("<boundary> cannot appear inside {scope}"),
3666            element,
3667        )
3668        .element("boundary")
3669        .help("move the <boundary> outside this raw, inert, or component-host content scope")
3670        .into()
3671    }
3672
3673    /// Build the error for a `<boundary>` outside an open body.
3674    #[cold]
3675    #[inline(never)]
3676    fn boundary_outside_body_error(&self, element: &Element<'_>) -> ParserError {
3677        self.authoring_error_at(
3678            codes::BOUNDARY_OUTSIDE_BODY,
3679            "<boundary> must appear inside <body>",
3680            element,
3681        )
3682        .element("boundary")
3683        .help("move the <boundary> between the opening <body> and its matching </body>")
3684        .into()
3685    }
3686
3687    /// Build the error for a `<boundary>` inside an HTML
3688    /// foster-parenting insertion mode (cold path).
3689    ///
3690    /// The browser would move the generated `<webui-hydrate>` sentinel out of
3691    /// the table while leaving its payload `<script>` behind, so the
3692    /// coordinator could not pair them and would halt hydration for the whole
3693    /// page. Rejecting at build time keeps that failure impossible.
3694    #[cold]
3695    #[inline(never)]
3696    fn boundary_in_foster_context_error(&self, element: &Element<'_>) -> ParserError {
3697        self.authoring_error_at(
3698            codes::BOUNDARY_IN_FOSTER_CONTEXT,
3699            "<boundary> cannot appear inside a table or select context",
3700            element,
3701        )
3702        .element("boundary")
3703        .help(
3704            "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>",
3705        )
3706        .into()
3707    }
3708
3709    #[cold]
3710    #[inline(never)]
3711    fn route_boundary_placement_error(&self, element: &Element<'_>) -> ParserError {
3712        self.authoring_error_at(
3713            codes::INVALID_ROUTE_BOUNDARY_PLACEMENT,
3714            "route boundary must be a direct <route> child",
3715            element,
3716        )
3717        .element("boundary")
3718        .help("move the <boundary> directly under <route>, or place it in the route component template")
3719        .into()
3720    }
3721
3722    /// Build the error for an authored `<webui-hydrate>` (cold path).
3723    /// `<webui-hydrate>` is reserved for the compiler/handler-generated
3724    /// runtime hydration sentinel and must never appear in authored markup.
3725    #[cold]
3726    #[inline(never)]
3727    fn authored_webui_hydrate_error(&self, element: &Element<'_>) -> ParserError {
3728        self.authoring_error_at(
3729            codes::AUTHORED_WEBUI_HYDRATE,
3730            "<webui-hydrate> is reserved for the generated runtime sentinel",
3731            element,
3732        )
3733        .element("webui-hydrate")
3734        .help("remove <webui-hydrate>; it is emitted only by the compiler/handler, never authored")
3735        .into()
3736    }
3737
3738    fn enter_component_directive<'a>(
3739        &mut self,
3740        element: &Element<'a>,
3741        fragments: &mut Vec<WebUIFragment>,
3742        depth: usize,
3743        ops: &mut Vec<ParseOp<'a>>,
3744    ) -> Result<()> {
3745        let needs_template_build = !self.fragment_records.contains_key(element.name());
3746        let cached_dom_analysis = self.component_dom_analyses.get(element.name()).copied();
3747        let has_plugin_artifact = self
3748            .component_registry
3749            .component_artifact_source(element.name())
3750            .is_some();
3751        let authored_html = if needs_template_build || cached_dom_analysis.is_none() {
3752            self.component_registry
3753                .component_authored_source(element.name())
3754        } else {
3755            None
3756        };
3757        let (dom_analysis, template_source) = {
3758            let component = self.component_registry.get(element.name()).ok_or_else(|| {
3759                self.authoring_error_at(
3760                    codes::UNKNOWN_COMPONENT,
3761                    format!("unknown component <{}>", element.name()),
3762                    element,
3763                )
3764                .help(self.unknown_component_help(element.name()))
3765            })?;
3766            (
3767                cached_dom_analysis.map_or_else(
3768                    || {
3769                        let fast_html = (has_plugin_artifact
3770                            && self.options.dom_strategy == DomStrategy::Shadow)
3771                            .then(|| {
3772                                Self::append_shadow_mode_if_missing(component.html_content.clone())
3773                            });
3774                        analyze_component_dom_with_diagnostic_source(
3775                            element.name(),
3776                            fast_html.as_deref().unwrap_or(&component.html_content),
3777                            self.options.dom_strategy,
3778                            authored_html.as_deref(),
3779                        )
3780                    },
3781                    Ok,
3782                )?,
3783                needs_template_build.then(|| {
3784                    (
3785                        component.html_content.clone(),
3786                        component.css_content.clone(),
3787                    )
3788                }),
3789            )
3790        };
3791        if cached_dom_analysis.is_none() {
3792            self.component_dom_analyses
3793                .insert(element.name().to_string(), dom_analysis);
3794        }
3795
3796        self.flush_raw_buffer(fragments);
3797        fragments.push(structural_signal(format!(
3798            "streaming_span_start:{}",
3799            element.name()
3800        )));
3801        self.add_raw_fragment("<");
3802        self.add_raw_fragment(element.name());
3803        if self
3804            .component_registry
3805            .render_policy(element.name())
3806            .is_some_and(ComponentRenderPolicy::is_interaction_hydration)
3807        {
3808            self.add_raw_fragment(" data-webui-interaction");
3809        }
3810
3811        let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
3812        if let Some(ref mut p) = self.plugin {
3813            if let Some(data) = p.finish_opening_tag(ElementStartContext {
3814                tag_name: element.name(),
3815                binding_count,
3816            }) {
3817                self.add_fragment(WebUIFragment::plugin(data), fragments);
3818            }
3819        }
3820
3821        // Emit a compiler-owned streamed SSR root signal immediately before the
3822        // component opening tag closes. Ordinary rendering ignores it byte-for-byte;
3823        // streaming rendering consumes it to inject ` data-ws` inside the opening tag
3824        // (so the marker exists before custom-element upgrade). Placing the signal
3825        // between the attribute run and the closing `>` is required because an
3826        // attribute cannot be injected once `>` has already been flushed.
3827        self.flush_raw_buffer(fragments);
3828        fragments.push(structural_signal(format!(
3829            "streaming_root:{}",
3830            element.name()
3831        )));
3832
3833        if element.self_closing() {
3834            self.add_raw_fragment("/>");
3835        } else {
3836            self.add_raw_fragment(">");
3837        }
3838
3839        self.flush_raw_buffer(fragments);
3840
3841        if let Some((html_content, css_content)) = template_source {
3842            let artifact_html = self
3843                .component_registry
3844                .component_artifact_source(element.name())
3845                .map(str::to_string);
3846            let built = self.build_component_templates(ComponentTemplateInput {
3847                tag_name: element.name(),
3848                html: &html_content,
3849                artifact_html: artifact_html.as_deref(),
3850                authored_html: authored_html.as_deref(),
3851                css_content: css_content.as_deref(),
3852                artifact_needed: self.plugin.is_some(),
3853            })?;
3854
3855            if let Some(ref mut p) = self.plugin {
3856                let component_data = self
3857                    .component_registry
3858                    .get(element.name())
3859                    .ok_or_else(|| {
3860                        ParserError::NotFound(format!(
3861                            "component <{}> disappeared during CSS compilation",
3862                            element.name()
3863                        ))
3864                    })?
3865                    .clone();
3866                p.component_built(built.plugin_context(&component_data))?;
3867            }
3868
3869            self.parse(element.name(), &built.ssr)?;
3870        }
3871
3872        fragments.push(WebUIFragment::component(element.name().to_string()));
3873
3874        if element.self_closing() {
3875            fragments.push(structural_signal(format!(
3876                "streaming_span_end:{}",
3877                element.name()
3878            )));
3879        } else {
3880            ops.push(ParseOp::EndComponent(element.name()));
3881            self.enter_component_content_scope(ops);
3882            ops.push(ParseOp::Parse {
3883                range: element.inner(),
3884                depth: depth + 1,
3885            });
3886        }
3887
3888        Ok(())
3889    }
3890
3891    fn analyze_component_dom(
3892        &mut self,
3893        tag_name: &str,
3894        source: &str,
3895        authored_source: Option<&str>,
3896    ) -> Result<ComponentDomAnalysis> {
3897        if let Some(analysis) = self.component_dom_analyses.get(tag_name) {
3898            return Ok(*analysis);
3899        }
3900        let analysis = analyze_component_dom_with_diagnostic_source(
3901            tag_name,
3902            source,
3903            self.options.dom_strategy,
3904            authored_source,
3905        )?;
3906        self.component_dom_analyses
3907            .insert(tag_name.to_string(), analysis);
3908        Ok(analysis)
3909    }
3910
3911    fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
3912        if !Self::should_emit_text_content(content) {
3913            return Ok(());
3914        }
3915
3916        let parsed_fragments = self.handlebars_parser.parse(content)?;
3917        for fragment in parsed_fragments {
3918            if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
3919                if Self::should_emit_text_content(&raw.value) {
3920                    self.add_raw_fragment(&raw.value);
3921                }
3922            } else if let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() {
3923                if signal.raw && is_html_text_only_scope(self.boundary_parent_scope) {
3924                    self.add_fragment(
3925                        WebUIFragment::raw_text_signal(signal.value.as_str(), true),
3926                        fragments,
3927                    );
3928                } else {
3929                    self.add_fragment(fragment, fragments);
3930                }
3931            } else {
3932                self.add_fragment(fragment, fragments);
3933            }
3934        }
3935        Ok(())
3936    }
3937
3938    fn process_style_content(
3939        &mut self,
3940        css: &str,
3941        comments: &[crate::css_parser::CssComment],
3942        fragments: &mut Vec<WebUIFragment>,
3943    ) {
3944        let mut last_end = 0usize;
3945
3946        for comment in comments {
3947            if comment.start_byte < last_end {
3948                if comment.end_byte > last_end {
3949                    last_end = comment.end_byte;
3950                }
3951                continue;
3952            }
3953
3954            self.add_raw_fragment(&css[last_end..comment.start_byte]);
3955            let comment_text = &css[comment.start_byte..comment.end_byte];
3956            if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
3957                self.add_fragment(fragment, fragments);
3958            } else if comment.preserve {
3959                self.add_raw_fragment(comment_text);
3960            }
3961            last_end = comment.end_byte;
3962        }
3963
3964        self.add_raw_fragment(&css[last_end..]);
3965    }
3966
3967    fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
3968        let signal = comment_policy::parse_css_signal_comment(comment)?;
3969        Some(WebUIFragment::raw_text_signal(signal.path, signal.raw))
3970    }
3971
3972    /// Check if an attribute value is a pure handlebars expression (e.g., "{{name}}" or
3973    /// "{{name}}" with quotes). Returns the inner signal name (borrowed) if so.
3974    fn extract_single_handlebars(value: &str) -> Option<&str> {
3975        let trimmed = value.trim();
3976        if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
3977            let inner = trimmed[2..trimmed.len() - 2].trim();
3978            // Verify there's no other {{ in the middle (i.e., it's truly a single expression)
3979            if !inner.contains("{{") && !inner.is_empty() {
3980                return Some(inner);
3981            }
3982        }
3983        None
3984    }
3985
3986    /// Check if an attribute value contains any handlebars expressions.
3987    fn contains_handlebars(value: &str) -> bool {
3988        value.contains("{{")
3989    }
3990
3991    fn process_tag_attributes(
3992        &mut self,
3993        attrs: Attrs<'_>,
3994        fragments: &mut Vec<WebUIFragment>,
3995        is_component: bool,
3996    ) -> Result<u32> {
3997        let mut first_dynamic_emitted = false;
3998        let mut binding_count: u32 = 0;
3999        for attr in attrs {
4000            let attr_name = attr.name;
4001            let attr_value = attr.value;
4002            let is_property_binding = attr_name.starts_with(':')
4003                && attr_value
4004                    .and_then(Self::extract_single_handlebars)
4005                    .is_some();
4006
4007            // `:property="{{expr}}"` is WebUI-owned syntax. Plugins may classify
4008            // FAST's single-brace `:property="{expr}"`, but cannot discard the
4009            // server-side scope transfer before parser-core processes it.
4010            if !is_property_binding {
4011                if let Some(ref mut p) = self.plugin {
4012                    match p.process_attribute(AttributeContext { name: attr_name }) {
4013                        AttributeAction::Keep => {}
4014                        AttributeAction::Skip => continue,
4015                        AttributeAction::SkipAndCountBinding => {
4016                            binding_count += 1;
4017                            continue;
4018                        }
4019                    }
4020                }
4021            }
4022
4023            if let Some(bool_name) = attr_name.strip_prefix('?') {
4024                if is_component {
4025                    if let Some(condition) = self.parse_boolean_condition(attr_value) {
4026                        let frag = Self::maybe_mark_attr_start(
4027                            WebUIFragment::attribute_boolean(bool_name, condition),
4028                            &mut first_dynamic_emitted,
4029                        );
4030                        self.add_fragment(frag, fragments);
4031                        binding_count += 1;
4032                    }
4033                } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
4034                    binding_count += 1;
4035                }
4036            } else if let Some(prop_name) = attr_name.strip_prefix(':') {
4037                if !is_component {
4038                    return Err(ParserError::Parse(format!(
4039                        ":{prop_name} complex binding is only allowed on custom elements. \
4040                         Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
4041                    )));
4042                }
4043                if Self::is_blocked_complex_property(prop_name) {
4044                    return Err(ParserError::Parse(format!(
4045                        ":{prop_name} is not allowed as a complex attribute binding \
4046                         because it enables arbitrary HTML injection. \
4047                         Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
4048                    )));
4049                }
4050                if let Some(val) = attr_value {
4051                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
4052                        let frag = Self::maybe_mark_attr_start(
4053                            WebUIFragment::attribute_complex(attr_name, signal_name),
4054                            &mut first_dynamic_emitted,
4055                        );
4056                        self.add_fragment(frag, fragments);
4057                        binding_count += 1;
4058                    }
4059                }
4060            } else if is_component && Self::is_skipped_attribute(attr_name) {
4061                if let Some(val) = attr_value {
4062                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
4063                        let frag = WebUIFragment {
4064                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4065                                WebUIFragmentAttribute {
4066                                    name: attr_name.to_string(),
4067                                    value: signal_name.to_string(),
4068                                    attr_skip: true,
4069                                    ..Default::default()
4070                                },
4071                            )),
4072                        };
4073                        self.add_fragment(frag, fragments);
4074                        binding_count += 1;
4075                    } else if Self::contains_handlebars(val) {
4076                        let template_id = self.next_fragment_id("attr");
4077                        let parsed = self.parse_attribute_template(val)?;
4078                        self.fragment_records.insert(
4079                            template_id.clone(),
4080                            FragmentList {
4081                                fragments: parsed,
4082                                contains_boundary: false,
4083                            },
4084                        );
4085                        let frag = WebUIFragment {
4086                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4087                                WebUIFragmentAttribute {
4088                                    name: attr_name.to_string(),
4089                                    template: template_id,
4090                                    attr_skip: true,
4091                                    ..Default::default()
4092                                },
4093                            )),
4094                        };
4095                        self.add_fragment(frag, fragments);
4096                        binding_count += 1;
4097                    } else {
4098                        let frag = WebUIFragment {
4099                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4100                                WebUIFragmentAttribute {
4101                                    name: attr_name.to_string(),
4102                                    value: html_escape::decode_html_entities(val).into_owned(),
4103                                    raw_value: true,
4104                                    attr_skip: true,
4105                                    ..Default::default()
4106                                },
4107                            )),
4108                        };
4109                        self.add_fragment(frag, fragments);
4110                    }
4111                } else {
4112                    self.add_raw_fragment(" ");
4113                    self.add_raw_fragment(attr_name);
4114                }
4115            } else if let Some(val) = attr_value {
4116                if Self::contains_handlebars(val) {
4117                    if is_component {
4118                        if let Some(signal_name) = Self::extract_single_handlebars(val) {
4119                            let frag = Self::maybe_mark_attr_start(
4120                                WebUIFragment::attribute(attr_name, signal_name),
4121                                &mut first_dynamic_emitted,
4122                            );
4123                            self.add_fragment(frag, fragments);
4124                            binding_count += 1;
4125                        } else {
4126                            let template_id = self.next_fragment_id("attr");
4127                            let parsed = self.parse_attribute_template(val)?;
4128                            self.fragment_records.insert(
4129                                template_id.clone(),
4130                                FragmentList {
4131                                    fragments: parsed,
4132                                    contains_boundary: false,
4133                                },
4134                            );
4135                            let frag = Self::maybe_mark_attr_start(
4136                                WebUIFragment::attribute_template(attr_name, template_id),
4137                                &mut first_dynamic_emitted,
4138                            );
4139                            self.add_fragment(frag, fragments);
4140                            binding_count += 1;
4141                        }
4142                    } else {
4143                        self.process_dynamic_attribute(attr_name, val, fragments)?;
4144                        binding_count += 1;
4145                    }
4146                } else if is_component {
4147                    let frag = Self::maybe_mark_attr_start(
4148                        WebUIFragment {
4149                            fragment: Some(web_ui_fragment::Fragment::Attribute(
4150                                WebUIFragmentAttribute {
4151                                    name: attr_name.to_string(),
4152                                    value: html_escape::decode_html_entities(val).into_owned(),
4153                                    raw_value: true,
4154                                    ..Default::default()
4155                                },
4156                            )),
4157                        },
4158                        &mut first_dynamic_emitted,
4159                    );
4160                    self.add_fragment(frag, fragments);
4161                } else {
4162                    self.add_raw_fragment(" ");
4163                    self.add_raw_fragment(attr.raw);
4164                }
4165            } else if is_component {
4166                let fragment = Self::maybe_mark_attr_start(
4167                    WebUIFragment::attribute_boolean(attr_name, ConditionExpr::identifier("true")),
4168                    &mut first_dynamic_emitted,
4169                );
4170                self.add_fragment(fragment, fragments);
4171            } else {
4172                self.add_raw_fragment(" ");
4173                self.add_raw_fragment(attr_name);
4174            }
4175        }
4176        Ok(binding_count)
4177    }
4178
4179    fn parse_attribute_template(&mut self, value: &str) -> Result<Vec<WebUIFragment>> {
4180        let mut fragments = self.handlebars_parser.parse(value)?;
4181        for fragment in &mut fragments {
4182            if let Some(web_ui_fragment::Fragment::Raw(raw)) = &mut fragment.fragment {
4183                if let std::borrow::Cow::Owned(decoded) =
4184                    html_escape::decode_html_entities(&raw.value)
4185                {
4186                    raw.value = decoded;
4187                }
4188            }
4189        }
4190        Ok(fragments)
4191    }
4192
4193    /// Set `attr_start = true` on the first non-skipped attribute fragment for
4194    /// a component element.
4195    fn maybe_mark_attr_start(
4196        mut frag: WebUIFragment,
4197        first_dynamic_emitted: &mut bool,
4198    ) -> WebUIFragment {
4199        if !*first_dynamic_emitted {
4200            if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
4201                a.attr_start = true;
4202            }
4203            *first_dynamic_emitted = true;
4204        }
4205        frag
4206    }
4207
4208    /// Process a boolean attribute (?prefix). Silently drops if value is not a
4209    /// pure handlebars expression.
4210    fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
4211        if let Some(val) = value {
4212            if let Some(expr_str) = Self::extract_single_handlebars(val) {
4213                return Some(
4214                    self.condition_parser
4215                        .parse(expr_str)
4216                        .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
4217                );
4218            }
4219        }
4220
4221        None
4222    }
4223
4224    fn process_boolean_attribute(
4225        &mut self,
4226        name: &str,
4227        value: Option<&str>,
4228        fragments: &mut Vec<WebUIFragment>,
4229    ) -> Result<bool> {
4230        if let Some(condition) = self.parse_boolean_condition(value) {
4231            self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
4232            return Ok(true);
4233        }
4234        // Invalid boolean attribute — silently drop (no output at all)
4235        Ok(false)
4236    }
4237
4238    /// Process a dynamic attribute (regular name with handlebars in value).
4239    fn process_dynamic_attribute(
4240        &mut self,
4241        name: &str,
4242        value: &str,
4243        fragments: &mut Vec<WebUIFragment>,
4244    ) -> Result<()> {
4245        if let Some(signal_name) = Self::extract_single_handlebars(value) {
4246            // Pure handlebars — simple attribute fragment
4247            self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
4248        } else {
4249            // Mixed static + dynamic — create a template sub-stream
4250            let template_id = self.next_fragment_id("attr");
4251            let parsed = self.handlebars_parser.parse(value)?;
4252
4253            self.fragment_records.insert(
4254                template_id.clone(),
4255                FragmentList {
4256                    fragments: parsed,
4257                    contains_boundary: false,
4258                },
4259            );
4260
4261            self.add_fragment(
4262                WebUIFragment::attribute_template(name, template_id),
4263                fragments,
4264            );
4265        }
4266        Ok(())
4267    }
4268
4269    fn process_style_element(
4270        &mut self,
4271        element: &Element<'_>,
4272        fragments: &mut Vec<WebUIFragment>,
4273    ) -> Result<()> {
4274        self.add_raw_fragment(element.opening());
4275        let inner = element.inner();
4276        let style_content = &element.source()[inner.start..inner.end];
4277        let (defs, requirements, comments) = self
4278            .css_parser
4279            .extract_definitions_requirements_and_comments(
4280                style_content,
4281                self.options.legal_comments,
4282            )
4283            .map_err(|e| self.css_diagnostic(e))?;
4284        self.record_fragment_css_tokens(defs, requirements);
4285        self.process_style_content(style_content, &comments, fragments);
4286        if element.close_end() > element.content_end() {
4287            // Reconstruct the closing tag from the parsed name so the emitted
4288            // case mirrors the source (e.g. `<STYLE>` closes with `</STYLE>`),
4289            // matching how regular elements are closed via `ParseOp::EmitClose`.
4290            self.add_raw_fragment("</");
4291            self.add_raw_fragment(element.name());
4292            self.add_raw_fragment(">");
4293        }
4294        Ok(())
4295    }
4296
4297    /// Process a `<route>` directive.
4298    ///
4299    /// Emits a `Fragment::Route` protocol fragment. The handler renders
4300    /// `<webui-route>` elements with server-side route matching — matched
4301    /// routes get `active` + component content, non-matched get `display:none`.
4302    fn process_route_directive(
4303        &mut self,
4304        element: &Element<'_>,
4305        fragments: &mut Vec<WebUIFragment>,
4306        depth: usize,
4307    ) -> Result<()> {
4308        let attrs = Self::route_attrs_from_element(element);
4309        let path = attrs.path.clone();
4310        let component = attrs.component.clone();
4311        route_parser::validate_attributes(&attrs)?;
4312
4313        let route_params: std::collections::HashSet<String> =
4314            route_parser::extract_params(&path)?.into_iter().collect();
4315        if !attrs.cache_tags.is_empty() {
4316            route_parser::validate_tag_placeholders(
4317                &attrs.cache_tags,
4318                &route_params,
4319                "cache-tags",
4320                &path,
4321            )?;
4322        }
4323        if !attrs.invalidates.is_empty() {
4324            route_parser::validate_tag_placeholders(
4325                &attrs.invalidates,
4326                &route_params,
4327                "invalidates",
4328                &path,
4329            )?;
4330        }
4331
4332        if !attrs.pending_component.is_empty() {
4333            self.ensure_route_component_parsed(&attrs.pending_component)?;
4334        }
4335        if !attrs.error_component.is_empty() {
4336            self.ensure_route_component_parsed(&attrs.error_component)?;
4337        }
4338        self.ensure_route_component_parsed(&component)?;
4339        self.flush_raw_buffer(fragments);
4340        let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4341
4342        let mut all_params = std::collections::HashSet::new();
4343        all_params.extend(route_params);
4344        self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
4345        let children =
4346            self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
4347
4348        let mut route_fragment =
4349            route_parser::build_route_fragment(&attrs, component.clone(), children);
4350        route_fragment.content_fragment_id = content_fragment_id;
4351        fragments.push(WebUIFragment::route_from(route_fragment));
4352
4353        Ok(())
4354    }
4355
4356    fn parse_route_boundary_content(
4357        &mut self,
4358        route: &Element<'_>,
4359        depth: usize,
4360    ) -> Result<String> {
4361        let inner = route.inner();
4362        if !route.source()[inner.clone()].contains("<boundary") {
4363            return Ok(String::new());
4364        }
4365        let saved_buffer = std::mem::take(&mut self.raw_buffer);
4366        let previous_route_depth = self.route_depth;
4367        self.route_depth += 1;
4368        let mut content = Vec::new();
4369        let parse_result: Result<()> = (|| {
4370            for event in Walker::new_range(route.source(), inner.start, inner.end) {
4371                if let Event::Element(element) = event {
4372                    if element.name() == "boundary" {
4373                        self.parse_range(
4374                            element.source(),
4375                            element.start..element.close_end(),
4376                            &mut content,
4377                            depth + 1,
4378                        )?;
4379                    }
4380                }
4381            }
4382            self.flush_raw_buffer(&mut content);
4383            Ok(())
4384        })();
4385        self.route_depth = previous_route_depth;
4386        self.raw_buffer = saved_buffer;
4387        parse_result?;
4388
4389        if content.is_empty() {
4390            return Ok(String::new());
4391        }
4392        let fragment_id = self.next_fragment_id("route-content");
4393        self.fragment_records.insert(
4394            fragment_id.clone(),
4395            FragmentList {
4396                fragments: content,
4397                contains_boundary: false,
4398            },
4399        );
4400        Ok(fragment_id)
4401    }
4402
4403    /// Parse nested `<route>` children into route fragments.
4404    ///
4405    /// `depth` is the nesting level of the routes produced here (top-level
4406    /// `<route>` children are depth 1). Nested routes are validated against
4407    /// [`MAX_TEMPLATE_DEPTH`] so pathological route trees cannot exhaust the
4408    /// call stack via `parse_route_as_fragment` recursion.
4409    fn parse_child_routes(
4410        &mut self,
4411        source: &str,
4412        range: Range<usize>,
4413        ancestor_params: &std::collections::HashSet<String>,
4414        depth: usize,
4415    ) -> Result<Vec<WebUiFragmentRoute>> {
4416        if depth > MAX_TEMPLATE_DEPTH {
4417            return Err(self
4418                .html_error(
4419                    codes::EXCESSIVE_NESTING,
4420                    format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4421                    source,
4422                    range.start,
4423                )
4424                .help("flatten deeply nested <route> trees before building")
4425                .into());
4426        }
4427
4428        let mut children = Vec::new();
4429        for event in Walker::new_range(source, range.start, range.end) {
4430            match event {
4431                Event::Element(element) => {
4432                    self.validate_closed_element(&element)?;
4433                    self.reject_boundary_or_hydrate_in_route(&element, true)?;
4434                    if element.name() == "route" {
4435                        children.push(self.parse_route_as_fragment(
4436                            &element,
4437                            ancestor_params,
4438                            depth,
4439                        )?);
4440                    } else if element.name().eq_ignore_ascii_case("style") {
4441                        self.validate_style_element(&element)?;
4442                    } else if !element.self_closing() && !element.is_void() {
4443                        self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
4444                    }
4445                }
4446                Event::Text(text) => {
4447                    if text.contains('<') {
4448                        return Err(self
4449                            .authoring_error(
4450                                codes::MALFORMED_HTML_TAG,
4451                                "malformed HTML tag in <route>",
4452                            )
4453                            .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
4454                            .into());
4455                    }
4456                }
4457                Event::Comment(comment_range) => {
4458                    self.validate_comment_range(source, comment_range)?;
4459                }
4460                Event::Declaration(declaration_range) => {
4461                    self.validate_declaration_range(source, declaration_range)?;
4462                }
4463                Event::ClosingTag(closing_range) => {
4464                    return Err(self.unexpected_closing_tag_error(source, closing_range));
4465                }
4466            }
4467        }
4468
4469        Ok(children)
4470    }
4471
4472    fn validate_route_nesting_depth(
4473        &self,
4474        source: &str,
4475        range: Range<usize>,
4476        depth: usize,
4477    ) -> Result<()> {
4478        let mut stack = vec![(range, depth)];
4479        while let Some((range, depth)) = stack.pop() {
4480            if depth > MAX_TEMPLATE_DEPTH {
4481                return Err(self
4482                    .html_error(
4483                        codes::EXCESSIVE_NESTING,
4484                        format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4485                        source,
4486                        range.start,
4487                    )
4488                    .help("flatten deeply nested <route> trees before building")
4489                    .into());
4490            }
4491
4492            for event in Walker::new_range(source, range.start, range.end) {
4493                if let Event::Element(element) = event {
4494                    self.validate_closed_element(&element)?;
4495                    if element.name() == "route" && !element.self_closing() {
4496                        stack.push((element.inner(), depth + 1));
4497                    }
4498                }
4499            }
4500        }
4501        Ok(())
4502    }
4503
4504    fn validate_ignored_route_html(
4505        &mut self,
4506        source: &str,
4507        range: Range<usize>,
4508        depth: usize,
4509    ) -> Result<()> {
4510        let mut stack = vec![(range, depth)];
4511        while let Some((range, depth)) = stack.pop() {
4512            if depth > MAX_TEMPLATE_DEPTH {
4513                return Err(self
4514                    .html_error(
4515                        codes::EXCESSIVE_NESTING,
4516                        format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4517                        source,
4518                        range.start,
4519                    )
4520                    .help("split deeply nested markup into components, or reduce generated nesting")
4521                    .into());
4522            }
4523
4524            for event in Walker::new_range(source, range.start, range.end) {
4525                match event {
4526                    Event::Element(element) => {
4527                        self.validate_closed_element(&element)?;
4528                        self.reject_boundary_or_hydrate_in_route(&element, false)?;
4529                        if element.name().eq_ignore_ascii_case("style") {
4530                            self.validate_style_element(&element)?;
4531                        } else if !element.self_closing() && !element.is_void() {
4532                            stack.push((element.inner(), depth + 1));
4533                        }
4534                    }
4535                    Event::Text(text) => {
4536                        if text.contains('<') {
4537                            return Err(self
4538                                .authoring_error(
4539                                    codes::MALFORMED_HTML_TAG,
4540                                    "malformed HTML tag in <route>",
4541                                )
4542                                .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
4543                                .into());
4544                        }
4545                    }
4546                    Event::Comment(comment_range) => {
4547                        self.validate_comment_range(source, comment_range)?;
4548                    }
4549                    Event::Declaration(declaration_range) => {
4550                        self.validate_declaration_range(source, declaration_range)?;
4551                    }
4552                    Event::ClosingTag(closing_range) => {
4553                        return Err(self.unexpected_closing_tag_error(source, closing_range));
4554                    }
4555                }
4556            }
4557        }
4558        Ok(())
4559    }
4560
4561    /// Reject `<webui-hydrate>` in route markup and boundaries that are not
4562    /// direct route children.
4563    ///
4564    /// `<route>` children are validated by a separate well-formedness walker
4565    /// ([`Self::parse_child_routes`]/[`Self::validate_ignored_route_html`]),
4566    /// not the main fragment dispatch in [`Self::parse_range`], so both
4567    /// reserved directives need an explicit check here to produce the same
4568    /// structured diagnostics as everywhere else instead of silently passing
4569    /// through as ignored HTML.
4570    fn reject_boundary_or_hydrate_in_route(
4571        &self,
4572        element: &Element<'_>,
4573        allow_boundary: bool,
4574    ) -> Result<()> {
4575        if element.name() == "boundary" && !allow_boundary {
4576            Err(self.route_boundary_placement_error(element))
4577        } else if element.name().eq_ignore_ascii_case("webui-hydrate") {
4578            Err(self.authored_webui_hydrate_error(element))
4579        } else {
4580            Ok(())
4581        }
4582    }
4583
4584    fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
4585        if !element.self_closing()
4586            && !element.is_void()
4587            && element.close_end() == element.content_end()
4588        {
4589            return Err(self
4590                .html_error(
4591                    codes::UNCLOSED_HTML_TAG,
4592                    format!("unclosed <{}> tag", element.name()),
4593                    element.source(),
4594                    element.start,
4595                )
4596                .help(format!(
4597                    "add the matching </{}> closing tag, or make the element self-closing",
4598                    element.name()
4599                ))
4600                .into());
4601        }
4602        Ok(())
4603    }
4604
4605    fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
4606        let inner = element.inner();
4607        let style_content = &element.source()[inner.start..inner.end];
4608        self.css_parser
4609            .extract_definitions_requirements_and_comments(
4610                style_content,
4611                self.options.legal_comments,
4612            )
4613            .map_err(|e| self.css_diagnostic(e))?;
4614        Ok(())
4615    }
4616
4617    fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4618        let start = range.start;
4619        if source[range].ends_with("-->") {
4620            return Ok(());
4621        }
4622        Err(self
4623            .html_error(
4624                codes::UNTERMINATED_HTML_COMMENT,
4625                "unterminated HTML comment in <route>",
4626                source,
4627                start,
4628            )
4629            .help("close the comment with `-->`")
4630            .into())
4631    }
4632
4633    fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4634        let start = range.start;
4635        if source[range].ends_with('>') {
4636            return Ok(());
4637        }
4638        Err(self
4639            .html_error(
4640                codes::UNTERMINATED_HTML_DECLARATION,
4641                "unterminated HTML declaration in <route>",
4642                source,
4643                start,
4644            )
4645            .help("close the declaration with `>`")
4646            .into())
4647    }
4648
4649    fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
4650        let start = range.start;
4651        let title = match html::parse_tag(&source[range]) {
4652            Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
4653            None => "unexpected closing tag in <route>".to_string(),
4654        };
4655        self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
4656            .help("remove it, or add the matching opening tag before it")
4657            .into()
4658    }
4659
4660    fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
4661        route_parser::RouteAttributes {
4662            path: element.attr("path").unwrap_or_default().to_string(),
4663            component: element.attr("component").unwrap_or_default().to_string(),
4664            exact: element.has_attr("exact"),
4665            query: element.attr("query").unwrap_or_default().to_string(),
4666            keep_alive: element.has_attr("keep-alive"),
4667            cache_tags: element
4668                .attr("cache-tags")
4669                .map(route_parser::parse_tag_list)
4670                .unwrap_or_default(),
4671            invalidates: element
4672                .attr("invalidates")
4673                .map(route_parser::parse_tag_list)
4674                .unwrap_or_default(),
4675            pending_component: element.attr("pending").unwrap_or_default().to_string(),
4676            error_component: element.attr("error").unwrap_or_default().to_string(),
4677        }
4678    }
4679
4680    fn parse_route_as_fragment(
4681        &mut self,
4682        element: &Element<'_>,
4683        ancestor_params: &std::collections::HashSet<String>,
4684        depth: usize,
4685    ) -> Result<WebUiFragmentRoute> {
4686        let attrs = Self::route_attrs_from_element(element);
4687        let path = attrs.path.clone();
4688        let component = attrs.component.clone();
4689
4690        route_parser::validate_attributes(&attrs)?;
4691
4692        let own_params: std::collections::HashSet<String> =
4693            route_parser::extract_params(&path)?.into_iter().collect();
4694        let mut all_params = ancestor_params.clone();
4695        all_params.extend(own_params.iter().cloned());
4696        if !attrs.cache_tags.is_empty() {
4697            route_parser::validate_tag_placeholders(
4698                &attrs.cache_tags,
4699                &all_params,
4700                "cache-tags",
4701                &path,
4702            )?;
4703        }
4704        if !attrs.invalidates.is_empty() {
4705            route_parser::validate_tag_placeholders(
4706                &attrs.invalidates,
4707                &all_params,
4708                "invalidates",
4709                &path,
4710            )?;
4711        }
4712
4713        if !attrs.pending_component.is_empty() {
4714            self.ensure_route_component_parsed(&attrs.pending_component)?;
4715        }
4716        if !attrs.error_component.is_empty() {
4717            self.ensure_route_component_parsed(&attrs.error_component)?;
4718        }
4719
4720        self.ensure_route_component_parsed(&component)?;
4721        let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4722        let children =
4723            self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
4724
4725        let mut route = route_parser::build_route_fragment(&attrs, component, children);
4726        route.content_fragment_id = content_fragment_id;
4727        Ok(route)
4728    }
4729
4730    /// Ensure a route-referenced component is parsed and registered.
4731    fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
4732        if component.is_empty()
4733            || !self.component_registry.contains(component)
4734            || self.fragment_records.contains_key(component)
4735        {
4736            return Ok(());
4737        }
4738
4739        let component_data = self
4740            .component_registry
4741            .get(component)
4742            .ok_or_else(|| {
4743                self.authoring_error(
4744                    codes::UNKNOWN_COMPONENT,
4745                    format!("unknown component <{component}>"),
4746                )
4747                .help(self.unknown_component_help(component))
4748            })?
4749            .clone();
4750        let artifact_html = self
4751            .component_registry
4752            .component_artifact_source(component)
4753            .map(str::to_string);
4754        let authored_html = self.component_registry.component_authored_source(component);
4755
4756        let built = self.build_component_templates(ComponentTemplateInput {
4757            tag_name: component,
4758            html: &component_data.html_content,
4759            artifact_html: artifact_html.as_deref(),
4760            authored_html: authored_html.as_deref(),
4761            css_content: component_data.css_content.as_deref(),
4762            artifact_needed: self.plugin.is_some(),
4763        })?;
4764
4765        if let Some(ref mut p) = self.plugin {
4766            let component_data = self
4767                .component_registry
4768                .get(component)
4769                .ok_or_else(|| {
4770                    ParserError::NotFound(format!(
4771                        "component <{component}> disappeared during CSS compilation"
4772                    ))
4773                })?
4774                .clone();
4775            p.component_built(built.plugin_context(&component_data))?;
4776        }
4777
4778        let saved_buffer = std::mem::take(&mut self.raw_buffer);
4779        self.parse(component, &built.ssr)?;
4780        self.raw_buffer = saved_buffer;
4781
4782        Ok(())
4783    }
4784
4785    /// Skipped attribute names for components.
4786    const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
4787    /// Skipped attribute prefixes for components.
4788    const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-"];
4789    const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
4790
4791    fn is_skipped_attribute(name: &str) -> bool {
4792        Self::SKIPPED_ATTRIBUTES.contains(&name)
4793            || Self::SKIPPED_ATTRIBUTE_PREFIXES
4794                .iter()
4795                .any(|prefix| name.starts_with(prefix))
4796    }
4797
4798    /// Properties that must never be set via `:attr` complex bindings.
4799    /// These enable XSS (HTML injection) or arbitrary code execution.
4800    fn is_blocked_complex_property(name: &str) -> bool {
4801        matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
4802    }
4803
4804    /// Build both SSR-facing and plugin-facing component template views.
4805    fn build_component_templates(
4806        &mut self,
4807        input: ComponentTemplateInput<'_>,
4808    ) -> Result<BuiltComponentTemplate> {
4809        let tag_name = input.tag_name;
4810        let synthesize_fast_shadow =
4811            input.artifact_html.is_some() && self.options.dom_strategy == DomStrategy::Shadow;
4812        let fast_html = synthesize_fast_shadow
4813            .then(|| Self::append_shadow_mode_if_missing(input.html.to_string()));
4814        let html = fast_html.as_deref().unwrap_or(input.html);
4815        let css_content = input.css_content;
4816        let dom_analysis = self.analyze_component_dom(tag_name, html, input.authored_html)?;
4817        let is_light = !dom_analysis.uses_shadow_dom;
4818        let mut inline_style_ranges = Vec::new();
4819        if is_light {
4820            let mut comment_ranges = Vec::new();
4821            Self::collect_html_comment_and_style_ranges(
4822                html,
4823                &mut comment_ranges,
4824                &mut inline_style_ranges,
4825            )?;
4826            if let Some(css) = css_content {
4827                css_light::validate_global_css(tag_name, css)?;
4828            }
4829            for (start, end) in &inline_style_ranges {
4830                css_light::validate_global_css(tag_name, &html[*start..*end])?;
4831            }
4832        }
4833        if self.component_registry.contains(tag_name) {
4834            self.component_registry
4835                .prepare_policy_css(tag_name, dom_analysis.uses_shadow_dom)?;
4836        }
4837        let css_content = self
4838            .component_registry
4839            .get(tag_name)
4840            .and_then(|component| component.css_content.as_deref())
4841            .or(css_content);
4842        let adopted_specifier = match self.options.css_strategy {
4843            CssStrategy::Module if css_content.is_some() => Some(tag_name),
4844            _ => None,
4845        };
4846        // CSS resources are installed by the handler from the precomputed
4847        // tree-local closure. Keeping template serialization style-free avoids
4848        // duplicating Light descendants and lets repeated Shadow instances each
4849        // receive an exact, claimable resource set.
4850        //
4851        // The resolved delivery is reported to the plugin as build context; a
4852        // plugin whose client runtime builds its own roots from the captured
4853        // template decides for itself what to do with it. Light CSS is
4854        // Document/tree-owned and is installed by the precomputed closure.
4855        let style = if dom_analysis.uses_shadow_dom && self.plugin.is_some() {
4856            self.component_style_delivery(tag_name, css_content)
4857        } else {
4858            None
4859        };
4860
4861        let runtime_attr_source = match dom_analysis.authored_shadow_root {
4862            Some((start, end)) => &html[start..end],
4863            None => html,
4864        };
4865        let artifact_source = if input.artifact_needed {
4866            input.artifact_html.or_else(|| {
4867                Self::template_has_stripped_runtime_attrs(runtime_attr_source).then_some(html)
4868            })
4869        } else {
4870            None
4871        };
4872        // A `w-render`/`w-hydrate` component authors its policy on a plain
4873        // `<template>` wrapper. The policy lands on the host element as
4874        // generated CSS, so the wrapper itself must not survive into a Light
4875        // template, where its contents would never render.
4876        let policy_wrapper = parse_component_render_policy(tag_name, html)?.is_authored();
4877        // Keep root runtime attributes only when the plugin processes them.
4878        let preserve_root_bindings = self.component_processing.process_root_template_attributes;
4879        // Keep CSS braces after client bindings when requested by the plugin.
4880        let styles_at_end = matches!(self.options.css_strategy, CssStrategy::Style)
4881            && self.component_processing.inline_styles_after_content;
4882        let ssr = self.process_component_template_with_mode(
4883            html,
4884            ComponentStyleInjection {
4885                css_snippet: None,
4886                adopted_specifier,
4887            },
4888            dom_analysis,
4889            ComponentTemplateMode {
4890                preserve_runtime_attrs: preserve_root_bindings,
4891                policy_wrapper,
4892                styles_at_end,
4893            },
4894        )?;
4895        let artifact = match artifact_source {
4896            Some(source) => {
4897                let fast_artifact = synthesize_fast_shadow
4898                    .then(|| Self::append_shadow_mode_if_missing(source.to_string()));
4899                let source = fast_artifact.as_deref().unwrap_or(source);
4900                let artifact_dom_analysis = analyze_component_dom_with_diagnostic_source(
4901                    tag_name,
4902                    source,
4903                    self.options.dom_strategy,
4904                    input.authored_html,
4905                )?;
4906                Some(self.process_component_template_with_mode(
4907                    source,
4908                    ComponentStyleInjection {
4909                        css_snippet: None,
4910                        adopted_specifier,
4911                    },
4912                    artifact_dom_analysis,
4913                    ComponentTemplateMode {
4914                        // Client artifacts preserve runtime-only attributes
4915                        // (`@event`, `:bind`, `?cond`) for the plugin runtime.
4916                        preserve_runtime_attrs: true,
4917                        policy_wrapper,
4918                        styles_at_end,
4919                    },
4920                )?)
4921            }
4922            None => None,
4923        };
4924        Ok(BuiltComponentTemplate {
4925            ssr,
4926            artifact,
4927            uses_shadow_dom: dom_analysis.uses_shadow_dom,
4928            style,
4929        })
4930    }
4931
4932    /// Resolve how a component's compiled CSS reaches the browser.
4933    ///
4934    /// Build-time only, once per component definition, and only when a plugin
4935    /// can observe it.
4936    fn component_style_delivery(
4937        &self,
4938        tag_name: &str,
4939        css_content: Option<&str>,
4940    ) -> Option<OwnedComponentStyle> {
4941        let css = css_content?;
4942        Some(match self.options.css_strategy {
4943            CssStrategy::Link => {
4944                OwnedComponentStyle::Link(self.options.css_link_options.resolve(tag_name, css).href)
4945            }
4946            CssStrategy::Style => OwnedComponentStyle::Inline(css.trim().to_string()),
4947            CssStrategy::Module => OwnedComponentStyle::Adopted(tag_name.to_string()),
4948        })
4949    }
4950
4951    /// Process component template HTML for SSR output.
4952    ///
4953    /// An authored declarative Shadow DOM wrapper is the source of truth.
4954    ///
4955    /// - **Dev supplied `<template shadowrootmode="open">`:** preserved verbatim — including
4956    ///   `shadowrootmode`, `shadowrootadoptedstylesheets`, signal fragments,
4957    ///   and any other custom attributes. SSR strips runtime-only attributes
4958    ///   (`@event`, `:bind`, `?cond`) from the opening tag, since those are
4959    ///   protocol metadata and never appear in HTML output. Plugin-facing
4960    ///   artifacts preserve them. If a CSS snippet is supplied, it is injected
4961    ///   immediately inside the opening tag (before the dev's children) so
4962    ///   styles still apply. For `CssStrategy::Module`, the parser appends
4963    ///   `shadowrootadoptedstylesheets="<tag>"` when it is missing.
4964    ///
4965    /// - **Sole bare `<template>` root:** unwraps the wrapper and emits its
4966    ///   contents as explicit Light DOM.
4967    ///
4968    /// - **No authored root:** follows the configured fallback, generating an
4969    ///   open Shadow wrapper for `DomStrategy::Shadow` or emitting direct Light
4970    ///   DOM for `DomStrategy::Light`.
4971    ///
4972    /// Performance: zero recursion, zero regex. The dev-template path uses
4973    /// quote-aware scanners for opening-tag queries and pre-sizes the output
4974    /// buffer to avoid reallocation in the hot path.
4975    #[cfg(test)]
4976    fn process_component_template(
4977        &mut self,
4978        html: &str,
4979        css_snippet: Option<&str>,
4980        adopted_specifier: Option<&str>,
4981    ) -> Result<String> {
4982        let dom_analysis = analyze_component_dom("component", html, self.options.dom_strategy)?;
4983        self.process_component_template_with_mode(
4984            html,
4985            ComponentStyleInjection {
4986                css_snippet,
4987                adopted_specifier,
4988            },
4989            dom_analysis,
4990            ComponentTemplateMode {
4991                preserve_runtime_attrs: false,
4992                policy_wrapper: false,
4993                styles_at_end: false,
4994            },
4995        )
4996    }
4997
4998    fn process_component_template_with_mode(
4999        &mut self,
5000        html: &str,
5001        style: ComponentStyleInjection<'_>,
5002        dom_analysis: ComponentDomAnalysis,
5003        mode: ComponentTemplateMode,
5004    ) -> Result<String> {
5005        let trimmed_end = html.trim_end();
5006        let (trimmed, content_start) = html::leading_content(trimmed_end);
5007        let snippet = style.css_snippet.unwrap_or_default();
5008
5009        let processed = if let Some((root_start, root_end)) = dom_analysis.authored_shadow_root {
5010            let trim_start = content_start;
5011            let trim_end = html.trim_end().len();
5012            if root_start < trim_start || root_end > trim_end || root_start >= root_end {
5013                return Err(ParserError::Html(
5014                    "invalid declarative Shadow DOM source range".to_string(),
5015                ));
5016            }
5017            let root = &html[root_start..root_end];
5018            // Compiler-owned policy attributes are stripped from both views;
5019            // runtime attributes survive only in the captured client template.
5020            let base = self.strip_template_build_attrs(root, mode.preserve_runtime_attrs);
5021            let with_adopted = Self::append_adopted_attr_if_missing(base, style.adopted_specifier);
5022            let root =
5023                Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end);
5024            let mut result =
5025                String::with_capacity(trimmed.len() + root.len() - (root_end - root_start));
5026            result.push_str(&html[trim_start..root_start]);
5027            result.push_str(&root);
5028            result.push_str(&html[root_end..trim_end]);
5029            result
5030        } else if let Some((content_start, content_end)) = dom_analysis.authored_light_root {
5031            let base = html[content_start..content_end].to_string();
5032            if snippet.is_empty() {
5033                base
5034            } else {
5035                let mut result = String::with_capacity(snippet.len() + base.len());
5036                result.push_str(snippet);
5037                result.push_str(&base);
5038                result
5039            }
5040        } else if dom_analysis.uses_shadow_dom {
5041            if mode.policy_wrapper {
5042                let base = self.strip_template_build_attrs(trimmed, mode.preserve_runtime_attrs);
5043                let with_shadow = Self::append_shadow_mode_if_missing(base);
5044                let with_adopted =
5045                    Self::append_adopted_attr_if_missing(with_shadow, style.adopted_specifier);
5046                Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end)
5047            } else {
5048                let adopted = style.adopted_specifier.unwrap_or_default();
5049                let adopted_extra = if adopted.is_empty() {
5050                    0
5051                } else {
5052                    Self::adopted_attr_len(adopted)
5053                };
5054                let mut result =
5055                    String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
5056                result.push_str("<template shadowrootmode=\"open\"");
5057                if !adopted.is_empty() {
5058                    Self::push_adopted_attr(&mut result, adopted);
5059                }
5060                result.push('>');
5061                if mode.styles_at_end {
5062                    result.push_str(trimmed);
5063                    result.push_str(snippet);
5064                } else {
5065                    result.push_str(snippet);
5066                    result.push_str(trimmed);
5067                }
5068                result.push_str("</template>");
5069                result
5070            }
5071        } else {
5072            // A policy component authors its `w-render`/`w-hydrate` on a plain
5073            // `<template>` wrapper. The policy is applied to the host element
5074            // through generated CSS, so a Light template must drop the wrapper
5075            // or its contents would never render.
5076            let base = if mode.policy_wrapper {
5077                Self::unwrap_component_template(trimmed)
5078            } else {
5079                trimmed.to_string()
5080            };
5081            if snippet.is_empty() {
5082                base
5083            } else {
5084                let mut result = String::with_capacity(snippet.len() + base.len());
5085                result.push_str(snippet);
5086                result.push_str(&base);
5087                result
5088            }
5089        };
5090
5091        self.strip_template_comments(processed)
5092    }
5093
5094    fn inject_css_snippet_into_template(html: String, snippet: &str, at_end: bool) -> String {
5095        if snippet.is_empty() {
5096            return html;
5097        }
5098
5099        // Trail the snippet after the template body (before the closing
5100        // `</template>`) so a client runtime that scans the body for `{`/`}`
5101        // bindings (FAST's declarative TemplateParser) never mistakes raw CSS
5102        // rule blocks for bindings. Styles apply regardless of shadow-root
5103        // position. Falls back to opening-tag injection when no closing tag is
5104        // present so styles are never silently dropped.
5105        if at_end {
5106            if let Some(close_start) = html.rfind("</template>") {
5107                let mut result = String::with_capacity(html.len() + snippet.len());
5108                result.push_str(&html[..close_start]);
5109                result.push_str(snippet);
5110                result.push_str(&html[close_start..]);
5111                return result;
5112            }
5113        }
5114
5115        match html::find_tag_close(&html) {
5116            Some(open_end) => {
5117                let mut result = String::with_capacity(html.len() + snippet.len());
5118                result.push_str(&html[..=open_end]);
5119                result.push_str(snippet);
5120                result.push_str(&html[open_end + 1..]);
5121                result
5122            }
5123            // Malformed opening tag (no closing `>`): emit as-is rather than
5124            // panic. The downstream parser surfaces the error.
5125            None => html,
5126        }
5127    }
5128
5129    fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
5130        let Some(adopted) = adopted_specifier else {
5131            return html;
5132        };
5133        let Some(tag) = html::parse_tag(&html) else {
5134            return html;
5135        };
5136        if !tag.name.eq_ignore_ascii_case("template")
5137            || tag.closing
5138            || tag.attrs().any(|attr| {
5139                attr.name
5140                    .eq_ignore_ascii_case(Self::ADOPTED_STYLESHEETS_ATTR)
5141            })
5142        {
5143            return html;
5144        }
5145
5146        let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
5147        result.push_str(&html[..tag.close]);
5148        Self::push_adopted_attr(&mut result, adopted);
5149        result.push_str(&html[tag.close..]);
5150        result
5151    }
5152
5153    fn append_shadow_mode_if_missing(html: String) -> String {
5154        let Some(tag) = html::parse_tag(&html) else {
5155            return html;
5156        };
5157        if !tag.name.eq_ignore_ascii_case("template")
5158            || tag.closing
5159            || tag
5160                .attrs()
5161                .any(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
5162        {
5163            return html;
5164        }
5165
5166        let mut result = String::with_capacity(html.len() + 22);
5167        result.push_str(&html[..tag.close]);
5168        result.push_str(" shadowrootmode=\"open\"");
5169        result.push_str(&html[tag.close..]);
5170        result
5171    }
5172
5173    fn unwrap_component_template(html: &str) -> String {
5174        let Some(tag) = html::parse_tag(html) else {
5175            return html.to_string();
5176        };
5177        let inner_start = tag.close + 1;
5178        let inner_end = html.rfind("</template>").unwrap_or(html.len());
5179        if inner_start >= inner_end {
5180            return String::new();
5181        }
5182        html[inner_start..inner_end].to_string()
5183    }
5184
5185    fn push_adopted_attr(out: &mut String, adopted: &str) {
5186        out.push(' ');
5187        out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
5188        out.push_str("=\"");
5189        out.push_str(adopted);
5190        out.push('"');
5191    }
5192
5193    fn adopted_attr_len(adopted: &str) -> usize {
5194        4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
5195    }
5196
5197    fn template_has_stripped_runtime_attrs(html: &str) -> bool {
5198        let (trimmed, _) = html::leading_content(html);
5199        let Some(tag) = html::parse_tag(trimmed) else {
5200            return false;
5201        };
5202        if !tag.name.eq_ignore_ascii_case("template") || tag.closing {
5203            return false;
5204        }
5205        tag.attrs().any(|attr| {
5206            attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
5207        })
5208    }
5209
5210    fn strip_template_comments(&mut self, html: String) -> Result<String> {
5211        let mut ranges = Vec::new();
5212        let mut style_ranges = Vec::new();
5213        Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
5214
5215        for (style_start, style_end) in style_ranges {
5216            let css = &html[style_start..style_end];
5217            let (_defs, _requirements, comments) = self
5218                .css_parser
5219                .extract_definitions_requirements_and_comments(css, self.options.legal_comments)
5220                .map_err(|e| self.css_diagnostic(e))?;
5221            for comment in comments {
5222                let comment_text = &css[comment.start_byte..comment.end_byte];
5223                if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
5224                    continue;
5225                }
5226                ranges.push((
5227                    style_start + comment.start_byte,
5228                    style_start + comment.end_byte,
5229                ));
5230            }
5231        }
5232
5233        if ranges.is_empty() {
5234            return Ok(html);
5235        }
5236
5237        Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
5238    }
5239
5240    /// Collect HTML comment ranges and `<style>` raw-text ranges in one scan.
5241    fn collect_html_comment_and_style_ranges(
5242        html: &str,
5243        comment_ranges: &mut Vec<(usize, usize)>,
5244        style_ranges: &mut Vec<(usize, usize)>,
5245    ) -> Result<()> {
5246        let mut index = 0usize;
5247        while index < html.len() {
5248            let remaining = &html[index..];
5249            if remaining.starts_with("<!--") {
5250                let Some(end) = html::find_comment_close(remaining) else {
5251                    return Err(Diagnostic::error("unterminated HTML comment")
5252                        .code(codes::UNTERMINATED_HTML_COMMENT)
5253                        .at_offset(html, index)
5254                        .help("close the comment with `-->`")
5255                        .into());
5256                };
5257                comment_ranges.push((index, index + end));
5258                index += end;
5259                continue;
5260            }
5261
5262            if remaining.starts_with('<') {
5263                if let Some((style_start, style_end, close_end)) =
5264                    html::style_element_bounds(remaining)
5265                {
5266                    style_ranges.push((index + style_start, index + style_end));
5267                    index += close_end;
5268                    continue;
5269                }
5270
5271                if let Some(tag) = html::parse_tag(remaining) {
5272                    index += tag.close + 1;
5273                    continue;
5274                }
5275            }
5276
5277            let Some(ch) = remaining.chars().next() else {
5278                break;
5279            };
5280            index += ch.len_utf8();
5281        }
5282        Ok(())
5283    }
5284
5285    /// Strip compiler-owned component policy attributes and, for the SSR view,
5286    /// runtime attributes starting with `@`, `:`, or `?` from the opening
5287    /// `<template>` tag.
5288    ///
5289    /// Uses the same quote-aware tag scanner as the main HTML pipeline.
5290    fn strip_template_build_attrs(&mut self, html: &str, preserve_runtime_attrs: bool) -> String {
5291        let Some(tag) = html::parse_tag(html) else {
5292            return html.to_string();
5293        };
5294
5295        let mut removals: Vec<(usize, usize)> = Vec::new();
5296        for attr in tag.attrs() {
5297            let runtime_attr = attr.name.starts_with('@')
5298                || attr.name.starts_with(':')
5299                || attr.name.starts_with('?');
5300            let policy_attr = matches!(
5301                attr.name,
5302                COMPONENT_RENDER_ATTR | COMPONENT_HYDRATE_ATTR | COMPONENT_RESERVE_BLOCK_SIZE_ATTR
5303            );
5304            if policy_attr || (!preserve_runtime_attrs && runtime_attr) {
5305                let mut start = attr.raw_range.start;
5306                while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
5307                    start -= 1;
5308                }
5309                removals.push((start, attr.raw_range.end));
5310            }
5311        }
5312
5313        if removals.is_empty() {
5314            return html.to_string();
5315        }
5316
5317        // Rebuild the string, skipping removed ranges
5318        let mut result = String::with_capacity(html.len());
5319        let mut pos = 0;
5320        for (start, end) in &removals {
5321            result.push_str(&html[pos..*start]);
5322            pos = *end;
5323        }
5324        result.push_str(&html[pos..]);
5325        result
5326    }
5327}
5328
5329#[cfg(test)]
5330mod tests {
5331    use webui_test_utils::*;
5332
5333    use super::*;
5334
5335    #[derive(Default)]
5336    struct ContextArtifactPlugin {
5337        artifacts: Vec<crate::plugin::ComponentTemplateArtifact>,
5338    }
5339
5340    impl ParserPlugin for ContextArtifactPlugin {
5341        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5342            self.artifacts
5343                .push(crate::plugin::ComponentTemplateArtifact::template(
5344                    context.component.tag_name.clone(),
5345                    context.template.to_string(),
5346                    context.uses_shadow_dom,
5347                ));
5348            Ok(())
5349        }
5350
5351        fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
5352            Ok(ParserPluginArtifacts::ComponentTemplates(self.artifacts))
5353        }
5354    }
5355
5356    fn structural_matcher(value: &str) -> FragmentMatcher {
5357        signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
5358    }
5359
5360    mod fast_parser;
5361
5362    fn boundary_matcher(name: &str, declaration: u32) -> FragmentMatcher {
5363        webui_test_utils::boundary(name, declaration)
5364    }
5365
5366    fn boundary_end_matcher(declaration: u32) -> FragmentMatcher {
5367        webui_test_utils::boundary_end(declaration)
5368    }
5369
5370    #[test]
5371    fn unwrapped_component_uses_configured_dom_strategy() {
5372        assert!(
5373            analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Shadow)
5374                .expect("valid component")
5375                .uses_shadow_dom
5376        );
5377        assert!(
5378            !analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Light)
5379                .expect("valid component")
5380                .uses_shadow_dom
5381        );
5382    }
5383
5384    #[test]
5385    fn bare_template_explicitly_selects_light_dom_over_shadow_fallback() {
5386        let analysis = analyze_component_dom(
5387            "x-card",
5388            "<template><div>content</div></template>",
5389            DomStrategy::Shadow,
5390        )
5391        .expect("valid explicit Light wrapper");
5392        assert!(!analysis.uses_shadow_dom);
5393        assert!(analysis.authored_light_root.is_some());
5394
5395        let attributed = analyze_component_dom(
5396            "x-card",
5397            r#"<template data-purpose="content"><div>content</div></template>"#,
5398            DomStrategy::Shadow,
5399        )
5400        .expect("ordinary template remains valid");
5401        assert!(attributed.uses_shadow_dom);
5402        assert!(attributed.authored_light_root.is_none());
5403    }
5404
5405    #[test]
5406    fn plugin_artifacts_preserve_resolved_shadow_mode() {
5407        let component_html = r#"<template shadowrootmode="open"><p>shadow</p></template>"#;
5408        {
5409            let mut parser = HtmlParser::with_plugin_options(
5410                Box::new(ContextArtifactPlugin::default()),
5411                CssStrategy::Style,
5412            );
5413            parser
5414                .component_registry_mut()
5415                .register_component(ComponentRegistration::new(
5416                    "x-card",
5417                    component_html,
5418                    None,
5419                    false,
5420                ))
5421                .expect("register component");
5422            parser
5423                .parse("index.html", "<x-card></x-card>")
5424                .expect("parse component");
5425
5426            let ParserPluginArtifacts::ComponentTemplates(artifacts) =
5427                parser.take_plugin_artifacts().expect("consume artifacts")
5428            else {
5429                panic!("expected component artifacts");
5430            };
5431            assert!(artifacts[0].uses_shadow_dom);
5432        }
5433    }
5434
5435    #[test]
5436    fn open_shadow_wrapper_marks_component_shadow() {
5437        let html =
5438            "<!-- lead --><template shadowrootmode='open'><slot></slot></template><!-- tail -->";
5439        assert!(
5440            analyze_component_dom("x-card", html, DomStrategy::Light)
5441                .expect("valid Shadow wrapper")
5442                .uses_shadow_dom
5443        );
5444    }
5445
5446    #[test]
5447    fn closed_shadow_wrapper_is_rejected() {
5448        let error = analyze_component_dom(
5449            "x-card",
5450            "<template shadowrootmode=\"closed\"><p>content</p></template>",
5451            DomStrategy::Shadow,
5452        )
5453        .expect_err("closed mode must fail");
5454        assert!(matches!(
5455            error,
5456            ParserError::Template(ref diagnostic)
5457                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5458                    && diagnostic.component_name() == Some("x-card")
5459        ));
5460    }
5461
5462    #[test]
5463    fn shadow_wrapper_must_be_the_only_top_level_element() {
5464        let error = analyze_component_dom(
5465            "x-card",
5466            "<template shadowrootmode=\"open\"><p>content</p></template><span>extra</span>",
5467            DomStrategy::Light,
5468        )
5469        .expect_err("multiple roots must fail");
5470        assert!(matches!(
5471            error,
5472            ParserError::Template(ref diagnostic)
5473                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5474        ));
5475    }
5476
5477    #[test]
5478    fn shadowrootmode_rejects_invalid_value_and_placement() {
5479        for html in [
5480            "<template shadowrootmode=\"invalid\"><p>content</p></template>",
5481            "<template shadowrootmode=\" open \"><p>content</p></template>",
5482            "<template :shadowrootmode=\"{{mode}}\"><p>content</p></template>",
5483            "<template shadowrootmode=\"open\" />",
5484            "<div><template shadowrootmode=\"open\"><p>content</p></template></div>",
5485            "<div shadowrootmode=\"open\">content</div>",
5486        ] {
5487            let error = analyze_component_dom("x-card", html, DomStrategy::Shadow)
5488                .expect_err("invalid shadowrootmode must fail");
5489            assert!(matches!(
5490                error,
5491                ParserError::Template(ref diagnostic)
5492                    if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5493                        && diagnostic.component_name() == Some("x-card")
5494                        && diagnostic.help_text().is_some()
5495                        && diagnostic.snippet_text().is_some()
5496            ));
5497        }
5498    }
5499
5500    #[test]
5501    fn light_component_slot_is_rejected() {
5502        let source = "<div><slot name=\"label\"></slot></div>";
5503        let error = analyze_component_dom("x-card", source, DomStrategy::Light)
5504            .expect_err("Light slot must fail");
5505        assert!(matches!(
5506            error,
5507            ParserError::Template(ref diagnostic)
5508                if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5509                    && diagnostic.help_text().is_some_and(|help| help.contains("shadowrootmode"))
5510        ));
5511        assert!(
5512            analyze_component_dom("x-card", source, DomStrategy::Shadow)
5513                .expect("implicit Shadow components support slots")
5514                .uses_shadow_dom
5515        );
5516
5517        let explicit_light = "<template><slot name=\"label\"></slot></template>";
5518        let error = analyze_component_dom("x-card", explicit_light, DomStrategy::Shadow)
5519            .expect_err("bare template explicitly selects Light");
5520        assert!(matches!(
5521            error,
5522            ParserError::Template(ref diagnostic)
5523                if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5524        ));
5525    }
5526
5527    #[test]
5528    fn webui_plugin_records_authored_shadow_wrapper() {
5529        let mut parser =
5530            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5531        parser
5532            .component_registry_mut()
5533            .register_component(ComponentRegistration::new(
5534                "x-card",
5535                "<!-- lead --><template shadowrootmode=\"open\" @click=\"{onClick()}\"><slot></slot></template><!-- tail -->",
5536                None,
5537                true,
5538            ))
5539            .expect("register component");
5540        parser
5541            .parse("index.html", "<x-card>projected</x-card>")
5542            .expect("parse component");
5543
5544        let ParserPluginArtifacts::ComponentTemplates(templates) =
5545            parser.take_plugin_artifacts().expect("plugin artifacts")
5546        else {
5547            panic!("expected component templates");
5548        };
5549        assert!(templates[0].uses_shadow_dom);
5550        assert!(templates[0].template_json.contains("\"sd\":1"));
5551        assert!(templates[0]
5552            .template_json
5553            .contains(r#""re":[["click","onClick",[]]]"#));
5554    }
5555
5556    /// Records the build context so tests can assert what the parser reports
5557    /// without asserting how any particular plugin reacts to it.
5558    struct StyleContextPlugin {
5559        captured: std::rc::Rc<std::cell::RefCell<Vec<(bool, Option<String>, String)>>>,
5560    }
5561
5562    impl crate::plugin::ParserPlugin for StyleContextPlugin {
5563        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5564            self.captured.borrow_mut().push((
5565                context.uses_shadow_dom,
5566                context.style.map(|style| format!("{style:?}")),
5567                context.template.to_string(),
5568            ));
5569            Ok(())
5570        }
5571    }
5572
5573    fn capture_style_context(
5574        strategy: CssStrategy,
5575        dom_strategy: DomStrategy,
5576        template: &str,
5577        css: Option<&str>,
5578    ) -> (bool, Option<String>, String) {
5579        let captured = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
5580        let mut parser = HtmlParser::with_plugin_options(
5581            Box::new(StyleContextPlugin {
5582                captured: std::rc::Rc::clone(&captured),
5583            }),
5584            ParserOptions {
5585                css_strategy: strategy,
5586                dom_strategy,
5587                ..ParserOptions::default()
5588            },
5589        );
5590        parser
5591            .component_registry_mut()
5592            .register_component(ComponentRegistration::new("x-card", template, css, true))
5593            .expect("register component");
5594        parser
5595            .parse("index.html", "<x-card></x-card>")
5596            .expect("parse component");
5597        let captured = captured.borrow();
5598        captured.first().cloned().expect("component registered")
5599    }
5600
5601    /// The parser reports the resolved delivery as data and injects nothing:
5602    /// a plugin that ignores `context.style` gets a style-free template even
5603    /// for an authored Shadow component.
5604    #[test]
5605    fn parser_reports_shadow_style_delivery_without_injecting_it() {
5606        for (strategy, expected) in [
5607            (CssStrategy::Link, "Link { href: \"x-card.css\" }"),
5608            (CssStrategy::Style, "Inline { css: \".card{color:red}\" }"),
5609            (CssStrategy::Module, "Adopted { specifier: \"x-card\" }"),
5610        ] {
5611            let (uses_shadow_dom, style, template) = capture_style_context(
5612                strategy,
5613                DomStrategy::Shadow,
5614                "<template shadowrootmode=\"open\" shadowrootadoptedstylesheets=\"x-card\"><div class=\"card\"></div></template>",
5615                Some(".card{color:red}"),
5616            );
5617            assert!(uses_shadow_dom, "{strategy:?} should be shadow");
5618            assert_eq!(style.as_deref(), Some(expected), "{strategy:?} delivery");
5619            assert!(
5620                !template.contains("<style>") && !template.contains("<link"),
5621                "{strategy:?} template must stay injection-free: {template}"
5622            );
5623        }
5624    }
5625
5626    /// Light CSS belongs to the owning CSS tree, so there is nothing for a
5627    /// plugin to place inside a runtime-created root.
5628    #[test]
5629    fn parser_reports_no_style_delivery_for_light_components() {
5630        let (uses_shadow_dom, style, _) = capture_style_context(
5631            CssStrategy::Style,
5632            DomStrategy::Light,
5633            "<div class=\"card\"></div>",
5634            Some(".card{color:red}"),
5635        );
5636        assert!(!uses_shadow_dom);
5637        assert_eq!(style, None);
5638    }
5639
5640    /// A component without CSS reports no delivery at all.
5641    #[test]
5642    fn parser_reports_no_style_delivery_without_css() {
5643        let (uses_shadow_dom, style, _) = capture_style_context(
5644            CssStrategy::Style,
5645            DomStrategy::Shadow,
5646            "<template shadowrootmode=\"open\"><div></div></template>",
5647            None,
5648        );
5649        assert!(uses_shadow_dom);
5650        assert_eq!(style, None);
5651    }
5652
5653    #[test]
5654    fn test_plugin_display_names() {
5655        assert_eq!(Plugin::Fast.to_string(), "fast");
5656        assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
5657        assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
5658        assert_eq!(Plugin::WebUI.to_string(), "webui");
5659    }
5660
5661    #[test]
5662    fn test_plugin_from_str_names() {
5663        assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
5664        assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
5665        assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
5666        assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
5667    }
5668
5669    #[test]
5670    fn test_plugin_from_str_unknown_mentions_supported_names() {
5671        let err = "legacy".parse::<Plugin>().unwrap_err();
5672        assert!(
5673            err.contains("fast-v3"),
5674            "error should mention fast-v3: {err}"
5675        );
5676        assert!(
5677            err.contains("fast-v2"),
5678            "error should mention fast-v2: {err}"
5679        );
5680        assert!(err.contains("fast"), "error should mention fast: {err}");
5681    }
5682
5683    #[test]
5684    fn test_parse_signal() {
5685        let mut parser = HtmlParser::new();
5686        let html = "Hello, {{name}}!";
5687        let result = parser.parse("test.html", html);
5688
5689        assert!(result.is_ok());
5690        let fragment_records = parser.into_fragment_records();
5691
5692        assert_stream!(
5693            fragment_records,
5694            "test.html",
5695            [raw("Hello, "), signal("name"), raw("!"),]
5696        );
5697    }
5698
5699    #[test]
5700    fn test_parse_raw_signal() {
5701        let mut parser = HtmlParser::new();
5702        let html = "Hello, {{{html_content}}}!";
5703        let result = parser.parse("test.html", html);
5704
5705        assert!(result.is_ok());
5706        let fragment_records = parser.into_fragment_records();
5707
5708        assert_stream!(
5709            fragment_records,
5710            "test.html",
5711            [raw("Hello, "), signal_raw("html_content"), raw("!"),]
5712        );
5713    }
5714
5715    #[test]
5716    fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
5717        let mut parser = HtmlParser::new();
5718        let html = "<nav>{{sectionName}} &gt; {{topicName}}</nav>";
5719        let result = parser.parse("test.html", html);
5720
5721        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5722        let fragment_records = parser.into_fragment_records();
5723
5724        assert_stream!(
5725            fragment_records,
5726            "test.html",
5727            [
5728                raw("<nav>"),
5729                signal("sectionName"),
5730                raw(" &gt; "),
5731                signal("topicName"),
5732                raw("</nav>"),
5733            ]
5734        );
5735    }
5736
5737    #[test]
5738    fn test_invalid_for_each_is_template_diagnostic() {
5739        let mut parser = HtmlParser::new();
5740        let err = parser
5741            .parse(
5742                "index.html",
5743                "<div>\n  <for each=\"person inpeople\"><p>x</p></for>\n</div>",
5744            )
5745            .expect_err("invalid for-each must error");
5746        assert!(matches!(err, ParserError::Template(_)));
5747        let msg = err.to_string();
5748        assert!(msg.contains("invalid <for> each expression"), "{msg}");
5749        assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
5750        // The <for> sits on line 2, column 3 (after the two-space indent),
5751        // reported rustc-style as `--> index.html:2:3`.
5752        assert!(
5753            msg.contains("--> index.html:2:3"),
5754            "missing line:column — {msg}"
5755        );
5756        assert!(msg.contains("help:"), "{msg}");
5757    }
5758
5759    #[test]
5760    fn test_invalid_if_condition_is_template_diagnostic() {
5761        let mut parser = HtmlParser::new();
5762        let err = parser
5763            .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
5764            .expect_err("invalid condition must error");
5765        assert!(matches!(err, ParserError::Template(_)));
5766        let msg = err.to_string();
5767        assert!(msg.contains("invalid <if> condition expression"), "{msg}");
5768        assert!(
5769            msg.contains("--> index.html:1:1"),
5770            "missing line:column — {msg}"
5771        );
5772    }
5773
5774    #[test]
5775    fn test_parse_for_directive() {
5776        let mut parser = HtmlParser::new();
5777        let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
5778
5779        let result = parser.parse("test.html", html);
5780        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5781        let fragment_records = parser.into_fragment_records();
5782
5783        assert_stream!(
5784            fragment_records,
5785            "test.html",
5786            [for_loop("item", "items", "for-1"),]
5787        );
5788
5789        // Verify the sub-fragment contains our item content
5790        assert_stream!(
5791            fragment_records,
5792            "for-1",
5793            [
5794                raw("<div class=\"item\">"),
5795                signal("item.name"),
5796                raw("</div>"),
5797            ]
5798        );
5799    }
5800
5801    #[test]
5802    fn test_parse_if_directive() {
5803        let mut parser = HtmlParser::new();
5804        let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
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!(fragment_records, "test.html", [if_cond("if-1"),]);
5811
5812        // Verify the sub-fragment contains our content
5813        assert_stream!(
5814            fragment_records,
5815            "if-1",
5816            [
5817                raw("<div>Welcome back, "),
5818                signal("username"),
5819                raw("!</div>"),
5820            ]
5821        );
5822    }
5823
5824    #[test]
5825    fn test_component_directive() {
5826        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5827        parser
5828            .component_registry
5829            .register_component(ComponentRegistration::new(
5830                "my-component",
5831                "<div>My Component</div>",
5832                Some("div { color: blue; }"),
5833                true,
5834            ))
5835            .expect("Failed to register component");
5836
5837        let result = parser.parse("test.html", "<my-component></my-component>");
5838        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5839        let records = parser.into_fragment_records();
5840
5841        assert_stream!(
5842            records,
5843            "test.html",
5844            [
5845                raw("<my-component"),
5846                structural_matcher("streaming_root:my-component"),
5847                raw(">"),
5848                component("my-component"),
5849                raw("</my-component>"),
5850            ]
5851        );
5852
5853        // Component template stream should contain the component content with
5854        // no compiler-owned Light DOM markers.
5855        let comp = &records["my-component"].fragments;
5856        assert_eq!(comp.len(), 1);
5857        assert!(
5858            matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5859                !raw.value.contains("<template shadowrootmode")
5860                    && raw.value.contains("<div")
5861                    && raw.value.contains(">My Component</div>"))
5862        );
5863    }
5864
5865    #[test]
5866    fn shadow_component_emits_style_hook_inside_declarative_root() {
5867        let mut parser = HtmlParser::new();
5868        parser
5869            .component_registry
5870            .register_component(ComponentRegistration::new(
5871                "my-component",
5872                r#"<template shadowrootmode="open"><div>My Component</div></template>"#,
5873                Some("div { color: blue; }"),
5874                true,
5875            ))
5876            .expect("register");
5877
5878        parser
5879            .parse("test.html", "<my-component></my-component>")
5880            .expect("parse");
5881        let records = parser.into_fragment_records();
5882        assert_stream!(
5883            records,
5884            "my-component",
5885            [
5886                raw("<template shadowrootmode=\"open\">"),
5887                structural_matcher("shadow_styles:my-component"),
5888                raw("<div>My Component</div></template>"),
5889            ]
5890        );
5891        assert!(
5892            records["my-component"]
5893                .fragments
5894                .iter()
5895                .all(|fragment| !matches!(
5896                    fragment.fragment.as_ref(),
5897                    Some(Fragment::Raw(raw)) if raw.value.contains("color: blue")
5898                )),
5899            "component CSS is delivered by the handler closure"
5900        );
5901    }
5902
5903    #[test]
5904    fn authored_shadow_attribute_name_is_ascii_case_insensitive_for_style_hook() {
5905        let mut parser = HtmlParser::new();
5906        parser
5907            .component_registry
5908            .register_component(ComponentRegistration::new(
5909                "my-component",
5910                r#"<template shadowRootMode="open"><slot></slot></template>"#,
5911                None,
5912                true,
5913            ))
5914            .expect("register");
5915
5916        parser
5917            .parse("test.html", "<my-component></my-component>")
5918            .expect("parse");
5919        let records = parser.into_fragment_records();
5920        assert_stream!(
5921            records,
5922            "my-component",
5923            [
5924                raw(r#"<template shadowRootMode="open">"#),
5925                structural_matcher("shadow_styles:my-component"),
5926                raw("<slot></slot></template>"),
5927            ]
5928        );
5929    }
5930
5931    #[test]
5932    fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
5933        // The compiler-owned streamed SSR root signal must sit between the
5934        // component's attribute run and the closing `>`, carrying the tag,
5935        // so the streaming handler can inject ` data-ws` inside the tag before
5936        // custom-element upgrade. Ordinary rendering ignores the signal.
5937        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5938        parser
5939            .component_registry
5940            .register_component(ComponentRegistration::new(
5941                "my-widget",
5942                "<div>w</div>",
5943                None,
5944                true,
5945            ))
5946            .expect("register");
5947
5948        let result = parser.parse("test.html", "<my-widget></my-widget>");
5949        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5950        let records = parser.into_fragment_records();
5951
5952        assert_stream!(
5953            records,
5954            "test.html",
5955            [
5956                raw("<my-widget"),
5957                structural_matcher("streaming_root:my-widget"),
5958                raw(">"),
5959                component("my-widget"),
5960                raw("</my-widget>"),
5961            ]
5962        );
5963    }
5964
5965    #[test]
5966    fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
5967        // Each distinct component host emits its own streaming_root signal,
5968        // including nested hosts and hosts repeated across sibling positions.
5969        use webui_protocol::web_ui_fragment::Fragment;
5970        let mut parser = HtmlParser::new();
5971        for name in ["outer-box", "inner-pill"] {
5972            parser
5973                .component_registry
5974                .register_component(ComponentRegistration::new(
5975                    name,
5976                    "<div>content</div>",
5977                    None,
5978                    true,
5979                ))
5980                .expect("register");
5981        }
5982
5983        let result = parser.parse(
5984            "test.html",
5985            "<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
5986        );
5987        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5988        let records = parser.into_fragment_records();
5989        let fragments = &records["test.html"].fragments;
5990
5991        let signals: Vec<&str> = fragments
5992            .iter()
5993            .filter_map(|f| match f.fragment.as_ref() {
5994                Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
5995                _ => None,
5996            })
5997            .collect();
5998        assert_eq!(
5999            signals,
6000            [
6001                "}}}webui:streaming_root:outer-box",
6002                "}}}webui:streaming_root:inner-pill",
6003                "}}}webui:streaming_root:inner-pill",
6004            ]
6005        );
6006
6007        // Every streaming_root signal is immediately followed by a `>` raw and
6008        // preceded by a raw ending in the host tag name (never a bare `<tag>`).
6009        for (i, frag) in fragments.iter().enumerate() {
6010            let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
6011                continue;
6012            };
6013            if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
6014                continue;
6015            }
6016            match fragments[i + 1].fragment.as_ref() {
6017                Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
6018                other => panic!("expected raw(\">\") after signal, got {other:?}"),
6019            }
6020        }
6021    }
6022
6023    #[test]
6024    fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
6025        let mut parser = HtmlParser::new();
6026        parser
6027            .component_registry
6028            .register_component(ComponentRegistration::new(
6029                "mp-button",
6030                "<button>b</button>",
6031                None,
6032                true,
6033            ))
6034            .expect("Failed to register component");
6035
6036        // `<mp-buton>` is a same-namespace one-character typo of `mp-button`.
6037        let err = parser
6038            .parse("test.html", "<mp-buton></mp-buton>")
6039            .expect_err("a near-typo component tag should error");
6040        let ParserError::Template(diag) = err else {
6041            panic!("expected ParserError::Template, got {err:?}");
6042        };
6043        assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
6044        let help = diag
6045            .help_text()
6046            .expect("diagnostic should carry a help line");
6047        assert!(
6048            help.contains("did you mean <mp-button>?"),
6049            "expected a suggestion, got: {help}"
6050        );
6051    }
6052
6053    #[test]
6054    fn external_custom_element_in_other_namespace_passes_through() {
6055        let mut parser = HtmlParser::new();
6056        parser
6057            .component_registry
6058            .register_component(ComponentRegistration::new(
6059                "mp-button",
6060                "<button>b</button>",
6061                None,
6062                true,
6063            ))
6064            .expect("Failed to register component");
6065
6066        // `<md-button>` is a different namespace (md- vs mp-): a genuine
6067        // third-party custom element must pass through as raw HTML, not error.
6068        let result = parser.parse("test.html", "<md-button></md-button>");
6069        assert!(
6070            result.is_ok(),
6071            "external custom element should pass through, got: {:?}",
6072            result.err()
6073        );
6074    }
6075
6076    #[test]
6077    fn for_missing_each_suggests_typoed_attribute() {
6078        let mut parser = HtmlParser::new();
6079        // `eahc` is a transposition of the required `each` attribute.
6080        let err = parser
6081            .parse("test.html", "<for eahc=\"todo in todos\"></for>")
6082            .expect_err("a <for> without `each` should error");
6083        let ParserError::Template(diag) = err else {
6084            panic!("expected ParserError::Template, got {err:?}");
6085        };
6086        assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
6087        let help = diag
6088            .help_text()
6089            .expect("diagnostic should carry a help line");
6090        assert!(
6091            help.contains("found `eahc`") && help.contains("did you mean"),
6092            "expected an attribute-typo suggestion, got: {help}"
6093        );
6094    }
6095
6096    #[test]
6097    fn parent_diagnostic_owner_survives_nested_component_parse() {
6098        // Regression: a component parse recurses through `self.parse(child, …)`,
6099        // which used to leave `current_fragment_id` pointing at the child. A
6100        // later error in the PARENT template must still be attributed to the
6101        // parent (here `index.html`), not the nested component.
6102        let mut parser = HtmlParser::new();
6103        parser
6104            .component_registry
6105            .register_component(ComponentRegistration::new(
6106                "my-card",
6107                "<div>card</div>",
6108                None,
6109                true,
6110            ))
6111            .expect("register");
6112
6113        // A registered component first, then a broken <for> in the same template.
6114        let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
6115        let err = parser
6116            .parse("index.html", html)
6117            .expect_err("the malformed <for> should error");
6118        let ParserError::Template(diag) = err else {
6119            panic!("expected ParserError::Template, got {err:?}");
6120        };
6121        assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
6122        assert_eq!(
6123            diag.component_name(),
6124            Some("index.html"),
6125            "parent diagnostic must be owned by index.html, not the nested component"
6126        );
6127    }
6128
6129    #[test]
6130    fn test_component_directive_with_slots() {
6131        let mut parser = HtmlParser::new();
6132        parser
6133            .component_registry
6134            .register_component(ComponentRegistration::new(
6135                "my-component",
6136                "<div>My Component</div>",
6137                Some("div { color: blue; }"),
6138                true,
6139            ))
6140            .expect("Failed to register component");
6141
6142        let result = parser.parse(
6143            "test.html",
6144            "Hello<my-component><p>World</p></my-component>",
6145        );
6146        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6147        let records = parser.into_fragment_records();
6148        let fragments = &records["test.html"].fragments;
6149
6150        // Entry: raw(Hello<my-component>) + component + raw(<p>World</p></my-component>)
6151        assert!(fragments.len() >= 3);
6152        // First fragment should contain "Hello" and "<my-component" (the opening
6153        // tag is split by the compiler-owned streaming_root signal before `>`).
6154        assert!(
6155            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
6156        );
6157        // Should have component fragment
6158        assert!(fragments.iter().any(|f| matches!(
6159            f.fragment.as_ref(),
6160            Some(Fragment::Component(c)) if c.fragment_id == "my-component"
6161        )));
6162        // Should end with closing tag
6163        let last = fragments.last().unwrap();
6164        assert!(
6165            matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
6166        );
6167    }
6168
6169    #[test]
6170    fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
6171        // Reproduces commerce app: mp-app has a shadow DOM template containing
6172        // child components (mp-navbar, mp-cart-panel, mp-footer) plus an <outlet>.
6173        // The parser must emit Fragment::Component entries for ALL child components
6174        // so the inventory walk finds them.
6175        let mut parser = HtmlParser::new();
6176
6177        parser
6178            .component_registry
6179            .register_component(ComponentRegistration::new(
6180                "app-shell",
6181                r#"<template shadowrootmode="open">
6182                  <my-navbar></my-navbar>
6183                  <main><outlet /></main>
6184                  <cart-panel></cart-panel>
6185                  <my-footer></my-footer>
6186                </template>"#,
6187                Some(":host{display:flex}"),
6188                true,
6189            ))
6190            .expect("register app-shell");
6191        parser
6192            .component_registry
6193            .register_component(ComponentRegistration::new(
6194                "my-navbar",
6195                "<nav>Nav</nav>",
6196                Some("nav{color:red}"),
6197                true,
6198            ))
6199            .expect("register my-navbar");
6200        parser
6201            .component_registry
6202            .register_component(ComponentRegistration::new(
6203                "cart-panel",
6204                "<aside>Cart</aside>",
6205                Some("aside{color:green}"),
6206                true,
6207            ))
6208            .expect("register cart-panel");
6209        parser
6210            .component_registry
6211            .register_component(ComponentRegistration::new(
6212                "my-footer",
6213                "<footer>Footer</footer>",
6214                Some("footer{color:blue}"),
6215                true,
6216            ))
6217            .expect("register my-footer");
6218
6219        parser
6220            .parse("index.html", "<app-shell></app-shell>")
6221            .expect("parse index.html");
6222        let records = parser.into_fragment_records();
6223
6224        // app-shell's fragment list must contain Component entries for all children
6225        let app_frags = &records["app-shell"].fragments;
6226        let component_ids: Vec<&str> = app_frags
6227            .iter()
6228            .filter_map(|f| match f.fragment.as_ref() {
6229                Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
6230                _ => None,
6231            })
6232            .collect();
6233
6234        assert!(
6235            component_ids.contains(&"my-navbar"),
6236            "app-shell fragments should contain my-navbar: {component_ids:?}"
6237        );
6238        assert!(
6239            component_ids.contains(&"cart-panel"),
6240            "app-shell fragments should contain cart-panel: {component_ids:?}"
6241        );
6242        assert!(
6243            component_ids.contains(&"my-footer"),
6244            "app-shell fragments should contain my-footer: {component_ids:?}"
6245        );
6246    }
6247
6248    // ── Component template wrapping tests ────────────────────────────
6249
6250    #[test]
6251    fn test_component_no_double_wrap_template() {
6252        // Developer-authored <template foo="bar"> must be preserved verbatim
6253        // in Light DOM. The framework only strips runtime-only attrs;
6254        // every other attribute is the developer's responsibility.
6255        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6256        parser
6257            .component_registry
6258            .register_component(ComponentRegistration::new(
6259                "custom-element",
6260                r#"<template foo="bar"><div>content</div></template>"#,
6261                None,
6262                true,
6263            ))
6264            .expect("register");
6265        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6266        assert!(result.is_ok());
6267        let records = parser.into_fragment_records();
6268
6269        assert_fragments!(
6270            records["index.html"].fragments,
6271            [
6272                raw("<custom-element"),
6273                structural_matcher("streaming_root:custom-element"),
6274                raw(">"),
6275                component("custom-element"),
6276                raw("Hello</custom-element>"),
6277            ]
6278        );
6279
6280        assert_stream!(
6281            records,
6282            "custom-element",
6283            [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6284        );
6285    }
6286
6287    #[test]
6288    fn test_component_styled_no_double_wrap() {
6289        // Light DOM with a developer-supplied ordinary <template> wrapper
6290        // preserves the wrapper verbatim. CSS is delivered by the owning
6291        // closure rather than spliced into the template.
6292        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6293        parser
6294            .component_registry
6295            .register_component(ComponentRegistration::new(
6296                "custom-element",
6297                r#"<template foo="bar"><div>content</div></template>"#,
6298                Some("div { color: red; }"),
6299                true,
6300            ))
6301            .expect("register");
6302        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6303        assert!(result.is_ok());
6304        let records = parser.into_fragment_records();
6305
6306        assert_stream!(
6307            records,
6308            "custom-element",
6309            [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6310        );
6311    }
6312
6313    #[test]
6314    fn test_component_strip_runtime_attrs() {
6315        // Runtime-only attributes (`@event`, `:bind`, `?cond`) are stripped
6316        // from the opening <template> tag, but the wrapper itself is
6317        // preserved. The declarative Shadow DOM attribute remains.
6318        let mut parser = HtmlParser::new();
6319        parser
6320            .component_registry
6321            .register_component(ComponentRegistration::new(
6322                "custom-element",
6323                r#"<template shadowrootmode="open" @click={foo} :bar="baz" ?bool="true"><div>content</div></template>"#,
6324                None,
6325                true,
6326            ))
6327            .expect("register");
6328        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6329        assert!(result.is_ok());
6330        let records = parser.into_fragment_records();
6331
6332        assert_stream!(
6333            records,
6334            "custom-element",
6335            [
6336                raw(r#"<template shadowrootmode="open">"#),
6337                structural_matcher("shadow_styles:custom-element"),
6338                raw("<div>content</div></template>"),
6339            ]
6340        );
6341    }
6342
6343    #[test]
6344    fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
6345        let mut parser = HtmlParser::new();
6346        parser
6347            .component_registry
6348            .register_component(ComponentRegistration::new(
6349                "custom-element",
6350                r#"<template shadowrootmode="open" data-note="@click={foo}" @click={foo}><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" data-note="@click={foo}">"#),
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_handles_duplicate_runtime_attrs() {
6372        let mut parser = HtmlParser::new();
6373        parser
6374            .component_registry
6375            .register_component(ComponentRegistration::new(
6376                "custom-element",
6377                r#"<template shadowrootmode="open" @click={foo} @click={bar}><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">"#),
6391                structural_matcher("shadow_styles:custom-element"),
6392                raw("<div>content</div></template>"),
6393            ]
6394        );
6395    }
6396
6397    #[test]
6398    fn test_component_with_slots_and_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"><slot></slot></template>"#,
6405                None,
6406                true,
6407            ))
6408            .expect("register");
6409        let result = parser.parse(
6410            "index.html",
6411            r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
6412        );
6413        assert!(result.is_ok());
6414        let records = parser.into_fragment_records();
6415        assert_fragments!(
6416            records["index.html"].fragments,
6417            [
6418                raw("<custom-element"),
6419                attr_raw_start("appearance", "subtle"),
6420                structural_matcher("streaming_root:custom-element"),
6421                raw(">"),
6422                component("custom-element"),
6423                raw("Hello World</custom-element>"),
6424            ]
6425        );
6426    }
6427
6428    #[test]
6429    fn test_component_legacy_no_styles() {
6430        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6431        parser
6432            .component_registry
6433            .register_component(ComponentRegistration::new(
6434                "custom-element",
6435                "<div>Custom Element</div>",
6436                None,
6437                true,
6438            ))
6439            .expect("register");
6440        let result = parser.parse("index.html", "<custom-element></custom-element>");
6441        assert!(result.is_ok());
6442        let records = parser.into_fragment_records();
6443
6444        assert_fragments!(
6445            records["index.html"].fragments,
6446            [
6447                raw("<custom-element"),
6448                structural_matcher("streaming_root:custom-element"),
6449                raw(">"),
6450                component("custom-element"),
6451                raw("</custom-element>"),
6452            ]
6453        );
6454
6455        assert_stream!(
6456            records,
6457            "custom-element",
6458            [raw("<div>Custom Element</div>"),]
6459        );
6460    }
6461
6462    #[test]
6463    fn test_component_self_closing() {
6464        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6465        parser
6466            .component_registry
6467            .register_component(ComponentRegistration::new(
6468                "custom-widget",
6469                "<div>Widget Content</div>",
6470                None,
6471                true,
6472            ))
6473            .expect("register");
6474        let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
6475        assert!(result.is_ok());
6476        let records = parser.into_fragment_records();
6477
6478        assert_fragments!(
6479            records["index.html"].fragments,
6480            [
6481                raw("<custom-widget"),
6482                attr_start("config", "settings"),
6483                structural_matcher("streaming_root:custom-widget"),
6484                raw("/>"),
6485                component("custom-widget"),
6486            ]
6487        );
6488    }
6489
6490    #[test]
6491    fn test_component_nested_self_closing_in_slot() {
6492        let mut parser = HtmlParser::new();
6493        parser
6494            .component_registry
6495            .register_component(ComponentRegistration::new(
6496                "custom-icon",
6497                "<template shadowrootmode=\"open\"><svg><slot></slot></svg></template>",
6498                None,
6499                true,
6500            ))
6501            .expect("register");
6502        let result = parser.parse(
6503            "index.html",
6504            r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
6505        );
6506        assert!(result.is_ok());
6507        let records = parser.into_fragment_records();
6508
6509        assert_fragments!(
6510            records["index.html"].fragments,
6511            [
6512                raw("<custom-icon"),
6513                structural_matcher("streaming_root:custom-icon"),
6514                raw(">"),
6515                component("custom-icon"),
6516                raw("<use"),
6517                attr_template("href", "attr-1"),
6518                raw("/></custom-icon>"),
6519            ]
6520        );
6521
6522        assert_stream!(
6523            records,
6524            "custom-icon",
6525            [
6526                raw("<template shadowrootmode=\"open\">"),
6527                structural_matcher("shadow_styles:custom-icon"),
6528                raw("<svg><slot></slot></svg></template>"),
6529            ]
6530        );
6531    }
6532
6533    #[test]
6534    fn test_component_leading_boolean_attr_start() {
6535        let mut parser = HtmlParser::new();
6536        parser
6537            .component_registry
6538            .register_component(ComponentRegistration::new(
6539                "custom-element",
6540                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6541                None,
6542                true,
6543            ))
6544            .expect("register");
6545        let result = parser.parse(
6546            "index.html",
6547            r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
6548        );
6549        assert!(result.is_ok());
6550        let records = parser.into_fragment_records();
6551
6552        assert_fragments!(
6553            records["index.html"].fragments,
6554            [
6555                raw("<custom-element"),
6556                // First dynamic attr: boolean with attrStart
6557                bool_attr_start("disabled", "isDisabled"),
6558                // Static attr after dynamic: rawValue
6559                attr_raw("title", "Hello"),
6560                structural_matcher("streaming_root:custom-element"),
6561                raw(">"),
6562                component("custom-element"),
6563                raw("</custom-element>"),
6564            ]
6565        );
6566    }
6567
6568    #[test]
6569    fn test_component_boolean_predicate_preserves_condition_tree() {
6570        let mut parser = HtmlParser::new();
6571        parser
6572            .component_registry
6573            .register_component(ComponentRegistration::new(
6574                "custom-element",
6575                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6576                None,
6577                true,
6578            ))
6579            .expect("register");
6580        let result = parser.parse(
6581            "index.html",
6582            r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
6583        );
6584        assert!(result.is_ok());
6585
6586        let records = parser.into_fragment_records();
6587        let fragments = &records["index.html"].fragments;
6588        match fragments
6589            .get(1)
6590            .and_then(|fragment| fragment.fragment.as_ref())
6591        {
6592            Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
6593                assert_eq!(attr.name, "disabled");
6594                assert!(attr.attr_start);
6595                match attr
6596                    .condition_tree
6597                    .as_ref()
6598                    .and_then(|condition| condition.expr.as_ref())
6599                {
6600                    Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
6601                        assert_eq!(pred.left, "page");
6602                        assert_eq!(pred.operator, 3);
6603                        assert_eq!(pred.right, "'dashboard'");
6604                    }
6605                    other => panic!("expected predicate condition tree, got {:?}", other),
6606                }
6607            }
6608            other => panic!("expected attribute fragment, got {:?}", other),
6609        }
6610    }
6611
6612    #[test]
6613    fn test_component_meta_link_tags() {
6614        let (fragments, _) = parse_and_get_fragments(
6615            r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
6616        );
6617        assert!(fragments.len() >= 7);
6618        assert!(
6619            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
6620        );
6621        assert!(
6622            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
6623                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
6624        );
6625        assert!(
6626            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
6627        );
6628        assert!(
6629            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
6630        );
6631        assert!(
6632            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
6633        );
6634        assert!(
6635            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
6636                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
6637        );
6638        assert!(
6639            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
6640        );
6641    }
6642
6643    #[test]
6644    fn test_nested_directives() {
6645        let mut parser = HtmlParser::new();
6646        let html = r#"<for each="category in categories">
6647            <if condition="category.hasItems">
6648                <for each="item in category.items">
6649                   {{item.title}}
6650                </for>
6651            </if>
6652        </for>"#;
6653
6654        let result = parser.parse("test.html", html);
6655
6656        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6657        let fragment_records = parser.into_fragment_records();
6658
6659        assert_stream!(
6660            fragment_records,
6661            "test.html",
6662            [for_loop("category", "categories", "for-1"),]
6663        );
6664
6665        assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
6666
6667        assert_stream!(
6668            fragment_records,
6669            "if-1",
6670            [for_loop("item", "category.items", "for-2"),]
6671        );
6672
6673        assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
6674    }
6675
6676    #[test]
6677    fn test_complex_directives() {
6678        let mut parser = HtmlParser::new();
6679        let html = r#"<for each="category in categories">
6680            <div class="category">
6681                <h2>{{category.name}}</h2>
6682                <if condition="category.hasItems">
6683                    <ul>
6684                        <for each="item in category.items">
6685                            <li>{{item.title}}</li>
6686                        </for>
6687                    </ul>
6688                </if>
6689            </div>
6690        </for>"#;
6691
6692        let result = parser.parse("test.html", html);
6693
6694        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6695        let fragment_records = parser.into_fragment_records();
6696
6697        assert_stream!(
6698            fragment_records,
6699            "test.html",
6700            [for_loop("category", "categories", "for-1"),]
6701        );
6702
6703        // Verify for fragments contains the category.name signal
6704        assert_stream!(
6705            fragment_records,
6706            "for-1",
6707            [
6708                raw("<div class=\"category\"><h2>"),
6709                signal("category.name"),
6710                raw("</h2>"),
6711                if_cond("if-1"),
6712                raw("</div>"),
6713            ]
6714        );
6715
6716        // Verify nested if condition.
6717        assert_stream!(
6718            fragment_records,
6719            "if-1",
6720            [
6721                raw("<ul>"),
6722                for_loop("item", "category.items", "for-2"),
6723                raw("</ul>"),
6724            ]
6725        );
6726
6727        // Verify nested for each.
6728        assert_stream!(
6729            fragment_records,
6730            "for-2",
6731            [raw("<li>"), signal("item.title"), raw("</li>"),]
6732        );
6733    }
6734
6735    // ── Attribute fragment tests ─────────────────────────────────────────
6736
6737    /// Helper to parse HTML and return the fragments for the entry stream.
6738    fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6739        let mut parser = HtmlParser::new();
6740        let result = parser.parse("index.html", html);
6741        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6742        let records = parser.into_fragment_records();
6743        let fragments = records
6744            .get("index.html")
6745            .expect("Failed to get index.html fragment")
6746            .fragments
6747            .clone();
6748        (fragments, records)
6749    }
6750
6751    /// Helper to parse HTML with a pre-registered component.
6752    fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6753        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6754        parser
6755            .component_registry
6756            .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
6757            .expect("register");
6758        let result = parser.parse("index.html", html);
6759        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6760        let records = parser.into_fragment_records();
6761        let fragments = records
6762            .get("index.html")
6763            .expect("Failed to get index.html fragment")
6764            .fragments
6765            .clone();
6766        (fragments, records)
6767    }
6768
6769    #[test]
6770    fn test_attribute_handlebars_in_href() {
6771        // Port of: 'should process handlebars from attributes as signals'
6772        let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
6773        assert_fragments!(
6774            fragments,
6775            [
6776                raw("<a"),
6777                attr("href", "url"),
6778                raw(">"),
6779                signal("name"),
6780                raw("</a>"),
6781            ]
6782        );
6783    }
6784
6785    #[test]
6786    fn test_attribute_boolean_with_handlebars() {
6787        // Port of: 'should process boolean attribute with handlebars expression'
6788        let (fragments, _) =
6789            parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
6790        assert_fragments!(
6791            fragments,
6792            [
6793                raw("<button"),
6794                bool_attr("disabled", "isDisabled"),
6795                raw(">Click</button>"),
6796            ]
6797        );
6798    }
6799
6800    #[test]
6801    fn test_attribute_multiple_boolean() {
6802        // Port of: 'should process multiple boolean attributes'
6803        // <input ?checked={{isChecked}} ?disabled={{isDisabled}} />
6804        let (fragments, _) =
6805            parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
6806
6807        assert_fragments!(
6808            fragments,
6809            [
6810                raw("<input"),
6811                bool_attr("checked", "isChecked"),
6812                bool_attr("disabled", "isDisabled"),
6813                raw("/>"),
6814            ]
6815        );
6816    }
6817
6818    #[test]
6819    fn test_attribute_boolean_and_regular_together() {
6820        // Port of: 'should process a boolean attribute and a regular attribute together'
6821        // <button ?disabled="{{isDisabled}}" type="button">Hi</button>
6822        let (fragments, _) = parse_and_get_fragments(
6823            r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6824        );
6825
6826        assert_fragments!(
6827            fragments,
6828            [
6829                raw("<button"),
6830                bool_attr("disabled", "isDisabled"),
6831                raw(" type=\"button\">Hi</button>"),
6832            ]
6833        );
6834    }
6835
6836    #[test]
6837    fn test_attribute_boolean_sandwiched() {
6838        // Port of: 'should process a boolean attribute sandwiched between regular attributes'
6839        // <button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>
6840        let (fragments, _) = parse_and_get_fragments(
6841            r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6842        );
6843
6844        assert_fragments!(
6845            fragments,
6846            [
6847                raw("<button"),
6848                attr("version", "edition"),
6849                bool_attr("disabled", "isDisabled"),
6850                raw(" type=\"button\">Hi</button>"),
6851            ]
6852        );
6853    }
6854
6855    #[test]
6856    fn test_attribute_boolean_ending() {
6857        // Port of: 'should process html ending with boolean attribute correctly'
6858        // <button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>
6859        let (fragments, _) = parse_and_get_fragments(
6860            r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
6861        );
6862
6863        assert_fragments!(
6864            fragments,
6865            [
6866                raw("<button"),
6867                attr("version", "edition"),
6868                bool_attr("disabled", "isDisabled"),
6869                raw(">Hi</button>"),
6870            ]
6871        );
6872    }
6873
6874    #[test]
6875    fn test_attribute_boolean_dotted_path() {
6876        // Port of: 'should process boolean attribute with dotted path'
6877        // <div ?checked={{layout.isPinned}}>Content</div>
6878        let (fragments, _) =
6879            parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
6880
6881        assert_fragments!(
6882            fragments,
6883            [
6884                raw("<div"),
6885                bool_attr("checked", "layout.isPinned"),
6886                raw(">Content</div>"),
6887            ]
6888        );
6889    }
6890
6891    #[test]
6892    fn test_attribute_colon_prefixed_complex() {
6893        // Port of: 'should process colon-prefixed attribute with handlebars'
6894        // <my-component :config="{{settings}}"></my-component>
6895        let (fragments, _) = parse_with_component(
6896            "my-component",
6897            r#"<my-component :config="{{settings}}"></my-component>"#,
6898        );
6899
6900        assert_fragments!(
6901            fragments,
6902            [
6903                raw("<my-component"),
6904                attr_complex_start(":config", "settings"),
6905                structural_matcher("streaming_root:my-component"),
6906                raw(">"),
6907                component("my-component"),
6908                raw("</my-component>"),
6909            ]
6910        );
6911    }
6912
6913    #[test]
6914    fn test_attribute_multiple_colon_prefixed() {
6915        // Port of: 'should process multiple colon-prefixed complex attributes'
6916        // <my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>
6917        let (fragments, _) = parse_with_component(
6918            "my-component",
6919            r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
6920        );
6921
6922        assert_fragments!(
6923            fragments,
6924            [
6925                raw("<my-component"),
6926                attr_complex_start(":prop1", "val1"),
6927                attr_complex(":prop2", "val2"),
6928                structural_matcher("streaming_root:my-component"),
6929                raw(">"),
6930                component("my-component"),
6931                raw("</my-component>"),
6932            ]
6933        );
6934    }
6935
6936    #[test]
6937    fn test_blocked_complex_property_innerhtml() {
6938        let mut parser = HtmlParser::new();
6939        let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
6940        assert!(
6941            result.is_err(),
6942            "Expected error for :innerHTML on native element"
6943        );
6944        let err = result.unwrap_err().to_string();
6945        assert!(
6946            err.contains("only allowed on custom elements"),
6947            "Error: {err}"
6948        );
6949    }
6950
6951    #[test]
6952    fn test_blocked_complex_on_native_element() {
6953        let mut parser = HtmlParser::new();
6954        let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
6955        assert!(
6956            result.is_err(),
6957            "Expected error for :data on native element"
6958        );
6959        let err = result.unwrap_err().to_string();
6960        assert!(
6961            err.contains("only allowed on custom elements"),
6962            "Error: {err}"
6963        );
6964    }
6965
6966    #[test]
6967    fn test_blocked_complex_property_on_component() {
6968        let mut parser = HtmlParser::new();
6969        parser
6970            .component_registry
6971            .register_component(ComponentRegistration::new(
6972                "my-widget",
6973                "<div></div>",
6974                None,
6975                true,
6976            ))
6977            .expect("register");
6978        let result = parser.parse(
6979            "index.html",
6980            r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
6981        );
6982        assert!(
6983            result.is_err(),
6984            "Expected error for :innerHTML on component"
6985        );
6986        let err = result.unwrap_err().to_string();
6987        assert!(err.contains("HTML injection"), "Error: {err}");
6988    }
6989
6990    #[test]
6991    fn test_allowed_complex_property() {
6992        // :config on a component should still work
6993        let (fragments, _) = parse_with_component(
6994            "my-component",
6995            r#"<my-component :config="{{settings}}"></my-component>"#,
6996        );
6997        assert_fragments!(
6998            fragments,
6999            [
7000                raw("<my-component"),
7001                attr_complex_start(":config", "settings"),
7002                structural_matcher("streaming_root:my-component"),
7003                raw(">"),
7004                component("my-component"),
7005                raw("</my-component>"),
7006            ]
7007        );
7008    }
7009
7010    #[test]
7011    fn test_attribute_mixed_normal_boolean_colon() {
7012        // Port of: 'should process mixed normal, boolean, and colon-prefixed attributes'
7013        // <my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>
7014        let (fragments, _) = parse_with_component(
7015            "my-component",
7016            r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
7017        );
7018
7019        assert_fragments!(
7020            fragments,
7021            [
7022                raw("<my-component"),
7023                attr_raw_start("id", "comp"),
7024                attr_complex(":config", "settings"),
7025                bool_attr("enabled", "isEnabled"),
7026                structural_matcher("streaming_root:my-component"),
7027                raw(">"),
7028                component("my-component"),
7029                raw("</my-component>"),
7030            ]
7031        );
7032    }
7033
7034    #[test]
7035    fn test_attribute_reject_boolean_without_handlebars() {
7036        // Port of: 'should reject boolean attribute without handlebars'
7037        // <button ?checked="name"></button>
7038        let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
7039
7040        // Boolean attribute is silently dropped
7041        assert_fragments!(fragments, [raw("<button></button>"),]);
7042    }
7043
7044    #[test]
7045    fn test_attribute_reject_boolean_with_partial_handlebars() {
7046        // Port of: 'should reject boolean attribute with partial handlebars'
7047        // <button ?checked="Hello {{name}}"></button>
7048        let (fragments, _) =
7049            parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
7050
7051        // Boolean attribute is silently dropped
7052        assert_fragments!(fragments, [raw("<button></button>"),]);
7053    }
7054
7055    #[test]
7056    fn test_attribute_reject_boolean_with_plain_value() {
7057        // Port of: 'should reject boolean attribute with plain value'
7058        // <button ?disabled="true">Click</button>
7059        let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
7060
7061        // Boolean attribute is silently dropped
7062        assert_fragments!(fragments, [raw("<button>Click</button>"),]);
7063    }
7064
7065    #[test]
7066    fn test_attribute_boolean_predicate_equal() {
7067        // Boolean attribute with == predicate expression
7068        let (fragments, _) =
7069            parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
7070        assert_fragments!(
7071            fragments,
7072            [
7073                raw("<div"),
7074                bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
7075                raw(">X</div>"),
7076            ]
7077        );
7078    }
7079
7080    #[test]
7081    fn test_attribute_boolean_predicate_greater_than() {
7082        // Boolean attribute with > predicate expression
7083        let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
7084        assert_fragments!(
7085            fragments,
7086            [
7087                raw("<span"),
7088                bool_attr_predicate("hidden", "num", 1, "9"),
7089                raw(">X</span>"),
7090            ]
7091        );
7092    }
7093
7094    #[test]
7095    fn test_attribute_boolean_predicate_not_equal() {
7096        // Boolean attribute with != predicate expression
7097        let (fragments, _) =
7098            parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
7099        assert_fragments!(
7100            fragments,
7101            [
7102                raw("<a"),
7103                bool_attr_predicate("data-active", "status", 4, "'inactive'"),
7104                raw(">X</a>"),
7105            ]
7106        );
7107    }
7108
7109    #[test]
7110    fn test_attribute_boolean_negation() {
7111        // Boolean attribute with negated expression
7112        let (fragments, _) =
7113            parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
7114        assert_fragments!(
7115            fragments,
7116            [
7117                raw("<button"),
7118                bool_attr_not("disabled", "isReady"),
7119                raw(">X</button>"),
7120            ]
7121        );
7122    }
7123
7124    #[test]
7125    fn test_attribute_mixed_static_dynamic() {
7126        // Port of: 'should process mixed attributes correctly'
7127        // <textarea value="hello {{world}}">Hi</textarea>
7128        let (fragments, records) =
7129            parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
7130
7131        assert_fragments!(
7132            fragments,
7133            [
7134                raw("<textarea"),
7135                attr_template("value", "attr-1"),
7136                raw(">Hi</textarea>"),
7137            ]
7138        );
7139
7140        // Verify the template sub-stream
7141        assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
7142    }
7143
7144    // ── Body signal tests ─────────────────────────────────────────────
7145
7146    #[test]
7147    fn test_body_signals() {
7148        let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
7149        assert_fragments!(
7150            fragments,
7151            [
7152                raw("<body>"),
7153                structural_matcher("body_start"),
7154                raw("<app-shell></app-shell>"),
7155                structural_matcher("body_end"),
7156                raw("</body>"),
7157            ]
7158        );
7159    }
7160
7161    // ── Empty for handling tests ──────────────────────────────────────
7162
7163    #[test]
7164    fn test_empty_for_produces_nothing() {
7165        let (fragments, records) =
7166            parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
7167        assert_fragments!(fragments, [raw("<div></div>"),]);
7168        assert!(!records.contains_key("for-1"));
7169    }
7170
7171    // ── Self-closing / void element tests ─────────────────────────────
7172
7173    #[test]
7174    fn test_self_closing_svg_path() {
7175        let (fragments, _) =
7176            parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
7177        assert_fragments!(
7178            fragments,
7179            [raw(
7180                r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
7181            ),]
7182        );
7183    }
7184
7185    #[test]
7186    fn test_html5_void_elements() {
7187        let (fragments, _) = parse_and_get_fragments(
7188            r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
7189        );
7190        assert_fragments!(
7191            fragments,
7192            [raw(
7193                r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
7194            ),]
7195        );
7196    }
7197
7198    #[test]
7199    fn test_self_closing_with_dynamic_attributes() {
7200        let (fragments, _) =
7201            parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
7202        assert_fragments!(
7203            fragments,
7204            [
7205                raw("<img"),
7206                attr("src", "imageUrl"),
7207                attr("alt", "imageAlt"),
7208                raw("/>"),
7209            ]
7210        );
7211    }
7212
7213    #[test]
7214    fn test_self_closing_with_boolean_attributes() {
7215        let (fragments, _) = parse_and_get_fragments(
7216            r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
7217        );
7218        assert_fragments!(
7219            fragments,
7220            [
7221                raw("<input type=\"checkbox\""),
7222                bool_attr("checked", "isSelected"),
7223                bool_attr("disabled", "isDisabled"),
7224                raw("/>"),
7225            ]
7226        );
7227    }
7228
7229    #[test]
7230    fn test_multiple_self_closing_in_sequence() {
7231        let (fragments, _) =
7232            parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
7233        assert_fragments!(
7234            fragments,
7235            [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
7236        );
7237    }
7238
7239    #[test]
7240    fn test_self_closing_with_mixed_content() {
7241        let (fragments, _) =
7242            parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
7243        assert_fragments!(
7244            fragments,
7245            [
7246                raw("<div>Text before<img"),
7247                attr("src", "url"),
7248                raw("/>Text after</div>"),
7249            ]
7250        );
7251    }
7252
7253    #[test]
7254    fn test_self_closing_svg_elements() {
7255        let (fragments, _) = parse_and_get_fragments(
7256            r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
7257        );
7258        assert_fragments!(
7259            fragments,
7260            [
7261                raw("<svg><circle"),
7262                attr("cx", "x"),
7263                attr("cy", "y"),
7264                raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
7265            ]
7266        );
7267    }
7268
7269    #[test]
7270    fn test_self_closing_inside_for_loop() {
7271        let (fragments, records) = parse_and_get_fragments(
7272            r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
7273        );
7274        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7275        assert_stream!(
7276            records,
7277            "for-1",
7278            [raw("<img"), attr("src", "item.url"), raw("/>"),]
7279        );
7280    }
7281
7282    #[test]
7283    fn test_self_closing_whitespace_variations() {
7284        let (fragments, _) =
7285            parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
7286        assert_fragments!(
7287            fragments,
7288            [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
7289        );
7290    }
7291
7292    #[test]
7293    fn test_deeply_nested_self_closing() {
7294        let (fragments, _) = parse_and_get_fragments(
7295            r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
7296        );
7297        assert_fragments!(
7298            fragments,
7299            [raw(
7300                r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
7301            ),]
7302        );
7303    }
7304
7305    #[test]
7306    fn test_self_closing_vs_empty_regular_tags() {
7307        let (fragments, _) =
7308            parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
7309        assert_fragments!(
7310            fragments,
7311            [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
7312        );
7313    }
7314
7315    // ── Feature 1: Named <for> bodies ─────────────────────────────────
7316
7317    #[test]
7318    fn test_for_custom_id_attribute() {
7319        let (fragments, records) = parse_and_get_fragments(
7320            r#"<for each="item in items" id="static"><span>Item</span></for>"#,
7321        );
7322        assert_fragments!(fragments, [for_loop("item", "items", "static-1"),]);
7323        assert_stream!(records, "static-1", [raw("<span>Item</span>"),]);
7324    }
7325
7326    #[test]
7327    fn test_for_recursive_template() {
7328        let mut parser = HtmlParser::new();
7329        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>"#;
7330        let result = parser.parse("index.html", html);
7331        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7332        let records = parser.into_fragment_records();
7333
7334        assert_fragments!(
7335            records["index.html"].fragments,
7336            [for_loop("item", "items", "static-1"),]
7337        );
7338
7339        assert_stream!(
7340            records,
7341            "static-1",
7342            [
7343                raw("<div><span>"),
7344                signal("item.name"),
7345                raw("</span>"),
7346                for_loop("item", "item.children", "static-1"),
7347                raw("</div>"),
7348            ]
7349        );
7350    }
7351
7352    #[test]
7353    fn each_accepts_only_direct_item_in_collection_syntax() {
7354        assert_eq!(
7355            parse_for_each("test-tree", "child in items").unwrap(),
7356            ("child", "items")
7357        );
7358        assert_eq!(
7359            parse_for_each("test-tree", " \tchild \n in \r child.children ").unwrap(),
7360            ("child", "child.children")
7361        );
7362    }
7363
7364    #[test]
7365    fn braced_each_is_rejected_by_server_and_client_compilation() {
7366        use crate::plugin::webui::generate_compiled_template;
7367
7368        for source in [
7369            r#"<for each="{{child in items}}"><span>{{child.name}}</span></for>"#,
7370            r#"<for id="tree" each="{{child in items}}"><span>{{child.name}}</span></for>"#,
7371            r#"<for id="tree" each="child in items"><for id="tree" each="{{child in child.children}}" /></for>"#,
7372            r#"<for id="tree" each="{{ child in items }}"><span>{{child.name}}</span></for>"#,
7373            r#"<for id="tree" each="{child in items}"><span>{{child.name}}</span></for>"#,
7374        ] {
7375            let server = HtmlParser::new().parse("test-tree", source).unwrap_err();
7376            let client = generate_compiled_template("test-tree", source).unwrap_err();
7377            let ParserError::Template(server) = server else {
7378                panic!("expected a server authoring diagnostic");
7379            };
7380            let ParserError::Template(client) = client else {
7381                panic!("expected a client authoring diagnostic");
7382            };
7383            assert_eq!(server.error_code(), client.error_code(), "{source}");
7384            assert!(matches!(
7385                server.error_code(),
7386                Some(codes::INVALID_FOR_EACH | codes::INVALID_FOR_IDENTIFIER)
7387            ));
7388            assert!(server.position_line_column().is_some());
7389            assert!(server.help_text().unwrap().contains("without braces"));
7390            assert!(client.help_text().unwrap().contains("without braces"));
7391        }
7392    }
7393
7394    #[test]
7395    fn named_for_recursion_reuses_one_scoped_record() {
7396        let mut parser = HtmlParser::new();
7397        parser
7398            .parse(
7399                "file-a.html",
7400                r#"<for id="tree-item" each="child in items"><li>{{child.name}}</li><for id="tree-item" each="child in child.children" /></for>"#,
7401            )
7402            .unwrap();
7403        let records = parser.into_fragment_records();
7404        assert_eq!(records.len(), 2);
7405        assert_stream!(
7406            records,
7407            "file-a.html",
7408            [for_loop("child", "items", "tree-item-1")]
7409        );
7410        assert_stream!(
7411            records,
7412            "tree-item-1",
7413            [
7414                raw("<li>"),
7415                signal("child.name"),
7416                raw("</li>"),
7417                for_loop("child", "child.children", "tree-item-1"),
7418            ]
7419        );
7420    }
7421
7422    #[test]
7423    fn named_for_forward_references_and_empty_definitions() {
7424        let mut parser = HtmlParser::new();
7425        parser
7426            .parse(
7427                "file-a.html",
7428                r#"<for id="loop" each="item in earlier" /><for id="loop" each="item in later"></for>"#,
7429            )
7430            .unwrap();
7431        let records = parser.into_fragment_records();
7432        assert_stream!(
7433            records,
7434            "file-a.html",
7435            [
7436                for_loop("item", "earlier", "loop-1"),
7437                for_loop("item", "later", "loop-1")
7438            ]
7439        );
7440        assert!(records["loop-1"].fragments.is_empty());
7441    }
7442
7443    #[test]
7444    fn named_for_scope_survives_component_parsing_and_reparsing() {
7445        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7446        parser
7447            .component_registry_mut()
7448            .register_component(ComponentRegistration::new(
7449                "other-tree",
7450                r#"<for id="loop" each="item in other"><b>{{item.label}}</b></for>"#,
7451                None,
7452                false,
7453            ))
7454            .unwrap();
7455        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>"#;
7456        parser.parse("file-a.html", source).unwrap();
7457        parser
7458            .parse(
7459                "file-b.html",
7460                r#"<for id="loop" each="item in items">B</for>"#,
7461            )
7462            .unwrap();
7463        parser.parse("file-a.html", source).unwrap();
7464        let records = parser.into_fragment_records();
7465        assert_stream!(
7466            records,
7467            "file-a.html",
7468            [for_loop("item", "items", "loop-1")]
7469        );
7470        assert_stream!(records, "other-tree", [for_loop("item", "other", "loop-2")]);
7471        assert_stream!(
7472            records,
7473            "file-b.html",
7474            [for_loop("item", "items", "loop-3")]
7475        );
7476        assert_stream!(records, "loop-3", [raw("B")]);
7477        assert!(records["loop-1"].fragments.iter().any(|fragment| {
7478            matches!(fragment.fragment.as_ref(), Some(Fragment::ForLoop(repeat)) if repeat.fragment_id == "loop-1")
7479        }));
7480    }
7481
7482    #[test]
7483    fn named_for_reports_authoring_errors() {
7484        for (source, code) in [
7485            (r#"<for id each="x in xs" />"#, codes::INVALID_FOR_ID),
7486            (r#"<for id="" each="x in xs" />"#, codes::INVALID_FOR_ID),
7487            (
7488                r#"<for id="{{name}}" each="x in xs" />"#,
7489                codes::INVALID_FOR_ID,
7490            ),
7491            (
7492                r#"<for id="tree" template="tree" each="x in xs" />"#,
7493                codes::INVALID_FOR_ID,
7494            ),
7495            (
7496                r#"<for id="tree" id="tree" each="x in xs" />"#,
7497                codes::INVALID_FOR_ID,
7498            ),
7499            (r#"<for id="tree" each="x in xs" />"#, codes::UNKNOWN_FOR_ID),
7500            (
7501                r#"<for id="tree" each="x in xs">a</for><for id="tree" each="x in ys">b</for>"#,
7502                codes::DUPLICATE_FOR_ID,
7503            ),
7504            (
7505                r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children"><b>{{y.name}}</b></for></for>"#,
7506                codes::DUPLICATE_FOR_ID,
7507            ),
7508            (
7509                r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children" /></for>"#,
7510                codes::INCOMPATIBLE_FOR_ITEM,
7511            ),
7512        ] {
7513            let error = HtmlParser::new().parse("file-a.html", source).unwrap_err();
7514            let ParserError::Template(diagnostic) = error else {
7515                panic!("expected structured diagnostic for {source}");
7516            };
7517            assert_eq!(diagnostic.error_code(), Some(code), "{source}");
7518            assert!(!diagnostic.to_string().contains('\u{1b}'));
7519            assert!(diagnostic.to_string().contains("help:"));
7520            assert!(diagnostic.to_string().contains("file-a.html"));
7521            assert!(diagnostic.position_line_column().is_some(), "{source}");
7522        }
7523    }
7524
7525    #[test]
7526    fn named_for_cannot_reference_a_definition_from_another_file() {
7527        let mut parser = HtmlParser::new();
7528        parser
7529            .parse("a.html", r#"<for id="tree" each="x in xs">a</for>"#)
7530            .unwrap();
7531        let error = parser
7532            .parse("b.html", r#"<for id="tree" each="x in xs" />"#)
7533            .unwrap_err();
7534        assert!(
7535            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::UNKNOWN_FOR_ID))
7536        );
7537    }
7538
7539    #[test]
7540    fn named_for_does_not_overwrite_generated_fragments() {
7541        let mut parser = HtmlParser::new();
7542        parser
7543            .parse(
7544                "a.html",
7545                r#"<for id="for" each="x in xs">named</for><for each="x in xs">ordinary</for>"#,
7546            )
7547            .unwrap();
7548        let records = parser.into_fragment_records();
7549        assert_stream!(records, "for-1", [raw("named")]);
7550        assert_stream!(records, "for-2", [raw("ordinary")]);
7551        let error = HtmlParser::new()
7552            .parse(
7553                "a.html",
7554                r#"<for each="x in xs">ordinary</for><for id="for" each="x in xs">named</for>"#,
7555            )
7556            .unwrap_err();
7557        assert!(
7558            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::INVALID_FOR_ID))
7559        );
7560    }
7561
7562    #[test]
7563    fn named_for_css_analysis_terminates_without_losing_sibling_scopes() {
7564        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7565        for (name, source, css) in [
7566            ("token-leaf", "<b>leaf</b>", "b { color: var(--accent); }"),
7567            (
7568                "defined-tree",
7569                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
7570                ":root { --accent: red; }",
7571            ),
7572            (
7573                "undefined-tree",
7574                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
7575                "",
7576            ),
7577        ] {
7578            parser
7579                .component_registry_mut()
7580                .register_component(ComponentRegistration::new(name, source, Some(css), false))
7581                .unwrap();
7582        }
7583        parser
7584            .parse(
7585                "index.html",
7586                "<defined-tree></defined-tree><undefined-tree></undefined-tree>",
7587            )
7588            .unwrap();
7589        let analysis = parser.token_analysis();
7590        assert_eq!(analysis.protocol_tokens, ["accent"]);
7591        assert_eq!(analysis.fallback_chains.len(), 1);
7592    }
7593
7594    #[test]
7595    fn named_for_css_analysis_deduplicates_equivalent_diamond_paths() {
7596        use std::fmt::Write;
7597
7598        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7599        parser
7600            .component_registry_mut()
7601            .register_component(ComponentRegistration::new(
7602                "token-leaf",
7603                "<b>leaf</b>",
7604                Some("b { color: var(--accent); }"),
7605                false,
7606            ))
7607            .unwrap();
7608        let mut source = String::with_capacity(12 * 150);
7609        for index in 0..12 {
7610            write!(source, r#"<for id="b{index}" each="item in items">"#).unwrap();
7611            if index < 11 {
7612                let next = index + 1;
7613                write!(source, r#"<for id="b{next}" each="item in item.children"/><for id="b{next}" each="item in item.children"/>"#).unwrap();
7614            } else {
7615                source.push_str("<token-leaf></token-leaf>");
7616            }
7617            source.push_str("</for>");
7618        }
7619        parser.parse("index.html", &source).unwrap();
7620        let analysis = parser.token_analysis();
7621        assert_eq!(analysis.protocol_tokens, ["accent"]);
7622        assert_eq!(analysis.fallback_chains.len(), 1);
7623    }
7624
7625    #[test]
7626    fn named_for_css_analysis_preserves_distinct_same_size_definition_sets() {
7627        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7628        for (name, source, css) in [
7629            (
7630                "token-leaf",
7631                "<b>leaf</b>",
7632                "b { color: var(--accent); background: var(--shade); }",
7633            ),
7634            (
7635                "shared-tree",
7636                r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children"/></for>"#,
7637                "",
7638            ),
7639            (
7640                "accent-tree",
7641                "<shared-tree></shared-tree>",
7642                ":root { --accent: red; }",
7643            ),
7644            (
7645                "shade-tree",
7646                "<shared-tree></shared-tree>",
7647                ":root { --shade: blue; }",
7648            ),
7649        ] {
7650            parser
7651                .component_registry_mut()
7652                .register_component(ComponentRegistration::new(name, source, Some(css), false))
7653                .unwrap();
7654        }
7655        parser
7656            .parse(
7657                "index.html",
7658                "<accent-tree></accent-tree><shade-tree></shade-tree>",
7659            )
7660            .unwrap();
7661        assert_eq!(parser.token_analysis().protocol_tokens, ["accent", "shade"]);
7662    }
7663
7664    #[test]
7665    fn legacy_for_template_attribute_is_rejected() {
7666        for source in [
7667            r#"<for template="legacy" each="x in xs"><span>{{x.name}}</span></for>"#,
7668            r#"<for template="legacy" each="x in xs" />"#,
7669            r#"<for template each="x in xs"></for>"#,
7670            r#"<for template="" each="x in xs"></for>"#,
7671        ] {
7672            let error = HtmlParser::new().parse("index.html", source).unwrap_err();
7673            let ParserError::Template(diagnostic) = error else {
7674                panic!("expected an authoring diagnostic for {source}");
7675            };
7676            assert_eq!(diagnostic.error_code(), Some(codes::INVALID_FOR_ID));
7677            assert!(diagnostic
7678                .help_text()
7679                .unwrap()
7680                .contains("replace template with id"));
7681            assert!(diagnostic.position_line_column().is_some());
7682        }
7683    }
7684
7685    #[test]
7686    fn named_for_recursion_still_rejects_reachable_boundaries() {
7687        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();
7688        assert!(
7689            matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::BOUNDARY_IN_REPEAT))
7690        );
7691    }
7692
7693    #[test]
7694    fn named_for_fast_components_reject_references_but_keep_plain_loops() {
7695        use crate::plugin::{
7696            fast_v2::FastV2ParserPlugin, fast_v3::FastV3ParserPlugin, ParserPlugin,
7697            ParserPluginArtifacts,
7698        };
7699
7700        for fast_v2 in [true, false] {
7701            for references in [true, false] {
7702                let plugin: Box<dyn ParserPlugin> = if fast_v2 {
7703                    Box::new(FastV2ParserPlugin::new())
7704                } else {
7705                    Box::new(FastV3ParserPlugin::new())
7706                };
7707                let mut parser = HtmlParser::with_plugin(plugin);
7708                let body = if references {
7709                    r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#
7710                } else {
7711                    r#"<for id="tree" each="item in items"><if condition="{{item.name}}"><b>{{item.name}}</b></if></for>"#
7712                };
7713                parser
7714                    .component_registry_mut()
7715                    .register_component(ComponentRegistration::new("my-tree", body, None, true))
7716                    .unwrap();
7717                let result = parser.parse("index.html", "<my-tree></my-tree>");
7718                if references {
7719                    let error = result.unwrap_err();
7720                    assert!(
7721                        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())
7722                    );
7723                } else {
7724                    result.unwrap();
7725                    let ParserPluginArtifacts::ComponentTemplates(templates) =
7726                        parser.take_plugin_artifacts().unwrap()
7727                    else {
7728                        panic!("FAST produces component templates");
7729                    };
7730                    assert!(templates[0]
7731                        .template
7732                        .contains(r#"<f-repeat value="{{item in items}}">"#));
7733                    assert!(templates[0]
7734                        .template
7735                        .contains(r#"<f-when value="{{item.name}}">"#));
7736                }
7737            }
7738        }
7739    }
7740
7741    // ── Feature 2: <if> / <for> with multiple children ──────────────────
7742
7743    #[test]
7744    fn test_if_multiple_children() {
7745        // Port of: 'should handle <if> with multiple children'
7746        let (fragments, records) =
7747            parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
7748        assert_fragments!(fragments, [if_cond("if-1"),]);
7749        assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
7750    }
7751
7752    #[test]
7753    fn if_condition_with_double_quoted_literal_round_trips_through_parser() {
7754        // The FAST converter emits a single-quoted `<if condition='…'>` when the
7755        // expression contains a double-quoted literal. The WebUI parser must
7756        // extract the full expression (no truncation at the embedded quote), so
7757        // SSR and client see the same predicate.
7758        let (fragments, _) =
7759            parse_and_get_fragments(r#"<if condition='status == "ready"'><span>ok</span></if>"#);
7760        let condition = fragments
7761            .iter()
7762            .find_map(|fragment| match fragment.fragment.as_ref() {
7763                Some(webui_protocol::web_ui_fragment::Fragment::IfCond(if_cond)) => {
7764                    if_cond.condition.as_ref()
7765                }
7766                _ => None,
7767            })
7768            .expect("if-condition fragment with a parsed condition");
7769        match condition.expr.as_ref() {
7770            Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
7771                assert_eq!(pred.left, "status");
7772                assert_eq!(pred.right, "\"ready\"");
7773            }
7774            other => panic!("expected a predicate condition, got {other:?}"),
7775        }
7776    }
7777
7778    #[test]
7779    fn test_for_multiple_children() {
7780        // Port of: 'should handle <for> with multiple children'
7781        let (fragments, records) =
7782            parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
7783        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7784        assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
7785    }
7786
7787    // ── Feature 3: Handlebars at beginning/end of text ──────────────────
7788
7789    #[test]
7790    fn test_handlebars_at_beginning() {
7791        // Port of: 'should process handlebars from text at beginning'
7792        let (fragments, _) = parse_and_get_fragments("{{first}}");
7793        assert_fragments!(fragments, [signal("first"),]);
7794    }
7795
7796    #[test]
7797    fn test_handlebars_at_beginning_and_raw() {
7798        // Port of: 'should process handlebars from text at beginning and raw'
7799        let (fragments, _) = parse_and_get_fragments("{{first}}test");
7800        assert_fragments!(fragments, [signal("first"), raw("test"),]);
7801    }
7802
7803    #[test]
7804    fn test_handlebars_raw_and_end() {
7805        // Port of: 'should process handlebars from text at raw and end'
7806        let (fragments, _) = parse_and_get_fragments("test{{first}}");
7807        assert_fragments!(fragments, [raw("test"), signal("first"),]);
7808    }
7809
7810    // ── Feature 4: Handlebars edge cases ────────────────────────────────
7811
7812    #[test]
7813    fn test_handlebars_invalid_triple_open() {
7814        // Port of: 'should not process handlebars when invalid'
7815        let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
7816        assert_fragments!(fragments, [raw("{{{invalid}}"),]);
7817    }
7818
7819    #[test]
7820    fn test_handlebars_four_open_braces() {
7821        // Port of: 'should not process handlebars when invalid since triple exists'
7822        let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
7823        assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
7824    }
7825
7826    #[test]
7827    fn test_handlebars_five_open_with_valid_double() {
7828        // Port of: 'should not process handlebars when invalid but with valid triple'
7829        let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
7830        assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
7831    }
7832
7833    #[test]
7834    fn test_entities_preserved() {
7835        // Port of: 'should process entities correctly'
7836        let (fragments, _) = parse_and_get_fragments("<p>Hello&#125;World</p>");
7837        assert_fragments!(fragments, [raw("<p>Hello&#125;World</p>"),]);
7838    }
7839
7840    // ── Feature 5: DOCTYPE handling ─────────────────────────────────────
7841
7842    #[test]
7843    fn test_doctype_preserved() {
7844        // DOCTYPE should be preserved as raw content
7845        let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
7846        assert!(!fragments.is_empty());
7847        assert!(
7848            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
7849        );
7850    }
7851
7852    // ── Feature 6: Component attribute skip / multiple nested ───────────
7853
7854    #[test]
7855    fn test_component_attr_skip() {
7856        // Port of: 'should set attrSkip for skipped component attributes'
7857        let mut parser = HtmlParser::new();
7858        parser
7859            .component_registry
7860            .register_component(ComponentRegistration::new(
7861                "custom-element",
7862                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7863                None,
7864                true,
7865            ))
7866            .expect("register");
7867        let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
7868        let result = parser.parse("index.html", html);
7869        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7870        let records = parser.into_fragment_records();
7871
7872        // <custom-element, :config(attrStart), class(attrSkip), style(attrSkip),
7873        // role(attrSkip), data-test(attrSkip), aria-test, >, component, </custom-element>
7874        assert_fragments!(
7875            records["index.html"].fragments,
7876            [
7877                raw("<custom-element"),
7878                // :config with attrStart
7879                attr_complex_start(":config", "config"),
7880                // Skipped attrs
7881                attr_skip("class", "value0"),
7882                attr_skip("style", "value1"),
7883                attr_skip("role", "value2"),
7884                attr_skip("data-test", "value3"),
7885                attr("aria-test", "value4"),
7886                structural_matcher("streaming_root:custom-element"),
7887                raw(">"),
7888                component("custom-element"),
7889                raw("</custom-element>"),
7890            ]
7891        );
7892    }
7893
7894    #[test]
7895    fn test_component_attr_skip_static_and_embedded() {
7896        // Skipped attrs with static values and embedded bindings should
7897        // emit fragments (not be silently dropped).
7898        let mut parser = HtmlParser::new();
7899        parser
7900            .component_registry
7901            .register_component(ComponentRegistration::new(
7902                "item-group",
7903                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7904                None,
7905                true,
7906            ))
7907            .expect("register");
7908
7909        let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
7910        let result = parser.parse("index.html", html);
7911        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7912        let records = parser.into_fragment_records();
7913
7914        assert_fragments!(
7915            records["index.html"].fragments,
7916            [
7917                raw("<item-group"),
7918                attr_skip_raw("role", "list"),
7919                FragmentMatcher::Attribute(AttrMatcher {
7920                    name: "aria-labelledby".into(),
7921                    template: Some("attr-1".into()),
7922                    attr_start: true,
7923                    ..Default::default()
7924                }),
7925                attr_skip_template("data-testid", "attr-2"),
7926                attr_skip_raw("class", "fixed-class"),
7927                structural_matcher("streaming_root:item-group"),
7928                raw(">"),
7929                component("item-group"),
7930                raw("</item-group>"),
7931            ]
7932        );
7933
7934        // Verify the embedded-binding template sub-streams exist and
7935        // contain the expected static + signal fragments.
7936        assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
7937        assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
7938    }
7939
7940    #[test]
7941    fn test_component_multiple_nested() {
7942        // Port of: 'handle multiple nested web components'
7943        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7944        parser
7945            .component_registry
7946            .register_component(ComponentRegistration::new(
7947                "custom-element",
7948                "<custom-child></custom-child><span></span>",
7949                None,
7950                true,
7951            ))
7952            .expect("register");
7953        parser
7954            .component_registry
7955            .register_component(ComponentRegistration::new(
7956                "custom-button",
7957                "<span></span>",
7958                None,
7959                true,
7960            ))
7961            .expect("register");
7962        parser
7963            .component_registry
7964            .register_component(ComponentRegistration::new(
7965                "custom-child",
7966                "<h1>Hello World!</h1>",
7967                None,
7968                true,
7969            ))
7970            .expect("register");
7971
7972        let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
7973        let result = parser.parse("index.html", html);
7974        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7975        let records = parser.into_fragment_records();
7976
7977        // Entry stream
7978        assert_fragments!(
7979            records["index.html"].fragments,
7980            [for_loop("item", "items", "for-1"),]
7981        );
7982
7983        // For stream
7984        assert_stream!(
7985            records,
7986            "for-1",
7987            [
7988                raw("<custom-element"),
7989                structural_matcher("streaming_root:custom-element"),
7990                raw(">"),
7991                component("custom-element"),
7992                raw("<custom-button"),
7993                structural_matcher("streaming_root:custom-button"),
7994                raw(">"),
7995                component("custom-button"),
7996                raw("Ok</custom-button></custom-element>"),
7997            ]
7998        );
7999
8000        // Component streams — custom-element has contains() checks, keep manual
8001        let ce = &records["custom-element"].fragments;
8002        assert_eq!(ce.len(), 5);
8003        assert!(
8004            matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
8005        );
8006        assert!(
8007            matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
8008        );
8009        assert!(
8010            matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><span></span>"))
8011        );
8012
8013        assert_stream!(records, "custom-button", [raw("<span></span>"),]);
8014
8015        assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
8016    }
8017
8018    // ── Error handling tests ──────────────────────────────────────────
8019
8020    #[test]
8021    fn test_invalid_markup_returns_error() {
8022        // Port of: 'should fail with invalid markup'
8023        let mut parser = HtmlParser::new();
8024        let result = parser.parse("index.html", "<div><span>Unclosed div");
8025        assert!(matches!(result, Err(ParserError::Template(diag)) if
8026            diag.to_string().contains("unclosed <div> tag")
8027        ));
8028    }
8029
8030    #[test]
8031    fn test_unterminated_opening_tag_returns_error() {
8032        let mut parser = HtmlParser::new();
8033        let result = parser.parse("index.html", "before <div");
8034        assert!(matches!(result, Err(ParserError::Template(diag)) if
8035            diag.to_string().contains("malformed HTML tag")
8036        ));
8037    }
8038
8039    #[test]
8040    fn test_unterminated_html_comment_returns_error() {
8041        let mut parser = HtmlParser::new();
8042        let result = parser.parse("index.html", "<div><!-- missing close</div>");
8043        assert!(matches!(result, Err(ParserError::Template(diag)) if
8044            diag.to_string().contains("unclosed <div> tag")
8045                || diag.to_string().contains("unterminated HTML comment")
8046        ));
8047    }
8048
8049    #[test]
8050    fn test_unexpected_closing_tag_returns_error() {
8051        let mut parser = HtmlParser::new();
8052        let result = parser.parse("index.html", "</span>");
8053        assert!(matches!(result, Err(ParserError::Template(diag)) if
8054            diag.to_string().contains("unexpected closing tag </span>")
8055        ));
8056    }
8057
8058    #[test]
8059    fn test_rejects_templates_over_size_limit() {
8060        let mut parser = HtmlParser::new();
8061        let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
8062        let result = parser.parse("index.html", &html);
8063
8064        assert!(matches!(result, Err(ParserError::Html(message)) if
8065            message.contains("exceeds") && message.contains("parser limit")
8066        ));
8067    }
8068
8069    #[test]
8070    fn test_rejects_templates_over_depth_limit() {
8071        let mut parser = HtmlParser::new();
8072        let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
8073        for _ in 0..=MAX_TEMPLATE_DEPTH {
8074            html.push_str("<div>");
8075        }
8076        for _ in 0..=MAX_TEMPLATE_DEPTH {
8077            html.push_str("</div>");
8078        }
8079
8080        let result = parser.parse("index.html", &html);
8081
8082        assert!(matches!(result, Err(ParserError::Template(diag)) if
8083            diag.to_string().contains("nesting exceeds")
8084        ));
8085    }
8086
8087    #[test]
8088    fn test_rejects_routes_over_depth_limit() {
8089        let mut parser = HtmlParser::new();
8090        parser
8091            .component_registry
8092            .register_component(ComponentRegistration::new(
8093                "route-page",
8094                r#"<template shadowrootmode="open"><slot></slot></template>"#,
8095                None,
8096                true,
8097            ))
8098            .expect("register");
8099
8100        let depth = MAX_TEMPLATE_DEPTH + 2;
8101        let mut html = String::with_capacity(depth * 48);
8102        for _ in 0..depth {
8103            html.push_str(r#"<route path="x" component="route-page">"#);
8104        }
8105        for _ in 0..depth {
8106            html.push_str("</route>");
8107        }
8108
8109        let result = parser.parse("index.html", &html);
8110
8111        assert!(matches!(result, Err(ParserError::Template(diag)) if
8112            diag.error_code() == Some(codes::EXCESSIVE_NESTING)
8113                && diag.to_string().contains("route nesting exceeds")
8114        ));
8115    }
8116
8117    #[test]
8118    fn test_rejects_recursive_component_templates() {
8119        let mut parser = HtmlParser::new();
8120        parser
8121            .component_registry
8122            .register_component(ComponentRegistration::new(
8123                "self-card",
8124                "<self-card></self-card>",
8125                None,
8126                true,
8127            ))
8128            .expect("register");
8129
8130        let result = parser.parse("index.html", "<self-card></self-card>");
8131
8132        assert!(matches!(result, Err(ParserError::Template(diag)) if
8133            diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
8134                && diag.to_string().contains("recursive template reference")
8135        ));
8136    }
8137
8138    // ── Integration tests ─────────────────────────────────────────────
8139
8140    #[test]
8141    fn test_complex_raw_text_full_page() {
8142        // Port of: 'should process a complex raw text'
8143        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>"#;
8144        let (fragments, _) = parse_and_get_fragments(html);
8145
8146        // DOCTYPE + <head>, head_start, head content, head_end, </head><body>,
8147        // body_start, body content, body_end, </body></html>
8148        assert!(fragments.len() >= 9);
8149        assert!(
8150            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8151                raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
8152        );
8153        assert!(
8154            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
8155                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
8156        );
8157        assert!(
8158            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8159                raw.value.contains("<title>Test</title>"))
8160        );
8161        assert!(
8162            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
8163                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
8164        );
8165        assert!(
8166            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8167                raw.value.contains("</head>") && raw.value.ends_with("<body>"))
8168        );
8169        assert!(
8170            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
8171                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
8172        );
8173        assert!(
8174            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8175                raw.value.contains("<app-shell>"))
8176        );
8177        assert!(
8178            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
8179                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
8180        );
8181        assert!(
8182            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8183                raw.value.contains("</body>") && raw.value.contains("</html>"))
8184        );
8185    }
8186
8187    #[test]
8188    fn raw_bindings_in_html_text_contexts_do_not_own_html_ranges() {
8189        for element in ["title", "textarea", "script", "xmp"] {
8190            let html = format!("<{element}>{{{{{{value}}}}}}</{element}>");
8191            let (fragments, _) = parse_and_get_fragments(&html);
8192            let signal = fragments
8193                .iter()
8194                .find_map(|fragment| match fragment.fragment.as_ref() {
8195                    Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
8196                    _ => None,
8197                });
8198            let signal = signal
8199                .unwrap_or_else(|| panic!("expected raw signal inside <{element}>: {fragments:?}"));
8200            assert!(
8201                signal.raw,
8202                "<{element}> should preserve raw brace semantics"
8203            );
8204            assert!(
8205                signal.raw_text_context,
8206                "<{element}> should suppress HTML ownership markers"
8207            );
8208        }
8209    }
8210
8211    #[test]
8212    fn raw_bindings_in_template_content_own_html_ranges() {
8213        let (fragments, _) = parse_and_get_fragments("<template>{{{value}}}</template>");
8214        let signal = fragments
8215            .iter()
8216            .find_map(|fragment| match fragment.fragment.as_ref() {
8217                Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
8218                _ => None,
8219            });
8220        let signal =
8221            signal.unwrap_or_else(|| panic!("expected raw signal inside template: {fragments:?}"));
8222        assert!(signal.raw);
8223        assert!(!signal.raw_text_context);
8224    }
8225
8226    #[test]
8227    fn test_css_strategy_external_defers_link_to_server_closure() {
8228        let mut parser = HtmlParser::new();
8229        parser
8230            .component_registry_mut()
8231            .register_component(ComponentRegistration::new(
8232                "my-card",
8233                r#"<template shadowrootmode="open"><p>content</p></template>"#,
8234                Some("p { color: red; }"),
8235                true,
8236            ))
8237            .ok();
8238        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
8239        let records = parser.into_fragment_records();
8240        let my_card = &records["my-card"].fragments;
8241        let raw_text: String = my_card
8242            .iter()
8243            .filter_map(|f| match &f.fragment {
8244                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8245                _ => None,
8246            })
8247            .collect();
8248        assert!(
8249            !raw_text.contains(r#"<link rel="stylesheet""#),
8250            "component templates must not own tree-local links: {}",
8251            raw_text
8252        );
8253        assert!(my_card.iter().any(|fragment| matches!(
8254            fragment.fragment.as_ref(),
8255            Some(Fragment::Signal(signal))
8256                if signal.value == "}}}webui:shadow_styles:my-card"
8257        )));
8258    }
8259
8260    #[test]
8261    fn test_uppercase_style_element_is_processed_as_css() {
8262        // HTML tag names are ASCII case-insensitive: an uppercase `<STYLE>`
8263        // block must be CSS-processed exactly like `<style>`, so a CSS signal
8264        // comment becomes a Signal fragment (not inert raw text) and the
8265        // reconstructed closing tag mirrors the source case.
8266        let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
8267        assert!(
8268            fragments.iter().any(|f| matches!(
8269                f.fragment.as_ref(),
8270                Some(Fragment::Signal(s))
8271                    if s.value == "tokens" && !s.raw && s.raw_text_context
8272            )),
8273            "uppercase <STYLE> should be CSS-processed, got: {:?}",
8274            fragments
8275        );
8276        let raw: String = fragments
8277            .iter()
8278            .filter_map(|f| match f.fragment.as_ref() {
8279                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8280                _ => None,
8281            })
8282            .collect();
8283        assert!(
8284            raw.contains("</STYLE>"),
8285            "closing tag should preserve source case, got: {raw}"
8286        );
8287    }
8288
8289    #[test]
8290    fn test_css_strategy_inline_defers_style_to_server_closure() {
8291        let mut parser = HtmlParser::with_options(CssStrategy::Style);
8292        parser
8293            .component_registry_mut()
8294            .register_component(ComponentRegistration::new(
8295                "my-card",
8296                "<p>content</p>",
8297                Some("p { color: red; }"),
8298                true,
8299            ))
8300            .expect("register component");
8301        parser
8302            .parse("index.html", "<my-card>Hello</my-card>")
8303            .expect("parse component");
8304        let records = parser.into_fragment_records();
8305        let my_card = &records["my-card"].fragments;
8306        let raw_text: String = my_card
8307            .iter()
8308            .filter_map(|f| match &f.fragment {
8309                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8310                _ => None,
8311            })
8312            .collect();
8313        assert!(
8314            !raw_text.contains("<style>"),
8315            "component templates must not own tree-local styles: {}",
8316            raw_text
8317        );
8318        assert!(
8319            !raw_text.contains("<link"),
8320            "Should not have <link> tag in inline mode: {}",
8321            raw_text
8322        );
8323    }
8324
8325    #[test]
8326    fn test_css_strategy_module_emits_adopted_stylesheets() {
8327        let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
8328        parser
8329            .component_registry_mut()
8330            .register_component(ComponentRegistration::new(
8331                "my-card",
8332                "<p>content</p>",
8333                Some("p { color: red; }"),
8334                true,
8335            ))
8336            .expect("register component");
8337        parser
8338            .parse("index.html", "<my-card>Hello</my-card>")
8339            .expect("parse component");
8340        let records = parser.into_fragment_records();
8341
8342        // Component template should have shadowrootadoptedstylesheets, no CSS
8343        // module in raw fragments (CSS lives on the component fragment's css field)
8344        let my_card = &records["my-card"].fragments;
8345        let template_text: String = my_card
8346            .iter()
8347            .filter_map(|f| match &f.fragment {
8348                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8349                _ => None,
8350            })
8351            .collect();
8352        assert!(
8353            !template_text.contains("shadowrootadoptedstylesheets"),
8354            "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
8355        );
8356        assert!(
8357            !template_text.contains("<link"),
8358            "Should not have <link> in module mode: {template_text}"
8359        );
8360        // No CSS module baked into raw fragments — CSS is stored in
8361        // protocol.components, populated by the build system, and emitted
8362        // by the handler (SSR inline + SPA partials) as importmap scripts.
8363        assert!(
8364            !template_text.contains(r#"<style type="module""#),
8365            "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
8366        );
8367        assert!(
8368            !template_text.contains(r#"<script type="importmap""#),
8369            "CSS module importmap should NOT be in raw fragments: {template_text}"
8370        );
8371    }
8372
8373    #[test]
8374    fn light_component_css_remains_authored_and_unscoped() {
8375        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8376        parser
8377            .component_registry_mut()
8378            .register_component(ComponentRegistration::new(
8379                "my-card",
8380                "<p class=\"label\">content</p>",
8381                Some(".label{color:red}"),
8382                false,
8383            ))
8384            .expect("register component");
8385        parser
8386            .parse("index.html", "<my-card></my-card>")
8387            .expect("parse component");
8388
8389        let css = parser
8390            .component_registry()
8391            .get("my-card")
8392            .and_then(|component| component.css_content.as_deref())
8393            .expect("authored CSS");
8394        assert_eq!(css, ".label{color:red}");
8395    }
8396
8397    #[test]
8398    fn light_component_inline_styles_remain_unscoped_and_unmarked() {
8399        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8400        let built = parser
8401            .build_component_templates(ComponentTemplateInput {
8402                tag_name: "my-card",
8403                html: "<style>.label{color:red}</style><p class=\"label\">content</p>",
8404                artifact_html: None,
8405                authored_html: None,
8406                css_content: None,
8407                artifact_needed: false,
8408            })
8409            .expect("build Light component");
8410
8411        assert_eq!(
8412            built.ssr,
8413            "<style>.label{color:red}</style><p class=\"label\">content</p>"
8414        );
8415        assert!(!built.ssr.contains("data-wl"));
8416    }
8417
8418    #[test]
8419    fn light_component_rejects_shadow_only_selectors() {
8420        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8421        for selector in [":host", ":host-context(body.dark)", "::slotted(*)"] {
8422            let html = format!("<style>{selector}{{color:red}}</style><p>content</p>");
8423            let result = parser.build_component_templates(ComponentTemplateInput {
8424                tag_name: "my-card",
8425                html: &html,
8426                artifact_html: None,
8427                authored_html: None,
8428                css_content: None,
8429                artifact_needed: false,
8430            });
8431            let error = match result {
8432                Ok(_) => panic!("Shadow-only selector must fail in Light DOM"),
8433                Err(error) => error,
8434            };
8435            assert!(matches!(
8436                error,
8437                ParserError::Template(ref diagnostic)
8438                    if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
8439            ));
8440        }
8441    }
8442
8443    #[test]
8444    fn light_component_external_css_rejects_host_selector() {
8445        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8446        parser
8447            .component_registry_mut()
8448            .register_component(ComponentRegistration::new(
8449                "my-card",
8450                "<p>content</p>",
8451                Some(":host{display:block}"),
8452                false,
8453            ))
8454            .expect("register component");
8455        let error = parser
8456            .parse("index.html", "<my-card></my-card>")
8457            .expect_err("external :host must fail in Light DOM");
8458        assert!(matches!(
8459            error,
8460            ParserError::Template(ref diagnostic)
8461                if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
8462        ));
8463    }
8464
8465    #[test]
8466    fn shadow_component_inline_styles_remain_tree_local_and_unchanged() {
8467        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
8468        let built = parser
8469            .build_component_templates(ComponentTemplateInput {
8470                tag_name: "my-card",
8471                html: "<style>:host{display:block}.label{color:red}</style><p class=\"label\">content</p>",
8472                artifact_html: None,
8473                authored_html: None,
8474                css_content: None,
8475                artifact_needed: false,
8476            })
8477            .expect("build Shadow component");
8478
8479        assert!(built.ssr.contains(
8480            "<template shadowrootmode=\"open\"><style>:host{display:block}.label{color:red}</style>"
8481        ));
8482        assert!(!built.ssr.contains("data-wl"));
8483    }
8484
8485    #[test]
8486    fn nested_light_hosts_are_unmarked() {
8487        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8488        parser
8489            .component_registry_mut()
8490            .register_component(ComponentRegistration::new(
8491                "my-child",
8492                "<p class=\"inner\">child</p>",
8493                Some(".inner{color:green}"),
8494                false,
8495            ))
8496            .expect("register child");
8497        parser
8498            .component_registry_mut()
8499            .register_component(ComponentRegistration::new(
8500                "my-card",
8501                "<my-child></my-child>",
8502                Some("my-child{display:block}"),
8503                false,
8504            ))
8505            .expect("register parent");
8506        parser
8507            .parse("index.html", "<my-card></my-card>")
8508            .expect("parse component");
8509
8510        let records = parser.into_fragment_records();
8511        let raw: String = records["my-card"]
8512            .fragments
8513            .iter()
8514            .filter_map(|fragment| match &fragment.fragment {
8515                Some(web_ui_fragment::Fragment::Raw(raw)) => Some(raw.value.as_str()),
8516                _ => None,
8517            })
8518            .collect();
8519        assert!(raw.contains("<my-child>"), "nested host was changed: {raw}");
8520        assert!(!raw.contains("data-wl"), "Light markers remain: {raw}");
8521    }
8522
8523    #[test]
8524    fn raw_html_binding_does_not_change_global_css() {
8525        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8526        parser
8527            .component_registry_mut()
8528            .register_component(ComponentRegistration::new(
8529                "my-card",
8530                "<div class=\"body\">{{{descriptionHtml}}}</div>",
8531                Some(".body p{margin:0}"),
8532                false,
8533            ))
8534            .expect("register component");
8535        parser
8536            .parse("index.html", "<my-card></my-card>")
8537            .expect("parse component");
8538
8539        let css = parser
8540            .component_registry()
8541            .get("my-card")
8542            .and_then(|component| component.css_content.as_deref())
8543            .expect("authored CSS");
8544        assert_eq!(css, ".body p{margin:0}");
8545    }
8546
8547    #[test]
8548    fn test_css_strategy_module_no_css_no_adopted_attr() {
8549        let mut parser = HtmlParser::with_options(CssStrategy::Module);
8550        parser
8551            .component_registry_mut()
8552            .register_component(ComponentRegistration::new(
8553                "my-card",
8554                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
8555                None,
8556                true,
8557            ))
8558            .ok();
8559        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
8560        let records = parser.into_fragment_records();
8561        let my_card = &records["my-card"].fragments;
8562        let template_text: String = my_card
8563            .iter()
8564            .filter_map(|f| match &f.fragment {
8565                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8566                _ => None,
8567            })
8568            .collect();
8569        // No CSS → no shadowrootadoptedstylesheets attribute
8570        assert!(
8571            !template_text.contains("shadowrootadoptedstylesheets"),
8572            "Should not have adopted attr without CSS: {template_text}"
8573        );
8574    }
8575
8576    // ── Dev-authored <template> wrapper handling ────────────────────
8577    //
8578    // These tests distinguish an ordinary `<template>` element from a
8579    // declarative Shadow DOM root:
8580    //
8581    // An ordinary `<template ...>` is Light DOM content and is preserved
8582    // verbatim. Only a sole open declarative Shadow root selects Shadow DOM.
8583    //
8584    // Calls `process_component_template` directly so the assertions observe
8585    // the exact HTML string the framework emits for the component template
8586    // (this is what gets handed to the inner parse + plugin hooks).
8587
8588    #[test]
8589    fn light_preserves_dev_template_with_static_attrs() {
8590        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8591        let processed = parser
8592            .process_component_template(
8593                r#"<template foo="bar"><div>hi</div></template>"#,
8594                None,
8595                None,
8596            )
8597            .expect("process failed");
8598        assert!(
8599            processed.contains(r#"<template foo="bar">"#),
8600            "expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
8601        );
8602        assert!(
8603            processed.contains("</template>"),
8604            "expected closing </template> preserved, got: {processed}"
8605        );
8606    }
8607
8608    #[test]
8609    fn light_preserves_dev_template_with_signal_fragment_attrs() {
8610        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8611        let processed = parser
8612            .process_component_template(
8613                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8614                None,
8615                None,
8616            )
8617            .expect("process failed");
8618        assert!(
8619            processed.contains(r#"<template foo="{{foo}}">"#),
8620            "expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
8621        );
8622    }
8623
8624    #[test]
8625    fn light_preserves_dev_template_with_multiple_attrs() {
8626        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8627        let processed = parser.process_component_template(
8628            r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
8629            None,
8630            None,
8631        )
8632        .expect("process failed");
8633        assert!(
8634            processed.contains("autofocus")
8635                && processed.contains(r#"tabindex="0""#)
8636                && processed.contains(r#"role="region""#)
8637                && processed.contains(r#"data-x="y""#),
8638            "expected all dev template attrs preserved, got: {processed}"
8639        );
8640    }
8641
8642    #[test]
8643    fn light_no_template_no_wrapper_added() {
8644        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8645        let processed = parser
8646            .process_component_template("<div>hi</div>", None, None)
8647            .expect("process failed");
8648        assert!(
8649            !processed.contains("<template"),
8650            "framework must not add a <template> wrapper when dev omits one, got: {processed}"
8651        );
8652        assert!(
8653            processed.contains("<div>hi</div>"),
8654            "expected inner content emitted as-is, got: {processed}"
8655        );
8656    }
8657
8658    #[test]
8659    fn ordinary_template_remains_light_dom_content() {
8660        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8661        let processed = parser
8662            .process_component_template(
8663                r#"<template foo="bar"><div>hi</div></template>"#,
8664                None,
8665                None,
8666            )
8667            .expect("process failed");
8668        assert!(
8669            processed.contains(r#"<template foo="bar">"#),
8670            "ordinary template content must be preserved, got: {processed}"
8671        );
8672        assert!(
8673            !processed.contains("shadowrootmode"),
8674            "ordinary template must not gain a generated Shadow root, got: {processed}"
8675        );
8676    }
8677
8678    #[test]
8679    fn bare_template_is_unwrapped_as_explicit_light_dom() {
8680        let mut parser = HtmlParser::new();
8681        let built = parser
8682            .build_component_templates(ComponentTemplateInput {
8683                tag_name: "my-card",
8684                html: "<template><div class=\"label\">hi</div></template>",
8685                artifact_html: None,
8686                authored_html: None,
8687                css_content: Some(".label{color:red}"),
8688                artifact_needed: false,
8689            })
8690            .expect("bare template should select Light DOM");
8691        assert!(!built.uses_shadow_dom);
8692        assert_eq!(built.ssr, r#"<div class="label">hi</div>"#);
8693    }
8694
8695    #[test]
8696    fn attributed_template_keeps_the_shadow_fallback() {
8697        let mut parser = HtmlParser::new();
8698        let built = parser
8699            .build_component_templates(ComponentTemplateInput {
8700                tag_name: "my-card",
8701                html: r#"<template data-purpose="content"><div>hi</div></template>"#,
8702                artifact_html: None,
8703                authored_html: None,
8704                css_content: None,
8705                artifact_needed: false,
8706            })
8707            .expect("attributed template should remain ordinary content");
8708        assert!(built.uses_shadow_dom);
8709        assert_eq!(
8710            built.ssr,
8711            r#"<template shadowrootmode="open"><template data-purpose="content"><div>hi</div></template></template>"#
8712        );
8713    }
8714
8715    #[test]
8716    fn closed_shadow_wrapper_is_rejected_during_template_processing() {
8717        let mut parser = HtmlParser::new();
8718        let error = parser
8719            .process_component_template(
8720                r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
8721                None,
8722                None,
8723            )
8724            .expect_err("closed roots must fail");
8725        assert!(matches!(
8726            error,
8727            ParserError::Template(ref diagnostic)
8728                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
8729        ));
8730    }
8731
8732    #[test]
8733    fn ordinary_template_preserves_signal_fragment_attrs() {
8734        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8735        let processed = parser
8736            .process_component_template(
8737                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8738                None,
8739                None,
8740            )
8741            .expect("process failed");
8742        assert!(
8743            processed.starts_with(r#"<template foo="{{foo}}">"#),
8744            "ordinary template must remain Light content, got: {processed}"
8745        );
8746    }
8747
8748    #[test]
8749    fn unwrapped_component_never_gains_shadow_wrapper() {
8750        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8751        let processed = parser
8752            .process_component_template("<div>hi</div>", None, None)
8753            .expect("process failed");
8754        assert!(
8755            !processed.contains("shadowrootmode"),
8756            "framework must not generate a Shadow root, got: {processed}"
8757        );
8758        assert!(
8759            processed.contains("<div>hi</div>"),
8760            "Light content must survive processing, got: {processed}"
8761        );
8762    }
8763
8764    #[test]
8765    fn default_unwrapped_component_gains_open_shadow_wrapper() {
8766        let mut parser = HtmlParser::new();
8767        let processed = parser
8768            .process_component_template("<div>hi</div>", None, None)
8769            .expect("process failed");
8770        assert_eq!(
8771            processed,
8772            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8773        );
8774    }
8775
8776    #[test]
8777    fn leading_comments_do_not_change_root_template_detection() {
8778        const COMMENT: &str = "<!-- Copyright (C) Corporation. All rights reserved. -->";
8779        let fixtures = [
8780            (
8781                format!(
8782                    "{COMMENT}\n<template shadowrootmode=\"open\">\n  <h1>Hello</h1>\n</template>"
8783                ),
8784                "<template shadowrootmode=\"open\">\n  <h1>Hello</h1>\n</template>",
8785            ),
8786            (format!("{COMMENT}\n<h1>Hello</h1>"), "<h1>Hello</h1>"),
8787            (format!("{COMMENT}\nHello"), "Hello"),
8788        ];
8789
8790        for (input, expected) in fixtures {
8791            let mut parser = HtmlParser::with_options(DomStrategy::Light);
8792            let processed = parser
8793                .process_component_template(&input, None, None)
8794                .expect("leading-comment fixture should process");
8795            assert_eq!(processed, expected);
8796        }
8797    }
8798
8799    #[test]
8800    fn component_policy_wrapper_is_build_only_and_unwrapped_for_light() {
8801        // A policy wrapper is authored on a plain `<template>`; the policy is
8802        // applied to the host through generated CSS, so the wrapper must not
8803        // survive into a Light template.
8804        let html = r#"<template w-hydrate="lazy"><div>hi</div></template>"#;
8805        let mut shadow = HtmlParser::with_options(DomStrategy::Shadow);
8806        let shadow_built = shadow
8807            .build_component_templates(ComponentTemplateInput {
8808                tag_name: "my-comp",
8809                html,
8810                artifact_html: None,
8811                authored_html: None,
8812                css_content: None,
8813                artifact_needed: true,
8814            })
8815            .expect("shadow policy wrapper should compile");
8816        assert_eq!(
8817            shadow_built.ssr,
8818            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8819        );
8820        assert!(!shadow_built.artifact().contains("w-hydrate"));
8821        let mut light = HtmlParser::with_options(DomStrategy::Light);
8822        let light_built = light
8823            .build_component_templates(ComponentTemplateInput {
8824                tag_name: "my-comp",
8825                html,
8826                artifact_html: None,
8827                authored_html: None,
8828                css_content: None,
8829                artifact_needed: true,
8830            })
8831            .expect("light policy wrapper should compile");
8832        assert_eq!(light_built.ssr, "<div>hi</div>");
8833        assert_eq!(light_built.artifact(), "<div>hi</div>");
8834
8835        let mut implicit_shadow = HtmlParser::new();
8836        let shadow_built = implicit_shadow
8837            .build_component_templates(ComponentTemplateInput {
8838                tag_name: "my-comp",
8839                html,
8840                artifact_html: None,
8841                authored_html: None,
8842                css_content: None,
8843                artifact_needed: true,
8844            })
8845            .expect("default policy wrapper should compile as Shadow");
8846        assert_eq!(
8847            shadow_built.ssr,
8848            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8849        );
8850        assert!(!shadow_built.artifact().contains("w-hydrate"));
8851
8852        // An authored Shadow component keeps its wrapper; only the
8853        // compiler-owned policy attributes are stripped.
8854        let shadow_html =
8855            r#"<template shadowrootmode="open" w-hydrate="lazy"><div>hi</div></template>"#;
8856        let mut shadow = HtmlParser::new();
8857        let authored_shadow_built = shadow
8858            .build_component_templates(ComponentTemplateInput {
8859                tag_name: "my-comp",
8860                html: shadow_html,
8861                artifact_html: None,
8862                authored_html: None,
8863                css_content: None,
8864                artifact_needed: true,
8865            })
8866            .expect("shadow policy wrapper should compile");
8867        assert_eq!(
8868            authored_shadow_built.ssr,
8869            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8870        );
8871        assert!(!authored_shadow_built.artifact().contains("w-hydrate"));
8872    }
8873
8874    #[test]
8875    fn interaction_policy_marks_component_hosts_in_ssr_fragments() {
8876        let mut parser = HtmlParser::new();
8877        parser
8878            .component_registry
8879            .register_component(ComponentRegistration::new(
8880                "interaction-shell",
8881                r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
8882                None,
8883                true,
8884            ))
8885            .expect("component registration");
8886        parser
8887            .parse(
8888                "index.html",
8889                "<body><interaction-shell></interaction-shell></body>",
8890            )
8891            .expect("entry parse");
8892        let records = parser.into_fragment_records();
8893        let entry_html: String = records["index.html"]
8894            .fragments
8895            .iter()
8896            .filter_map(|fragment| match fragment.fragment.as_ref() {
8897                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8898                _ => None,
8899            })
8900            .collect();
8901        assert!(entry_html.contains("<interaction-shell data-webui-interaction"));
8902    }
8903
8904    #[test]
8905    fn combined_interaction_policy_marks_hosts_and_emits_render_css() {
8906        let mut parser = HtmlParser::new();
8907        parser
8908            .component_registry
8909            .register_component(ComponentRegistration::new(
8910                "interaction-panel",
8911                concat!(
8912                    r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
8913                    r#"w-hydrate="interaction"><button>Open</button></template>"#,
8914                ),
8915                None,
8916                true,
8917            ))
8918            .expect("component registration");
8919        parser
8920            .parse(
8921                "index.html",
8922                "<body><interaction-panel></interaction-panel></body>",
8923            )
8924            .expect("entry parse");
8925        assert!(parser
8926            .component_registry
8927            .render_policy_css(["interaction-panel"])
8928            .contains("content-visibility:auto"));
8929        let records = parser.into_fragment_records();
8930        let entry_html: String = records["index.html"]
8931            .fragments
8932            .iter()
8933            .filter_map(|fragment| match fragment.fragment.as_ref() {
8934                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8935                _ => None,
8936            })
8937            .collect();
8938        assert!(entry_html.contains("<interaction-panel data-webui-interaction"));
8939    }
8940
8941    // ── CSS-module adoption on dev-authored <template> wrappers ────────
8942    //
8943    // Regression guard: under `CssStrategy::Module`, the
8944    // `shadowrootadoptedstylesheets` attribute is the only wire between
8945    // the framework-emitted importmap/module-script and the shadow root.
8946    // When the developer supplies their own `<template>` wrapper (e.g.
8947    // to attach `@event` handlers), the framework preserves that wrapper
8948    // and appends the CSS-module adoption attribute when it is missing.
8949
8950    #[test]
8951    fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
8952        let mut parser = HtmlParser::new();
8953        let processed = parser
8954            .process_component_template(
8955                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
8956                None,
8957                Some("my-comp"),
8958            )
8959            .expect("process failed");
8960        assert!(
8961            processed.contains(r#"shadowrootmode="open""#),
8962            "dev-authored shadowrootmode must be preserved, got: {processed}"
8963        );
8964        assert!(
8965            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
8966            "module CSS should append adopted stylesheets, got: {processed}"
8967        );
8968    }
8969
8970    #[test]
8971    fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
8972        let mut parser = HtmlParser::new();
8973        let processed = parser
8974            .process_component_template(
8975                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
8976                None,
8977                Some("my-comp"),
8978            )
8979            .unwrap();
8980        assert!(
8981            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
8982            "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
8983        );
8984        assert_eq!(
8985            processed.matches("shadowrootadoptedstylesheets").count(),
8986            1,
8987            "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
8988        );
8989    }
8990
8991    #[test]
8992    fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
8993        for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
8994            let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
8995            let built = match parser.build_component_templates(ComponentTemplateInput {
8996                tag_name: "my-comp",
8997                html: r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
8998                artifact_html: None,
8999                authored_html: None,
9000                css_content: Some(":host { color: red; }"),
9001                artifact_needed: true,
9002            }) {
9003                Ok(built) => built,
9004                Err(err) => panic!(
9005                    "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
9006                ),
9007            };
9008            let artifact = built.artifact();
9009
9010            assert!(artifact.contains(r#"shadowrootmode="open""#));
9011            assert!(artifact.contains(r#"@click="{onClick()}""#));
9012            assert!(artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#));
9013            assert_eq!(artifact.matches("shadowrootadoptedstylesheets").count(), 1);
9014            assert!(!built.ssr.contains("@click"));
9015        }
9016    }
9017
9018    #[test]
9019    fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
9020        // Multi-specifier scenarios: the dev wants to adopt extra sheets
9021        // alongside their own component's module. Honored verbatim — the
9022        // framework's only job is to validate that *some*
9023        // `shadowrootadoptedstylesheets` is present.
9024        let mut parser = HtmlParser::new();
9025        let processed = parser
9026            .process_component_template(
9027                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
9028                None,
9029                Some("my-comp"),
9030            )
9031            .unwrap();
9032        assert!(
9033            processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
9034            "dev's multi-specifier list must be preserved verbatim, got: {processed}"
9035        );
9036    }
9037
9038    #[test]
9039    fn dev_template_no_adopted_specifier_does_not_validate() {
9040        // CssStrategy::Link or CssStrategy::Style pass `adopted_specifier=None`.
9041        // Dev's <template> must be preserved verbatim and the validation
9042        // must not fire.
9043        let mut parser = HtmlParser::new();
9044        let processed = parser
9045            .process_component_template(
9046                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
9047                None,
9048                None,
9049            )
9050            .unwrap();
9051        assert!(
9052            !processed.contains("shadowrootadoptedstylesheets"),
9053            "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
9054        );
9055    }
9056
9057    // test_signal_with_default_value — SKIPPED
9058    // The NodeJS `<f-signal value="testSignal">Default Text</f-signal>` feature
9059    // is not supported in the Rust parser. There is no `f-signal` element
9060    // handling in HtmlParser and no corresponding fragment type in
9061    // webui_protocol.
9062
9063    // test_estimated_buffer_size — SKIPPED
9064    // The NodeJS `estimatedBufferSize` field does not exist in the Rust
9065    // WebUIFragmentRecords / WebUIProtocol types. Buffer size estimation is
9066    // not part of the Rust parser output.
9067
9068    #[test]
9069    fn test_body_start_end_injection() {
9070        // Port of: 'should inject body_start and body_end signals around body content'
9071        // Verifies body_start appears immediately after <body> and body_end
9072        // appears immediately before </body> in a full HTML page.
9073        let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
9074        let (fragments, _) = parse_and_get_fragments(html);
9075
9076        assert_fragments!(
9077            fragments,
9078            [
9079                raw("<html><head>"),
9080                structural_matcher("head_start"),
9081                raw("<title>Test</title>"),
9082                structural_matcher("head_end"),
9083                raw("</head><body>"),
9084                structural_matcher("body_start"),
9085                raw("<div>Content</div><p>More</p>"),
9086                structural_matcher("body_end"),
9087                raw("</body></html>"),
9088            ]
9089        );
9090    }
9091
9092    #[test]
9093    fn test_body_preserves_static_attributes() {
9094        // The <body> tag's attributes must be carried through to the rendered
9095        // HTML, otherwise host frames (e.g. webui-press's `<body data-layout="…">`)
9096        // lose their styling hooks. Earlier versions of `process_body_with_signals`
9097        // emitted a bare `<body>` and dropped every attribute on the element.
9098        let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
9099        let (fragments, _) = parse_and_get_fragments(html);
9100
9101        assert_fragments!(
9102            fragments,
9103            [
9104                raw("<html><head>"),
9105                structural_matcher("head_start"),
9106                raw("<title>T</title>"),
9107                structural_matcher("head_end"),
9108                raw(r#"</head><body data-layout="doc" class="page">"#),
9109                structural_matcher("body_start"),
9110                raw("<p>x</p>"),
9111                structural_matcher("body_end"),
9112                raw("</body></html>"),
9113            ]
9114        );
9115    }
9116
9117    #[test]
9118    fn test_body_preserves_dynamic_attributes() {
9119        // Dynamic attributes on <body> (e.g. `data-layout="{{layout}}"`) must
9120        // emit attribute fragments rather than being inlined as literal text,
9121        // so the handler binds them to runtime state at render time.
9122        let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
9123        let (fragments, _) = parse_and_get_fragments(html);
9124
9125        assert_fragments!(
9126            fragments,
9127            [
9128                raw("<html><head>"),
9129                structural_matcher("head_start"),
9130                raw("<title>T</title>"),
9131                structural_matcher("head_end"),
9132                raw("</head><body"),
9133                attr("data-layout", "layout"),
9134                raw(">"),
9135                structural_matcher("body_start"),
9136                raw("<p>x</p>"),
9137                structural_matcher("body_end"),
9138                raw("</body></html>"),
9139            ]
9140        );
9141    }
9142
9143    #[test]
9144    fn test_head_start_signal_ordering_with_authored_script() {
9145        // `render_streaming` preflights on `head_start` before authored async
9146        // scripts in <head>, mirroring `body_start`. `head_start` must appear
9147        // immediately after the raw `<head>` opening fragment and before any
9148        // child content, including an authored `<script>`.
9149        let html = r#"<html><head><script async src="./analytics.js"></script></head><body></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(r#"<script async src="./analytics.js"></script>"#),
9158                structural_matcher("head_end"),
9159                raw("</head><body>"),
9160                structural_matcher("body_start"),
9161                structural_matcher("body_end"),
9162                raw("</body></html>"),
9163            ]
9164        );
9165    }
9166
9167    #[test]
9168    fn test_head_static_attributes_preserve_legacy_opening_bytes() {
9169        // Compatibility: origin/main emitted a literal `<head>` and ignored
9170        // authored head attributes. The structural signal must not alter that
9171        // ordinary protocol/output shape.
9172        let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
9173        let (fragments, _) = parse_and_get_fragments(html);
9174
9175        assert_fragments!(
9176            fragments,
9177            [
9178                raw("<html><head>"),
9179                structural_matcher("head_start"),
9180                raw("<title>T</title>"),
9181                structural_matcher("head_end"),
9182                raw("</head><body>"),
9183                structural_matcher("body_start"),
9184                structural_matcher("body_end"),
9185                raw("</body></html>"),
9186            ]
9187        );
9188    }
9189
9190    #[test]
9191    fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
9192        // Bound head attributes were also ignored by origin/main. In
9193        // particular, do not introduce an attribute fragment before
9194        // `head_start`, because ordinary rendering must remain byte-identical.
9195        let html =
9196            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
9197        let (fragments, _) = parse_and_get_fragments(html);
9198
9199        assert_fragments!(
9200            fragments,
9201            [
9202                raw("<html><head>"),
9203                structural_matcher("head_start"),
9204                raw("<title>T</title>"),
9205                structural_matcher("head_end"),
9206                raw("</head><body>"),
9207                structural_matcher("body_start"),
9208                structural_matcher("body_end"),
9209                raw("</body></html>"),
9210            ]
9211        );
9212    }
9213
9214    #[test]
9215    fn native_head_and_body_are_ascii_case_insensitive() {
9216        let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
9217        let (fragments, _) = parse_and_get_fragments(html);
9218
9219        assert_fragments!(
9220            fragments,
9221            [
9222                raw(r#"<html><HEAD data-theme="ignored">"#),
9223                structural_matcher("head_start"),
9224                raw("<title>T</title>"),
9225                structural_matcher("head_end"),
9226                raw("</HEAD><BODY"),
9227                attr("data-layout", "layout"),
9228                raw(">"),
9229                structural_matcher("body_start"),
9230                raw("<p>x</p>"),
9231                structural_matcher("body_end"),
9232                raw("</BODY></html>"),
9233            ]
9234        );
9235    }
9236
9237    // ── `<boundary>` / `<webui-hydrate>` tests (Progressive Streaming
9238    // Hydration) ───────────────────────────────────────────────────────────
9239
9240    #[test]
9241    fn entry_boundary_is_an_inline_start_end_tape() {
9242        let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
9243        let (fragments, records) = parse_and_get_fragments(html);
9244
9245        let boundary = match fragments[3].fragment.as_ref() {
9246            Some(Fragment::Boundary(boundary)) => boundary,
9247            other => panic!("expected typed boundary start, got {other:?}"),
9248        };
9249        assert_eq!(boundary.phase(), BoundaryPhase::Start);
9250        assert_eq!(boundary.declaration_id, 0);
9251        assert_eq!(boundary.owner_fragment_id, "index.html");
9252        assert_eq!(boundary.name, "counter-ready");
9253        assert_eq!(boundary.key, None);
9254        assert!(!boundary.may_repeat);
9255        // The body stays inline in the owner record, closed by an end marker,
9256        // so ordinary rendering never looks up a second record.
9257        assert_stream!(
9258            records,
9259            "index.html",
9260            [
9261                raw("<body>"),
9262                structural_matcher("body_start"),
9263                raw("<div>"),
9264                boundary_matcher("counter-ready", 0),
9265                raw("<my-counter></my-counter>"),
9266                boundary_end_matcher(0),
9267                raw("</div>"),
9268                structural_matcher("body_end"),
9269                raw("</body>"),
9270            ]
9271        );
9272        assert!(records["index.html"].contains_boundary);
9273        assert!(
9274            !records.contains_key("boundary-1"),
9275            "an inline tape must not create a body record"
9276        );
9277    }
9278
9279    #[test]
9280    fn boundary_names_are_scoped_per_owner_and_declaration_ids_are_protocol_wide() {
9281        let mut parser = HtmlParser::new();
9282        parser
9283            .parse(
9284                "first.html",
9285                r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
9286            )
9287            .expect("first entry parses");
9288        parser
9289            .parse(
9290                "second.html",
9291                r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
9292            )
9293            .expect("a second owner may reuse the name");
9294        let records = parser.into_fragment_records();
9295        let first = records["first.html"]
9296            .fragments
9297            .iter()
9298            .find_map(|fragment| match fragment.fragment.as_ref() {
9299                Some(Fragment::Boundary(boundary)) => Some(boundary),
9300                _ => None,
9301            })
9302            .expect("first boundary");
9303        let second = records["second.html"]
9304            .fragments
9305            .iter()
9306            .find_map(|fragment| match fragment.fragment.as_ref() {
9307                Some(Fragment::Boundary(boundary)) => Some(boundary),
9308                _ => None,
9309            })
9310            .expect("second boundary");
9311        assert_eq!((first.declaration_id, first.name.as_str()), (0, "a"));
9312        assert_eq!((second.declaration_id, second.name.as_str()), (1, "a"));
9313    }
9314
9315    #[test]
9316    fn boundary_missing_name_errors() {
9317        for html in [
9318            "<body><boundary><p>x</p></boundary></body>",
9319            r#"<body><boundary name=""><p>x</p></boundary></body>"#,
9320            r#"<body><boundary name="   "><p>x</p></boundary></body>"#,
9321        ] {
9322            let mut parser = HtmlParser::new();
9323            let err = parser
9324                .parse("index.html", html)
9325                .expect_err("a boundary without a non-empty name must error");
9326            let ParserError::Template(diag) = err else {
9327                panic!("expected ParserError::Template, got {err:?}");
9328            };
9329            assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
9330        }
9331    }
9332
9333    #[test]
9334    fn boundary_dynamic_name_errors() {
9335        let mut parser = HtmlParser::new();
9336        let err = parser
9337            .parse(
9338                "index.html",
9339                r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
9340            )
9341            .expect_err("a dynamic boundary name must error");
9342        let ParserError::Template(diag) = err else {
9343            panic!("expected ParserError::Template, got {err:?}");
9344        };
9345        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
9346    }
9347
9348    #[test]
9349    fn boundary_names_are_free_form_and_live_on_typed_fragments() {
9350        let mut parser = HtmlParser::new();
9351        let html = concat!(
9352            "<body>",
9353            r#"<boundary name="above the fold"><p>1</p></boundary>"#,
9354            r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
9355            r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
9356            "</body>",
9357        );
9358        parser
9359            .parse("index.html", html)
9360            .expect("free-form boundary names must be accepted");
9361        let records = parser.into_fragment_records();
9362        let names: Vec<&str> = records["index.html"]
9363            .fragments
9364            .iter()
9365            .filter_map(|fragment| match fragment.fragment.as_ref() {
9366                Some(Fragment::Boundary(boundary)) if boundary.phase() == BoundaryPhase::Start => {
9367                    Some(boundary.name.as_str())
9368                }
9369                _ => None,
9370            })
9371            .collect();
9372        assert_eq!(names, ["above the fold", "feed/items #2", "ダッシュボード"]);
9373    }
9374
9375    #[test]
9376    fn module_entry_srcs_records_only_preloadable_critical_entries() {
9377        let mut parser = HtmlParser::new();
9378        parser
9379            .component_registry
9380            .register_component(ComponentRegistration::new(
9381                "weather-island",
9382                r#"<script type="module" src="/nested-weather.js"></script>"#,
9383                None,
9384                true,
9385            ))
9386            .expect("register");
9387        let html = concat!(
9388            "<head>",
9389            // Recorded: a static, module-typed, non-boundary entry.
9390            r#"<script type="module" async src="/index.js"></script>"#,
9391            // Recorded: mixed case on the tag, and a padded/mixed-case `type`
9392            // value, still identify a module. (Attribute *names* are matched
9393            // case-sensitively here, as everywhere else in this parser.)
9394            r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
9395            // Skipped: classic scripts have no ES module graph to preload.
9396            r#"<script src="/legacy.js"></script>"#,
9397            // Skipped: no `src` means nothing to resolve.
9398            r#"<script type="module">import "./x.js";</script>"#,
9399            // Skipped: a per-request binding matches no build artifact.
9400            r#"<script type="module" src="/{{bundle}}.js"></script>"#,
9401            "</head>",
9402            "<body>",
9403            // Skipped: an island loader is deferred on purpose.
9404            r#"<boundary name="weather">"#,
9405            r#"<script type="module" async src="/weather-panel.js"></script>"#,
9406            "<weather-island></weather-island>",
9407            "</boundary>",
9408            // Recorded once: a bottom-of-body entry is still critical, and a
9409            // repeat of an already-seen src must not duplicate a hint.
9410            r#"<script type="module" src="/index.js"></script>"#,
9411            "</body>",
9412        );
9413        parser.parse("index.html", html).expect("parse");
9414
9415        assert_eq!(
9416            parser.module_entry_srcs(),
9417            ["/index.js", "/late.js"],
9418            "only static module entries outside boundaries are preloadable"
9419        );
9420    }
9421
9422    #[test]
9423    fn module_entry_srcs_reset_between_top_level_parses() {
9424        let mut parser = HtmlParser::new();
9425        parser
9426            .parse(
9427                "a.html",
9428                r#"<head><script type="module" src="/a.js"></script></head>"#,
9429            )
9430            .expect("parse a");
9431        assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
9432
9433        parser
9434            .parse(
9435                "b.html",
9436                r#"<head><script type="module" src="/b.js"></script></head>"#,
9437            )
9438            .expect("parse b");
9439        assert_eq!(
9440            parser.module_entry_srcs(),
9441            ["/b.js"],
9442            "each entry owns its own critical modules"
9443        );
9444    }
9445
9446    #[test]
9447    fn module_entry_srcs_preserve_low_priority_script_delivery() {
9448        let mut parser = HtmlParser::new();
9449        let html = concat!(
9450            "<head>",
9451            r#"<script type="module" async src="/streaming.js"></script>"#,
9452            r#"<script type="module" src="/low-head.js" fetchpriority="LOW"></script>"#,
9453            "</head><body>",
9454            r#"<script type="module" src="/application.js" fetchpriority="low"></script>"#,
9455            r#"<script type="module" src="/low-cased.js" FETCHPRIORITY="LoW"></script>"#,
9456            r#"<script type="module" src="/critical.js" fetchpriority="high"></script>"#,
9457            r#"<script type="module" src="/ordinary.js"></script>"#,
9458            "</body>",
9459        );
9460        parser.parse("index.html", html).expect("parse");
9461        assert_eq!(
9462            parser.module_entry_srcs(),
9463            ["/streaming.js", "/critical.js", "/ordinary.js"],
9464            "low-priority application chunks must not be pulled into head preloads"
9465        );
9466        let records = parser.into_fragment_records();
9467        let raw = records["index.html"]
9468            .fragments
9469            .iter()
9470            .filter_map(|fragment| match fragment.fragment.as_ref() {
9471                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
9472                _ => None,
9473            })
9474            .collect::<String>();
9475        assert!(
9476            raw.contains(r#"src="/application.js" fetchpriority="low""#),
9477            "excluding preloads must preserve the authored script"
9478        );
9479    }
9480
9481    #[test]
9482    fn boundary_duplicate_name_errors() {
9483        let mut parser = HtmlParser::new();
9484        let html = concat!(
9485            "<body>",
9486            r#"<boundary name="dup"><p>1</p></boundary>"#,
9487            r#"<boundary name="dup"><p>2</p></boundary>"#,
9488            "</body>",
9489        );
9490        let err = parser
9491            .parse("index.html", html)
9492            .expect_err("a duplicate boundary name must error");
9493        let ParserError::Template(diag) = err else {
9494            panic!("expected ParserError::Template, got {err:?}");
9495        };
9496        assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
9497    }
9498
9499    #[test]
9500    fn boundary_nested_boundary_errors() {
9501        let mut parser = HtmlParser::new();
9502        let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
9503        let err = parser
9504            .parse("index.html", html)
9505            .expect_err("a nested boundary must error");
9506        let ParserError::Template(diag) = err else {
9507            panic!("expected ParserError::Template, got {err:?}");
9508        };
9509        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
9510    }
9511
9512    #[test]
9513    fn boundary_transitively_nested_through_component_errors() {
9514        let mut parser = HtmlParser::new();
9515        parser
9516            .component_registry
9517            .register_component(ComponentRegistration::new(
9518                "inner-panel",
9519                r#"<boundary name="inner"><p>x</p></boundary>"#,
9520                None,
9521                true,
9522            ))
9523            .expect("register");
9524        let err = parser
9525            .parse(
9526                "index.html",
9527                r#"<body><boundary name="outer"><inner-panel></inner-panel></boundary></body>"#,
9528            )
9529            .expect_err("transitively nested boundaries must error");
9530        let ParserError::Template(diag) = err else {
9531            panic!("expected ParserError::Template, got {err:?}");
9532        };
9533        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
9534        assert!(diag
9535            .help_text()
9536            .is_some_and(|help| help.contains("runtime branches")));
9537    }
9538
9539    #[test]
9540    fn boundary_inside_if_preserves_false_branch_structure() {
9541        let mut parser = HtmlParser::new();
9542        let html =
9543            r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
9544        parser.parse("index.html", html).expect("parse");
9545        let records = parser.into_fragment_records();
9546        let if_fragment_id = records["index.html"]
9547            .fragments
9548            .iter()
9549            .find_map(|fragment| match fragment.fragment.as_ref() {
9550                Some(Fragment::IfCond(if_cond)) => Some(if_cond.fragment_id.as_str()),
9551                _ => None,
9552            })
9553            .expect("if reference");
9554        let boundary = records[if_fragment_id]
9555            .fragments
9556            .iter()
9557            .find_map(|fragment| match fragment.fragment.as_ref() {
9558                Some(Fragment::Boundary(boundary)) => Some(boundary),
9559                _ => None,
9560            })
9561            .expect("boundary remains behind the runtime if");
9562        assert_eq!(boundary.owner_fragment_id, "index.html");
9563        assert!(!boundary.may_repeat);
9564        assert!(records["index.html"].contains_boundary);
9565        assert!(records[if_fragment_id].contains_boundary);
9566        assert_stream!(
9567            records,
9568            if_fragment_id,
9569            [
9570                boundary_matcher("x", 0),
9571                raw("<p>x</p>"),
9572                boundary_end_matcher(0),
9573            ]
9574        );
9575    }
9576
9577    #[test]
9578    fn boundary_directly_inside_for_is_rejected() {
9579        let mut parser = HtmlParser::new();
9580        let err = parser
9581            .parse(
9582                "index.html",
9583                r#"<body><for each="item in items"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></for></body>"#,
9584            )
9585            .expect_err("a boundary inside a repeat must error");
9586        let ParserError::Template(diag) = err else {
9587            panic!("expected ParserError::Template, got {err:?}");
9588        };
9589        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9590        assert!(
9591            diag.help_text()
9592                .is_some_and(|help| help.contains("wrap the whole <for>")),
9593            "{diag}"
9594        );
9595    }
9596
9597    #[test]
9598    fn boundary_under_if_inside_for_is_rejected() {
9599        let mut parser = HtmlParser::new();
9600        let err = parser
9601            .parse(
9602                "index.html",
9603                r#"<body><for each="item in items"><if condition="item.ready"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></if></for></body>"#,
9604            )
9605            .expect_err("a boundary behind a runtime branch in a repeat must error");
9606        let ParserError::Template(diag) = err else {
9607            panic!("expected ParserError::Template, got {err:?}");
9608        };
9609        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9610    }
9611
9612    #[test]
9613    fn component_boundary_used_inside_for_is_rejected() {
9614        let mut parser = HtmlParser::new();
9615        parser
9616            .component_registry
9617            .register_component(ComponentRegistration::new(
9618                "row-item",
9619                r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9620                None,
9621                true,
9622            ))
9623            .expect("register");
9624        let err = parser
9625            .parse(
9626                "index.html",
9627                r#"<body><for each="item in items"><row-item></row-item></for></body>"#,
9628            )
9629            .expect_err("a component-local boundary reached from a repeat must error");
9630        let ParserError::Template(diag) = err else {
9631            panic!("expected ParserError::Template, got {err:?}");
9632        };
9633        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9634        // The diagnostic names both ends of the chain: the repeat that would
9635        // execute it and the template that declares it.
9636        assert!(diag.to_string().contains("row-ready"), "{diag}");
9637        assert_eq!(diag.component_name(), Some("index.html"));
9638        assert!(diag.to_string().contains("item in items"), "{diag}");
9639    }
9640
9641    #[test]
9642    fn transitive_repeat_boundary_diagnostic_uses_lowest_repeat_site() {
9643        for _ in 0..20 {
9644            let mut parser = HtmlParser::new();
9645            for (tag, template) in [
9646                (
9647                    "left-list",
9648                    r#"<for each="a in xs"><row-card></row-card></for>"#,
9649                ),
9650                (
9651                    "right-list",
9652                    r#"<for each="b in ys"><row-card></row-card></for>"#,
9653                ),
9654                (
9655                    "row-card",
9656                    r#"<boundary name="row-ready"><p>row</p></boundary>"#,
9657                ),
9658            ] {
9659                parser
9660                    .component_registry
9661                    .register_component(ComponentRegistration::new(tag, template, None, true))
9662                    .expect("register");
9663            }
9664            let err = parser
9665                .parse(
9666                    "index.html",
9667                    r#"<body><left-list></left-list><right-list></right-list></body>"#,
9668                )
9669                .expect_err("both repeats reach the same boundary declaration");
9670            let ParserError::Template(diag) = err else {
9671                panic!("expected ParserError::Template, got {err:?}");
9672            };
9673            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9674            assert_eq!(diag.component_name(), Some("left-list"));
9675            assert!(diag.to_string().contains("a in xs"), "{diag}");
9676        }
9677    }
9678
9679    #[test]
9680    fn route_boundary_reached_from_for_is_rejected() {
9681        let mut parser = HtmlParser::with_options(DomStrategy::Light);
9682        parser
9683            .component_registry
9684            .register_component(ComponentRegistration::new(
9685                "row-page",
9686                r#"<boundary name="row-ready"><p>row</p></boundary>"#,
9687                None,
9688                true,
9689            ))
9690            .expect("register");
9691        let err = parser
9692            .parse(
9693                "index.html",
9694                r#"<body><for each="item in items"><route path="/" component="row-page"></route></for></body>"#,
9695            )
9696            .expect_err("a route-mounted boundary inside a repeat must error");
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    }
9702
9703    #[test]
9704    fn outlet_mounted_boundary_reached_from_for_is_rejected() {
9705        // The child route renders at the parent component's <outlet />, which
9706        // the repeat body reaches through <shell-page>. That edge is a runtime
9707        // mount with no lexical nesting, so only the mount analysis catches it.
9708        let mut parser = HtmlParser::with_options(DomStrategy::Light);
9709        for (tag, template) in [
9710            (
9711                "shell-page",
9712                r#"<div><for each="item in items"><outlet></outlet></for></div>"#,
9713            ),
9714            (
9715                "child-page",
9716                r#"<boundary name="child-ready"><p>child</p></boundary>"#,
9717            ),
9718        ] {
9719            parser
9720                .component_registry
9721                .register_component(ComponentRegistration::new(tag, template, None, true))
9722                .expect("register");
9723        }
9724        let err = parser
9725            .parse(
9726                "index.html",
9727                r#"<body><route path="/" component="shell-page"><route path="child" component="child-page"></route></route></body>"#,
9728            )
9729            .expect_err("an outlet-mounted boundary inside a repeat must error");
9730        let ParserError::Template(diag) = err else {
9731            panic!("expected ParserError::Template, got {err:?}");
9732        };
9733        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9734        assert!(diag.to_string().contains("child-ready"), "{diag}");
9735    }
9736
9737    #[test]
9738    fn for_inside_one_boundary_is_allowed_and_stays_atomic() {
9739        // The whole finite list is one atomic checkpoint, so the repeat body
9740        // record must not be marked as carrying a boundary of its own.
9741        let mut parser = HtmlParser::new();
9742        parser
9743            .parse(
9744                "index.html",
9745                r#"<body><boundary name="feed"><for each="item in items"><p>{{item.label}}</p></for></boundary></body>"#,
9746            )
9747            .expect("a repeat inside one boundary parses");
9748        let records = parser.into_fragment_records();
9749        assert!(records["index.html"].contains_boundary);
9750        assert!(
9751            !records["for-1"].contains_boundary,
9752            "the repeat body itself declares no boundary"
9753        );
9754        let boundary = records["index.html"]
9755            .fragments
9756            .iter()
9757            .find_map(|fragment| match fragment.fragment.as_ref() {
9758                Some(Fragment::Boundary(boundary)) => Some(boundary),
9759                _ => None,
9760            })
9761            .expect("boundary");
9762        assert!(!boundary.may_repeat);
9763    }
9764
9765    #[test]
9766    fn boundary_after_for_is_allowed() {
9767        let mut parser = HtmlParser::new();
9768        parser
9769            .parse(
9770                "index.html",
9771                r#"<body><for each="item in items"><p>{{item.label}}</p></for><boundary name="tail"><p>tail</p></boundary></body>"#,
9772            )
9773            .expect("a boundary following a repeat parses");
9774        let records = parser.into_fragment_records();
9775        assert!(records["index.html"].contains_boundary);
9776        assert!(!records["for-1"].contains_boundary);
9777    }
9778
9779    #[test]
9780    fn boundary_free_for_still_renders_without_boundary_metadata() {
9781        let mut parser = HtmlParser::new();
9782        parser
9783            .parse(
9784                "index.html",
9785                r#"<body><for each="item in items"><p>{{item.label}}</p></for></body>"#,
9786            )
9787            .expect("an ordinary repeat parses");
9788        let records = parser.into_fragment_records();
9789        assert!(!records["index.html"].contains_boundary);
9790        assert!(!records["for-1"].contains_boundary);
9791    }
9792
9793    #[test]
9794    fn empty_boundary_key_is_still_rejected() {
9795        let mut parser = HtmlParser::new();
9796        let err = parser
9797            .parse(
9798                "invalid.html",
9799                r#"<body><boundary name="x" key=""><p>x</p></boundary></body>"#,
9800            )
9801            .expect_err("an empty key must error");
9802        let ParserError::Template(diag) = err else {
9803            panic!("expected ParserError::Template, got {err:?}");
9804        };
9805        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_KEY));
9806    }
9807
9808    #[test]
9809    fn boundary_inside_reusable_component_is_component_local() {
9810        let mut parser = HtmlParser::new();
9811        parser
9812            .component_registry
9813            .register_component(ComponentRegistration::new(
9814                "my-widget",
9815                r#"<boundary name="x"><p>x</p></boundary>"#,
9816                None,
9817                true,
9818            ))
9819            .expect("register");
9820
9821        parser
9822            .parse(
9823                "index.html",
9824                r#"<body><my-widget></my-widget><boundary name="x"><p>entry</p></boundary></body>"#,
9825            )
9826            .expect("component-local boundary parses");
9827        let records = parser.into_fragment_records();
9828        let boundary = records["my-widget"]
9829            .fragments
9830            .iter()
9831            .find_map(|fragment| match fragment.fragment.as_ref() {
9832                Some(Fragment::Boundary(boundary)) => Some(boundary),
9833                _ => None,
9834            })
9835            .expect("component boundary");
9836        assert_eq!(boundary.owner_fragment_id, "my-widget");
9837        assert_eq!(boundary.name, "x");
9838        assert!(records["my-widget"].contains_boundary);
9839        assert!(records["index.html"].contains_boundary);
9840        let owners: HashSet<&str> = records
9841            .values()
9842            .flat_map(|list| list.fragments.iter())
9843            .filter_map(|fragment| match fragment.fragment.as_ref() {
9844                Some(Fragment::Boundary(boundary)) if boundary.name == "x" => {
9845                    Some(boundary.owner_fragment_id.as_str())
9846                }
9847                _ => None,
9848            })
9849            .collect();
9850        assert_eq!(owners, HashSet::from(["index.html", "my-widget"]));
9851    }
9852
9853    #[test]
9854    fn component_boundary_repeated_from_static_callsites_requires_a_key() {
9855        // `<for>` can no longer produce multiple occurrences, but two static
9856        // callsites of the same boundary-bearing component still can, so the
9857        // declaration keeps its key requirement.
9858        let mut parser = HtmlParser::new();
9859        parser
9860            .component_registry
9861            .register_component(ComponentRegistration::new(
9862                "row-item",
9863                r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9864                None,
9865                true,
9866            ))
9867            .expect("register");
9868        let err = parser
9869            .parse(
9870                "index.html",
9871                r#"<body><row-item></row-item><row-item></row-item></body>"#,
9872            )
9873            .expect_err("a repeated declaration without a key must error");
9874        let ParserError::Template(diag) = err else {
9875            panic!("expected ParserError::Template, got {err:?}");
9876        };
9877        assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_KEY));
9878        assert_eq!(diag.component_name(), Some("row-item"));
9879
9880        let mut parser = HtmlParser::new();
9881        parser
9882            .component_registry
9883            .register_component(ComponentRegistration::new(
9884                "row-item",
9885                r#"<boundary name="row-ready" key="{{rowId}}"><span>{{label}}</span></boundary>"#,
9886                None,
9887                true,
9888            ))
9889            .expect("register");
9890        parser
9891            .parse(
9892                "index.html",
9893                r#"<body><row-item row-id="a"></row-item><row-item row-id="b"></row-item></body>"#,
9894            )
9895            .expect("a keyed repeated declaration parses");
9896        let records = parser.into_fragment_records();
9897        let boundary = records["row-item"]
9898            .fragments
9899            .iter()
9900            .find_map(|fragment| match fragment.fragment.as_ref() {
9901                Some(Fragment::Boundary(boundary)) => Some(boundary),
9902                _ => None,
9903            })
9904            .expect("component boundary");
9905        assert!(boundary.may_repeat);
9906        assert_eq!(boundary.key.as_deref(), Some("{{rowId}}"));
9907    }
9908
9909    #[test]
9910    fn component_boundary_once_per_independent_entry_is_not_repeatable() {
9911        let mut parser = HtmlParser::new();
9912        parser
9913            .component_registry
9914            .register_component(ComponentRegistration::new(
9915                "shared-panel",
9916                r#"<boundary name="ready"><span>x</span></boundary>"#,
9917                None,
9918                true,
9919            ))
9920            .expect("register");
9921        for entry in ["first.html", "second.html"] {
9922            parser
9923                .parse(entry, "<body><shared-panel></shared-panel></body>")
9924                .expect("entry parses");
9925        }
9926        let records = parser.into_fragment_records();
9927        let boundary = records["shared-panel"]
9928            .fragments
9929            .iter()
9930            .find_map(|fragment| match fragment.fragment.as_ref() {
9931                Some(Fragment::Boundary(boundary)) => Some(boundary),
9932                _ => None,
9933            })
9934            .expect("component boundary");
9935        assert!(!boundary.may_repeat);
9936    }
9937
9938    #[test]
9939    fn boundary_inside_component_host_content_errors() {
9940        let mut parser = HtmlParser::new();
9941        parser
9942            .component_registry
9943            .register_component(ComponentRegistration::new(
9944                "my-widget",
9945                "<div>content</div>",
9946                None,
9947                true,
9948            ))
9949            .expect("register");
9950
9951        let err = parser
9952            .parse(
9953                "index.html",
9954                r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
9955            )
9956            .expect_err("a boundary inside component host content must error");
9957        let ParserError::Template(diag) = err else {
9958            panic!("expected ParserError::Template, got {err:?}");
9959        };
9960        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
9961        assert!(
9962            diag.to_string().contains("component host content"),
9963            "{diag}"
9964        );
9965    }
9966
9967    #[test]
9968    fn boundary_inside_raw_or_inert_html_context_errors() {
9969        for (element, expected_scope) in [
9970            ("textarea", "<textarea> text content"),
9971            ("title", "<title> text content"),
9972            ("script", "<script> raw-text content"),
9973            ("xmp", "<xmp> raw-text content"),
9974            ("iframe", "<iframe> raw-text content"),
9975            ("noembed", "<noembed> raw-text content"),
9976            ("noframes", "<noframes> raw-text content"),
9977            ("noscript", "<noscript> inert content"),
9978            ("plaintext", "<plaintext> text content"),
9979            ("template", "<template> inert content"),
9980        ] {
9981            let mut parser = HtmlParser::new();
9982            let html = format!(
9983                r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
9984            );
9985            let err = parser
9986                .parse("index.html", &html)
9987                .expect_err("a boundary in raw or inert content must error");
9988            let ParserError::Template(diag) = err else {
9989                panic!("expected ParserError::Template, got {err:?}");
9990            };
9991            assert_eq!(
9992                diag.error_code(),
9993                Some(codes::BOUNDARY_CROSSES_SCOPE),
9994                "unexpected code for <{element}>"
9995            );
9996            assert!(
9997                diag.to_string().contains(expected_scope),
9998                "unexpected scope for <{element}>: {diag}"
9999            );
10000        }
10001    }
10002
10003    #[test]
10004    fn route_contained_boundary_has_runtime_content_fragment() {
10005        let mut parser = HtmlParser::with_options(DomStrategy::Light);
10006        parser
10007            .component_registry
10008            .register_component(ComponentRegistration::new(
10009                "home-page",
10010                "<h1>Home</h1>",
10011                None,
10012                true,
10013            ))
10014            .expect("register");
10015        parser
10016            .parse(
10017                "index.html",
10018                r#"<route path="/" component="home-page"><boundary name="route-ready"><aside>Ready</aside></boundary></route>"#,
10019            )
10020            .expect("route-contained boundary parses");
10021        let records = parser.into_fragment_records();
10022        assert!(records["index.html"].contains_boundary);
10023        let route = records["index.html"]
10024            .fragments
10025            .iter()
10026            .find_map(|fragment| match fragment.fragment.as_ref() {
10027                Some(Fragment::Route(route)) => Some(route),
10028                _ => None,
10029            })
10030            .expect("route");
10031        assert!(!route.content_fragment_id.is_empty());
10032        let boundary = records[&route.content_fragment_id]
10033            .fragments
10034            .iter()
10035            .find_map(|fragment| match fragment.fragment.as_ref() {
10036                Some(Fragment::Boundary(boundary)) => Some(boundary),
10037                _ => None,
10038            })
10039            .expect("route boundary");
10040        assert_eq!(boundary.owner_fragment_id, "index.html");
10041        assert_eq!(boundary.name, "route-ready");
10042        assert_stream!(
10043            records,
10044            route.content_fragment_id.as_str(),
10045            [
10046                boundary_matcher("route-ready", 0),
10047                raw("<aside>Ready</aside>"),
10048                boundary_end_matcher(0),
10049            ]
10050        );
10051    }
10052
10053    #[test]
10054    fn route_boundary_nested_in_ignored_markup_errors() {
10055        let mut parser = HtmlParser::new();
10056        let err = parser
10057            .parse(
10058                "index.html",
10059                r#"<route path="/" component="home-page"><div><boundary name="late"><p>x</p></boundary></div></route>"#,
10060            )
10061            .expect_err("route boundaries must be direct children");
10062        let ParserError::Template(diag) = err else {
10063            panic!("expected ParserError::Template, got {err:?}");
10064        };
10065        assert_eq!(
10066            diag.error_code(),
10067            Some(codes::INVALID_ROUTE_BOUNDARY_PLACEMENT)
10068        );
10069    }
10070
10071    #[test]
10072    fn boundary_must_be_inside_an_open_body() {
10073        for html in [
10074            r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
10075            r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
10076        ] {
10077            let mut parser = HtmlParser::new();
10078            let err = parser
10079                .parse("index.html", html)
10080                .expect_err("a boundary outside body must error");
10081            let ParserError::Template(diag) = err else {
10082                panic!("expected ParserError::Template, got {err:?}");
10083            };
10084            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
10085            assert_eq!(
10086                diag.help_text(),
10087                Some("move the <boundary> between the opening <body> and its matching </body>")
10088            );
10089        }
10090
10091        let mut parser = HtmlParser::new();
10092        parser
10093            .parse(
10094                "index.html",
10095                r#"<BODY><boundary name="inside"></boundary></BODY>"#,
10096            )
10097            .expect("mixed-case native body keeps the boundary inside an open body");
10098    }
10099
10100    #[test]
10101    fn boundary_inside_foster_parenting_context_errors() {
10102        // Each of these insertion modes moves an unknown element (the
10103        // generated <webui-hydrate> sentinel) out of the subtree, which would
10104        // split it from its payload script and halt hydration at runtime.
10105        for html in [
10106            r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
10107            r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
10108            r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
10109            r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
10110            r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
10111            r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
10112            r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
10113            r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
10114            // Mixed case must be rejected identically: HTML tag names are
10115            // case-insensitive, so the browser foster-parents these too.
10116            r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
10117        ] {
10118            let mut parser = HtmlParser::new();
10119            let err = parser
10120                .parse("index.html", html)
10121                .expect_err("a boundary in a foster-parenting context must error");
10122            let ParserError::Template(diag) = err else {
10123                panic!("expected ParserError::Template, got {err:?}");
10124            };
10125            assert_eq!(
10126                diag.error_code(),
10127                Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
10128                "unexpected code for {html}"
10129            );
10130            assert!(
10131                diag.help_text()
10132                    .is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
10133                "help should suggest a safe placement for {html}"
10134            );
10135        }
10136    }
10137
10138    #[test]
10139    fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
10140        // <td>/<th>/<caption> switch back to "in body" rules, so an unknown
10141        // element is *not* foster-parented and the boundary is safe even
10142        // though <table>/<tr> ancestors are hostile.
10143        for html in [
10144            r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
10145            r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
10146            r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
10147            // Wrapping the whole table is the other documented fix.
10148            r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
10149            // A <td> barrier resets the depth, an inner table raises it again,
10150            // and closing that inner table must restore the barrier's zero —
10151            // not the outer table's non-zero depth.
10152            concat!(
10153                "<body><table><tbody><tr><td>",
10154                "<table><tbody><tr><td>inner</td></tr></tbody></table>",
10155                r#"<boundary name="after-inner"><p>x</p></boundary>"#,
10156                "</td></tr></tbody></table></body>",
10157            ),
10158            // Void and self-closing elements must not leak restore ops.
10159            r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
10160        ] {
10161            let mut parser = HtmlParser::new();
10162            parser
10163                .parse("index.html", html)
10164                .unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
10165        }
10166    }
10167
10168    #[test]
10169    fn foster_context_depth_is_restored_after_leaving_a_table() {
10170        // Regression guard for the save/restore op: a boundary *after* a table
10171        // must not inherit the table's hostile depth.
10172        let mut parser = HtmlParser::new();
10173        parser
10174            .parse(
10175                "index.html",
10176                concat!(
10177                    "<body>",
10178                    "<table><tbody><tr><td>x</td></tr></tbody></table>",
10179                    r#"<boundary name="after-table"><p>y</p></boundary>"#,
10180                    "</body>",
10181                ),
10182            )
10183            .expect("a boundary after a closed table must be accepted");
10184    }
10185
10186    #[test]
10187    fn authored_raw_bindings_cannot_forge_structural_signals() {
10188        let html = concat!(
10189            "<html><head></head><body>",
10190            "{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
10191            "{{{boundary_start:0}}}{{{boundary_end:0}}}",
10192            "{{{streaming_root:forged}}}",
10193            "</body></html>",
10194        );
10195        let (fragments, _) = parse_and_get_fragments(html);
10196        let raw_signals: Vec<&str> = fragments
10197            .iter()
10198            .filter_map(|fragment| match fragment.fragment.as_ref() {
10199                Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
10200                _ => None,
10201            })
10202            .collect();
10203
10204        assert_eq!(
10205            raw_signals,
10206            [
10207                "}}}webui:head_start",
10208                "}}}webui:head_end",
10209                "}}}webui:body_start",
10210                "head_start",
10211                "head_end",
10212                "body_start",
10213                "body_end",
10214                "boundary_start:0",
10215                "boundary_end:0",
10216                "streaming_root:forged",
10217                "}}}webui:body_end",
10218            ]
10219        );
10220    }
10221
10222    #[test]
10223    fn authored_webui_hydrate_errors_at_top_level() {
10224        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
10225            let mut parser = HtmlParser::new();
10226            let html = format!("<div><{tag}></{tag}></div>");
10227            let err = parser
10228                .parse("index.html", &html)
10229                .expect_err("an authored <webui-hydrate> must error in any ASCII casing");
10230            let ParserError::Template(diag) = err else {
10231                panic!("expected ParserError::Template, got {err:?}");
10232            };
10233            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
10234        }
10235    }
10236
10237    #[test]
10238    fn authored_webui_hydrate_errors_inside_route() {
10239        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
10240            let mut parser = HtmlParser::new();
10241            let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
10242            let err = parser
10243                .parse("index.html", &html)
10244                .expect_err("an authored <webui-hydrate> inside <route> must error");
10245            let ParserError::Template(diag) = err else {
10246                panic!("expected ParserError::Template, got {err:?}");
10247            };
10248            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
10249        }
10250    }
10251
10252    #[test]
10253    fn boundary_directive_remains_case_sensitive() {
10254        let (fragments, _) =
10255            parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
10256
10257        assert_fragments!(
10258            fragments,
10259            [raw(
10260                r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
10261            ),]
10262        );
10263    }
10264
10265    #[test]
10266    fn templates_without_webui_boundary_are_unaffected() {
10267        // Regression: ordinary templates with no `<boundary>` directive
10268        // must parse exactly as before — no boundary signals, no behavior
10269        // change for the non-streaming path.
10270        let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
10271        let (fragments, _) = parse_and_get_fragments(html);
10272
10273        assert_fragments!(
10274            fragments,
10275            [
10276                raw("<div><my-counter"),
10277                attr("count", "count"),
10278                raw("></my-counter></div>"),
10279            ]
10280        );
10281    }
10282
10283    #[test]
10284    fn test_fail_with_invalid_markup() {
10285        // Port of: 'should fail with invalid markup'
10286        let mut parser = HtmlParser::new();
10287        let result = parser.parse("index.html", "<div><span></div></span>");
10288
10289        assert!(matches!(result, Err(ParserError::Template(diag)) if
10290            diag.to_string().contains("unclosed <span> tag")
10291        ));
10292    }
10293
10294    #[test]
10295    fn test_complex_raw_text_page() {
10296        // Port of: 'should process a complex raw text page with DOCTYPE,
10297        // meta tags, styles, and scripts'
10298        let html = concat!(
10299            "<!DOCTYPE html>",
10300            "<html lang=\"en\">",
10301            "<head>",
10302            "<meta charset=\"utf-8\">",
10303            "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
10304            "<title>Complex Page</title>",
10305            "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
10306            "<link rel=\"stylesheet\" href=\"styles.css\">",
10307            "</head>",
10308            "<body>",
10309            "<h1>Hello World</h1>",
10310            "<script type=\"module\" src=\"./app.js\"></script>",
10311            "</body>",
10312            "</html>",
10313        );
10314        let (fragments, _) = parse_and_get_fragments(html);
10315
10316        // Should have: raw(DOCTYPE+<head>), head_start, raw(head content),
10317        // head_end, raw(</head><body>), body_start, raw(body content),
10318        // body_end, raw(</body></html>)
10319        assert!(
10320            fragments.len() >= 9,
10321            "Expected at least 9 fragments, got {}",
10322            fragments.len()
10323        );
10324
10325        // First fragment: DOCTYPE through the opening <head> tag
10326        assert!(
10327            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10328                raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
10329            "First fragment should contain DOCTYPE and end with <head>, got: {:?}",
10330            fragments[0]
10331        );
10332
10333        // head_start signal
10334        assert!(
10335            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
10336                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
10337            "Second fragment should be head_start signal"
10338        );
10339
10340        // Head content (meta, title, style, link)
10341        assert!(
10342            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10343                raw.value.contains("<meta charset=\"utf-8\">") &&
10344                raw.value.contains("<meta name=\"viewport\"") &&
10345                raw.value.contains("<title>Complex Page</title>") &&
10346                raw.value.contains("<style>") &&
10347                raw.value.contains("body { margin: 0; padding: 0; }")),
10348            "Third fragment should contain all head content, got: {:?}",
10349            fragments[2]
10350        );
10351
10352        // head_end signal
10353        assert!(
10354            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
10355                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
10356            "Fourth fragment should be head_end signal"
10357        );
10358
10359        // </head><body>
10360        assert!(
10361            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10362                raw.value.contains("</head>") && raw.value.ends_with("<body>")),
10363            "Fifth fragment should contain </head><body>"
10364        );
10365
10366        // body_start signal
10367        assert!(
10368            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
10369                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
10370            "Sixth fragment should be body_start signal"
10371        );
10372
10373        // Body content (h1 and script)
10374        assert!(
10375            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10376                raw.value.contains("<h1>Hello World</h1>") &&
10377                raw.value.contains("<script")),
10378            "Seventh fragment should contain body content"
10379        );
10380
10381        // body_end signal
10382        assert!(
10383            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
10384                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
10385            "Eighth fragment should be body_end signal"
10386        );
10387
10388        // Closing tags
10389        assert!(
10390            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10391                raw.value.contains("</body>") && raw.value.contains("</html>")),
10392            "Ninth fragment should contain closing tags"
10393        );
10394    }
10395
10396    // --- Binding count tests (with mock plugin) ---
10397
10398    /// Mock plugin that records the binding attribute count for each element.
10399    struct BindingCountPlugin {
10400        counts: Vec<u32>,
10401    }
10402
10403    impl BindingCountPlugin {
10404        fn new() -> Self {
10405            Self { counts: Vec::new() }
10406        }
10407    }
10408
10409    impl crate::plugin::ParserPlugin for BindingCountPlugin {
10410        fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
10411            let attr_name = context.name;
10412            if attr_name.starts_with('@') || attr_name == "f-ref" {
10413                AttributeAction::SkipAndCountBinding
10414            } else {
10415                AttributeAction::Keep
10416            }
10417        }
10418
10419        fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
10420            let binding_attribute_count = context.binding_count;
10421            self.counts.push(binding_attribute_count);
10422            if binding_attribute_count > 0 {
10423                Some(binding_attribute_count.to_le_bytes().to_vec())
10424            } else {
10425                None
10426            }
10427        }
10428    }
10429
10430    struct TemplateCapturePlugin {
10431        template: Option<String>,
10432        uses_shadow_dom: bool,
10433    }
10434
10435    impl TemplateCapturePlugin {
10436        fn new() -> Self {
10437            Self {
10438                template: None,
10439                uses_shadow_dom: false,
10440            }
10441        }
10442    }
10443
10444    impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
10445        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
10446            self.template = Some(context.template.to_string());
10447            self.uses_shadow_dom = context.uses_shadow_dom;
10448            Ok(())
10449        }
10450
10451        fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
10452            let attr_name = context.name;
10453            if attr_name.starts_with('@') || attr_name == "f-ref" {
10454                AttributeAction::SkipAndCountBinding
10455            } else {
10456                AttributeAction::Keep
10457            }
10458        }
10459
10460        fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
10461            match self.template {
10462                Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
10463                    crate::plugin::ComponentTemplateArtifact::template(
10464                        "todo-app".to_string(),
10465                        template,
10466                        self.uses_shadow_dom,
10467                    ),
10468                ])),
10469                None => Ok(ParserPluginArtifacts::None),
10470            }
10471        }
10472    }
10473
10474    #[test]
10475    fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
10476        let mut parser = HtmlParser::with_plugin_options(
10477            Box::new(TemplateCapturePlugin::new()),
10478            CssStrategy::Link,
10479        );
10480        parser
10481            .component_registry_mut()
10482            .register_component(ComponentRegistration::new(
10483                "todo-app",
10484                r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
10485                Some(":host { display: block; }"),
10486             true,))
10487            .expect("register todo-app");
10488
10489        parser
10490            .parse("index.html", "<todo-app></todo-app>")
10491            .expect("parse failed");
10492
10493        let artifacts = parser.take_plugin_artifacts().expect("artifacts");
10494        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10495            panic!("expected captured component template");
10496        };
10497        let template = &templates[0].template;
10498
10499        assert!(
10500            template.contains(r#"shadowrootmode="open""#),
10501            "parser must pass dev-authored root template attrs to plugins, got: {template}"
10502        );
10503        assert!(
10504            template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
10505            "parser must pass dev-authored root event attrs to plugins, got: {template}"
10506        );
10507        assert!(
10508            template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
10509            "parser must pass dev-authored root event attrs to plugins, got: {template}"
10510        );
10511        assert!(
10512            template.contains(r#"f-ref="{root}""#),
10513            "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
10514        );
10515        assert!(
10516            !template.contains("shadowrootadoptedstylesheets"),
10517            "link CSS must not add CSS module adoption attrs, got: {template}"
10518        );
10519    }
10520
10521    #[test]
10522    fn test_component_static_attrs_not_counted_as_bindings() {
10523        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10524        parser
10525            .component_registry
10526            .register_component(ComponentRegistration::new(
10527                "my-btn",
10528                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10529                None,
10530                true,
10531            ))
10532            .expect("register");
10533
10534        // All attributes are static — binding count should be 0
10535        let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
10536        let result = parser.parse("index.html", html);
10537        assert!(result.is_ok());
10538
10539        let records = parser.into_fragment_records();
10540
10541        // No Plugin fragment should appear (binding count = 0)
10542        let frags = &records["index.html"].fragments;
10543        let plugin_count = frags
10544            .iter()
10545            .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
10546            .count();
10547        assert_eq!(
10548            plugin_count, 0,
10549            "Static-only component attributes should not emit a Plugin fragment"
10550        );
10551    }
10552
10553    #[test]
10554    fn test_component_dynamic_attr_counted_as_binding() {
10555        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10556        parser
10557            .component_registry
10558            .register_component(ComponentRegistration::new(
10559                "my-btn",
10560                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10561                None,
10562                true,
10563            ))
10564            .expect("register");
10565
10566        // One dynamic attribute ({{...}}) — binding count should be 1
10567        let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
10568        let result = parser.parse("index.html", html);
10569        assert!(result.is_ok());
10570
10571        let records = parser.into_fragment_records();
10572        let frags = &records["index.html"].fragments;
10573
10574        // Exactly one Plugin fragment with count = 1
10575        let plugin_frags: Vec<_> = frags
10576            .iter()
10577            .filter_map(|f| match f.fragment.as_ref() {
10578                Some(Fragment::Plugin(p)) => Some(&p.data),
10579                _ => None,
10580            })
10581            .collect();
10582        assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
10583        let count = u32::from_le_bytes([
10584            plugin_frags[0][0],
10585            plugin_frags[0][1],
10586            plugin_frags[0][2],
10587            plugin_frags[0][3],
10588        ]);
10589        assert_eq!(count, 1, "Binding attribute count should be 1");
10590    }
10591
10592    #[test]
10593    fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
10594        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10595        parser
10596            .component_registry
10597            .register_component(ComponentRegistration::new(
10598                "my-btn",
10599                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10600                None,
10601                true,
10602            ))
10603            .expect("register");
10604
10605        // 2 static, 1 dynamic, 1 skipped-with-plugin (@click) — only dynamic + skipped counted
10606        let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
10607        let result = parser.parse("index.html", html);
10608        assert!(result.is_ok());
10609
10610        let records = parser.into_fragment_records();
10611        let frags = &records["index.html"].fragments;
10612
10613        let plugin_frags: Vec<_> = frags
10614            .iter()
10615            .filter_map(|f| match f.fragment.as_ref() {
10616                Some(Fragment::Plugin(p)) => Some(&p.data),
10617                _ => None,
10618            })
10619            .collect();
10620        assert_eq!(plugin_frags.len(), 1);
10621        let count = u32::from_le_bytes([
10622            plugin_frags[0][0],
10623            plugin_frags[0][1],
10624            plugin_frags[0][2],
10625            plugin_frags[0][3],
10626        ]);
10627        // 1 dynamic (appearance) + 1 skipped-but-counted (@click) = 2
10628        assert_eq!(
10629            count, 2,
10630            "Binding count should include dynamic + plugin-skipped attrs, not static"
10631        );
10632    }
10633
10634    #[test]
10635    fn test_component_only_skipped_plugin_attrs_counted() {
10636        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10637        parser
10638            .component_registry
10639            .register_component(ComponentRegistration::new(
10640                "my-btn",
10641                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10642                None,
10643                true,
10644            ))
10645            .expect("register");
10646
10647        // Only plugin-skipped attrs (@click, f-ref) plus static — only skipped counted
10648        let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
10649        let result = parser.parse("index.html", html);
10650        assert!(result.is_ok());
10651
10652        let records = parser.into_fragment_records();
10653        let frags = &records["index.html"].fragments;
10654
10655        let plugin_frags: Vec<_> = frags
10656            .iter()
10657            .filter_map(|f| match f.fragment.as_ref() {
10658                Some(Fragment::Plugin(p)) => Some(&p.data),
10659                _ => None,
10660            })
10661            .collect();
10662        assert_eq!(plugin_frags.len(), 1);
10663        let count = u32::from_le_bytes([
10664            plugin_frags[0][0],
10665            plugin_frags[0][1],
10666            plugin_frags[0][2],
10667            plugin_frags[0][3],
10668        ]);
10669        // @click + f-ref = 2, title is static = not counted
10670        assert_eq!(
10671            count, 2,
10672            "Only plugin-skipped attrs should be counted, not static title"
10673        );
10674    }
10675
10676    // ── Comment stripping tests ──────────────────────────────────────
10677
10678    #[test]
10679    fn test_comment_handlebars_signal_is_stripped() {
10680        let mut parser = HtmlParser::new();
10681        let html = "<!--{{tokens}}-->";
10682        parser.parse("test.html", html).expect("parse failed");
10683        let records = parser.into_fragment_records();
10684
10685        assert_stream!(records, "test.html", []);
10686    }
10687
10688    #[test]
10689    fn test_comment_triple_brace_raw_signal_is_stripped() {
10690        let mut parser = HtmlParser::new();
10691        let html = "<!--{{{tokens}}}-->";
10692        parser.parse("test.html", html).expect("parse failed");
10693        let records = parser.into_fragment_records();
10694
10695        assert_stream!(records, "test.html", []);
10696    }
10697
10698    #[test]
10699    fn test_comment_regular_stripped() {
10700        let mut parser = HtmlParser::new();
10701        let html = "<!-- regular comment -->";
10702        parser.parse("test.html", html).expect("parse failed");
10703        let records = parser.into_fragment_records();
10704
10705        assert_stream!(records, "test.html", []);
10706    }
10707
10708    #[test]
10709    fn test_comment_dotted_signal_is_stripped() {
10710        let mut parser = HtmlParser::new();
10711        let html = "<!--{{tokens.light}}-->";
10712        parser.parse("test.html", html).expect("parse failed");
10713        let records = parser.into_fragment_records();
10714
10715        assert_stream!(records, "test.html", []);
10716    }
10717
10718    #[test]
10719    fn test_comment_arbitrary_identifier_is_stripped() {
10720        let mut parser = HtmlParser::new();
10721        let html = "<!--{{someOtherBinding}}-->";
10722        parser.parse("test.html", html).expect("parse failed");
10723        let records = parser.into_fragment_records();
10724
10725        assert_stream!(records, "test.html", []);
10726    }
10727
10728    #[test]
10729    fn test_comment_with_surrounding_content_is_stripped() {
10730        let mut parser = HtmlParser::new();
10731        let html = "<div><!--{{tokens}}--></div>";
10732        parser.parse("test.html", html).expect("parse failed");
10733        let records = parser.into_fragment_records();
10734
10735        assert_stream!(records, "test.html", [raw("<div></div>")]);
10736    }
10737
10738    #[test]
10739    fn test_component_template_unterminated_comment_returns_error() {
10740        let mut parser = HtmlParser::new();
10741        parser
10742            .component_registry_mut()
10743            .register_component(ComponentRegistration::new(
10744                "x-bad",
10745                "<span>ok</span><!-- missing close",
10746                None,
10747                true,
10748            ))
10749            .expect("register failed");
10750
10751        let result = parser.parse("index.html", "<x-bad></x-bad>");
10752
10753        assert!(matches!(result, Err(ParserError::Template(diag)) if
10754            diag.to_string().contains("unterminated HTML comment")
10755        ));
10756    }
10757
10758    #[test]
10759    fn test_webui_plugin_strips_component_comments_before_metadata() {
10760        let mut parser =
10761            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10762        parser
10763            .component_registry_mut()
10764            .register_component(ComponentRegistration::new(
10765                "x-bleed",
10766                r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
10767                None,
10768                true,
10769            ))
10770            .expect("register failed");
10771
10772        parser
10773            .parse("index.html", "<x-bleed></x-bleed>")
10774            .expect("parse failed");
10775
10776        let artifacts = parser.take_plugin_artifacts().unwrap();
10777        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10778            panic!("expected component templates");
10779        };
10780        let template = &templates[0].template_json;
10781        assert!(template.contains("<div>hello</div>"));
10782        assert!(!template.contains("{{path}}"));
10783        assert!(!template.contains("@click"));
10784        assert!(!template.contains("?hidden"));
10785        assert!(!template.contains("-->"));
10786    }
10787
10788    #[test]
10789    fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
10790        let mut parser =
10791            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10792        parser
10793            .component_registry_mut()
10794            .register_component(ComponentRegistration::new(
10795                "x-style",
10796                "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
10797                None,
10798                true,
10799            ))
10800            .expect("register failed");
10801
10802        parser
10803            .parse("index.html", "<x-style></x-style>")
10804            .expect("parse failed");
10805
10806        let artifacts = parser.take_plugin_artifacts().unwrap();
10807        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10808            panic!("expected component templates");
10809        };
10810        let template = &templates[0].template_json;
10811        assert!(template.contains(".x { color: red; }"));
10812        assert!(!template.contains("ignored"));
10813        assert!(!template.contains("//"));
10814    }
10815
10816    // ── Token collection tests ───────────────────────────────────────
10817
10818    #[test]
10819    fn test_tokens_from_style_tag() {
10820        let mut parser = HtmlParser::new();
10821        let html = r#"<style>
10822            .btn { color: var(--colorPrimary); background: var(--bgColor); }
10823        </style>"#;
10824        parser.parse("test.html", html).expect("parse failed");
10825        let tokens = parser.take_tokens();
10826
10827        assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
10828    }
10829
10830    #[test]
10831    fn test_tokens_from_malformed_style_error_on_unclosed_var() {
10832        let mut parser = HtmlParser::new();
10833        let html = r#"<style>
10834            .bad { color: var(--dangling; }
10835            .ok { color: var(--valid); }
10836        </style>"#;
10837        let result = parser.parse("test.html", html);
10838
10839        assert!(matches!(result, Err(ParserError::Template(diag)) if
10840            diag.error_code() == Some(codes::INVALID_CSS)
10841                && diag.to_string().contains("Unterminated CSS var() call")
10842        ));
10843    }
10844
10845    #[test]
10846    fn test_tokens_from_component_css() {
10847        let mut parser = HtmlParser::new();
10848        parser
10849            .component_registry_mut()
10850            .register_component(ComponentRegistration::new(
10851                "my-button",
10852                "<button>Click</button>",
10853                Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
10854                true,
10855            ))
10856            .expect("register failed");
10857
10858        let html = "<my-button></my-button>";
10859        parser.parse("test.html", html).expect("parse failed");
10860        let tokens = parser.take_tokens();
10861
10862        assert_eq!(tokens, vec!["borderWidth", "textColor"]);
10863    }
10864
10865    #[test]
10866    fn test_token_requirements_preserve_fallback_chains() {
10867        let mut parser = HtmlParser::new();
10868        parser
10869            .component_registry_mut()
10870            .register_component(ComponentRegistration::new(
10871                "my-card",
10872                "<div>Card</div>",
10873                Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
10874                true,
10875            ))
10876            .expect("register failed");
10877
10878        parser
10879            .parse("test.html", "<my-card></my-card>")
10880            .expect("parse failed");
10881        let analysis = parser.token_analysis();
10882
10883        assert_eq!(analysis.fallback_chains.len(), 1);
10884        assert_eq!(
10885            analysis.fallback_chains[0].tokens,
10886            vec!["token-a", "token-b", "token-c"]
10887        );
10888        assert!(!analysis.fallback_chains[0].has_literal_fallback);
10889    }
10890
10891    #[test]
10892    fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
10893        let mut parser = HtmlParser::new();
10894        parser
10895            .component_registry_mut()
10896            .register_component(ComponentRegistration::new(
10897                "my-card",
10898                "<div>Card</div>",
10899                Some(
10900                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10901                ),
10902             true,))
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.protocol_tokens, vec!["token-b", "token-c"]);
10911        assert_eq!(analysis.fallback_chains.len(), 1);
10912        assert_eq!(
10913            analysis.fallback_chains[0].tokens,
10914            vec!["token-b", "token-c"]
10915        );
10916    }
10917
10918    #[test]
10919    fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
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        parser
10932            .parse("test.html", "<my-card></my-card>")
10933            .expect("parse failed");
10934
10935        let analysis = parser.token_analysis();
10936        let theme = webui_tokens::TokenFile {
10937            themes: HashMap::from([(
10938                "light".to_string(),
10939                HashMap::from([("token-b".to_string(), "green".to_string())]),
10940            )]),
10941        };
10942
10943        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
10944            panic!("missing --token-c in the theme must fail parser validation");
10945        };
10946        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
10947        // The help is concise: a typo suggestion plus the local-definition
10948        // escape hatch — it does not restate "add --token-c to theme".
10949        let help = diagnostic.help_text().expect("help text");
10950        assert!(help.contains("did you mean --token-b?"), "help: {help}");
10951        assert!(help.contains("define it locally"), "help: {help}");
10952        assert!(!help.contains("add --token"), "help: {help}");
10953    }
10954
10955    #[test]
10956    fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
10957        let mut parser = HtmlParser::new();
10958        parser
10959            .component_registry_mut()
10960            .register_component(ComponentRegistration::new(
10961                "my-card",
10962                "<div>Card</div>",
10963                Some(":host { color: var(--brand, #000); }"),
10964                true,
10965            ))
10966            .expect("register failed");
10967        parser
10968            .parse("test.html", "<my-card></my-card>")
10969            .expect("parse failed");
10970
10971        let analysis = parser.token_analysis();
10972        // The token is still hoisted so the runtime resolves it when a theme
10973        // does provide it.
10974        assert_eq!(analysis.protocol_tokens, vec!["brand"]);
10975
10976        // A theme without `--brand` must NOT fail the build: the CSS literal
10977        // fallback (`#000`) already provides a value.
10978        let theme = webui_tokens::TokenFile {
10979            themes: HashMap::from([("light".to_string(), HashMap::new())]),
10980        };
10981        analysis
10982            .validate_theme_tokens(&theme)
10983            .expect("literal fallback must exempt --brand from theme validation");
10984    }
10985
10986    #[test]
10987    fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
10988        let mut parser = HtmlParser::new();
10989        parser
10990            .component_registry_mut()
10991            .register_component(ComponentRegistration::new(
10992                "my-card",
10993                "<div>Card</div>",
10994                // One usage has a literal fallback, the other does not. The
10995                // bare `var(--brand)` makes the token genuinely required.
10996                Some(":host { color: var(--brand, #000); background: var(--brand); }"),
10997                true,
10998            ))
10999            .expect("register failed");
11000        parser
11001            .parse("test.html", "<my-card></my-card>")
11002            .expect("parse failed");
11003
11004        let analysis = parser.token_analysis();
11005        let theme = webui_tokens::TokenFile {
11006            themes: HashMap::from([("light".to_string(), HashMap::new())]),
11007        };
11008
11009        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
11010            panic!("a bare var(--brand) usage must require --brand in the theme");
11011        };
11012        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
11013        assert!(diagnostic.to_string().contains("--brand"));
11014    }
11015
11016    #[test]
11017    fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
11018        let mut parser = HtmlParser::new();
11019        parser
11020            .component_registry_mut()
11021            .register_component(ComponentRegistration::new(
11022                "my-card",
11023                "<div>Card</div>",
11024                Some(
11025                    ":host { \
11026                       color: var(--colr-brand, #000); \
11027                       border: var(--present, 1px); \
11028                       margin: var(--required); \
11029                     }",
11030                ),
11031                true,
11032            ))
11033            .expect("register failed");
11034        parser
11035            .parse("test.html", "<my-card></my-card>")
11036            .expect("parse failed");
11037
11038        let analysis = parser.token_analysis();
11039        let theme = webui_tokens::TokenFile {
11040            themes: HashMap::from([(
11041                "light".to_string(),
11042                HashMap::from([
11043                    ("present".to_string(), "2px".to_string()),
11044                    ("required".to_string(), "8px".to_string()),
11045                ]),
11046            )]),
11047        };
11048
11049        // `colr-brand`: literal-only and absent from every theme → warned.
11050        // `present`: literal-only but defined in the theme → not warned.
11051        // `required`: has no literal fallback → a validation concern, not a warning.
11052        assert_eq!(
11053            analysis.unthemed_literal_fallback_tokens(&theme),
11054            vec!["colr-brand".to_string()]
11055        );
11056    }
11057
11058    #[test]
11059    fn test_theme_token_error_reports_location_and_suggestion() {
11060        let mut parser = HtmlParser::new();
11061        parser
11062            .component_registry_mut()
11063            .register_component(ComponentRegistration::new(
11064                "my-card",
11065                "<div>Card</div>",
11066                ":host {\n  color: var(--color-neutral-2000);\n}".into(),
11067                true,
11068            ))
11069            .expect("register failed");
11070        parser
11071            .parse("test.html", "<my-card></my-card>")
11072            .expect("parse failed");
11073
11074        let analysis = parser.token_analysis();
11075        let theme = webui_tokens::TokenFile {
11076            themes: HashMap::from([(
11077                "dark".to_string(),
11078                HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
11079            )]),
11080        };
11081
11082        let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
11083            panic!("missing --color-neutral-2000 must fail validation");
11084        };
11085        assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
11086        // File + line:column, like other authoring diagnostics.
11087        let location = diag.location().expect("a source location");
11088        assert!(
11089            location.contains("my-card.css:2:14"),
11090            "location: {location}"
11091        );
11092        // Snippet shows the offending CSS line.
11093        assert!(
11094            diag.snippet_text()
11095                .is_some_and(|s| s.contains("--color-neutral-2000")),
11096            "snippet: {:?}",
11097            diag.snippet_text()
11098        );
11099        // Did-you-mean from the theme's own tokens.
11100        assert!(
11101            diag.help_text()
11102                .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
11103            "help: {:?}",
11104            diag.help_text()
11105        );
11106    }
11107
11108    #[test]
11109    fn test_theme_token_warning_reports_location_and_suggestion() {
11110        let mut parser = HtmlParser::new();
11111        parser
11112            .component_registry_mut()
11113            .register_component(ComponentRegistration::new(
11114                "my-card",
11115                "<div>Card</div>",
11116                ":host {\n  color: var(--colr-brand, #000);\n}".into(),
11117                true,
11118            ))
11119            .expect("register failed");
11120        parser
11121            .parse("test.html", "<my-card></my-card>")
11122            .expect("parse failed");
11123
11124        let analysis = parser.token_analysis();
11125        let theme = webui_tokens::TokenFile {
11126            themes: HashMap::from([(
11127                "light".to_string(),
11128                HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
11129            )]),
11130        };
11131
11132        let warnings = analysis.theme_token_warnings(&theme);
11133        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
11134        let body = warnings[0].body();
11135        assert!(body.contains("my-card.css:2:"), "warning: {body}");
11136        assert!(body.contains("--colr-brand"), "warning: {body}");
11137        assert!(
11138            body.contains("did you mean --color-brand?"),
11139            "warning: {body}"
11140        );
11141    }
11142
11143    #[test]
11144    fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
11145        let mut parser = HtmlParser::new();
11146        let result =
11147            parser
11148                .component_registry_mut()
11149                .register_component(ComponentRegistration::new(
11150                    "my-card",
11151                    "<div>Card</div>",
11152                    Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
11153                    true,
11154                ));
11155
11156        assert!(matches!(result, Err(ParserError::Css(message)) if
11157            message.contains("Unterminated CSS var() call")
11158        ));
11159    }
11160
11161    #[test]
11162    fn test_tokens_merged_from_style_and_components() {
11163        let mut parser = HtmlParser::new();
11164        parser
11165            .component_registry_mut()
11166            .register_component(ComponentRegistration::new(
11167                "my-widget",
11168                "<div>Widget</div>",
11169                Some(".w { padding: var(--spacingM); }"),
11170                true,
11171            ))
11172            .expect("register failed");
11173
11174        let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
11175        parser.parse("test.html", html).expect("parse failed");
11176        let tokens = parser.take_tokens();
11177
11178        assert_eq!(tokens, vec!["spacingM", "textColor"]);
11179    }
11180
11181    #[test]
11182    fn test_tokens_deduplicated() {
11183        let mut parser = HtmlParser::new();
11184        parser
11185            .component_registry_mut()
11186            .register_component(ComponentRegistration::new(
11187                "my-btn",
11188                "<button>B</button>",
11189                Some(".b { color: var(--shared); }"),
11190                true,
11191            ))
11192            .expect("register failed");
11193
11194        let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
11195        parser.parse("test.html", html).expect("parse failed");
11196        let tokens = parser.take_tokens();
11197
11198        assert_eq!(tokens, vec!["shared"]);
11199    }
11200
11201    #[test]
11202    fn test_tokens_empty_when_no_vars() {
11203        let mut parser = HtmlParser::new();
11204        let html = "<div>Hello</div>";
11205        parser.parse("test.html", html).expect("parse failed");
11206        let tokens = parser.take_tokens();
11207
11208        assert!(tokens.is_empty());
11209    }
11210
11211    #[test]
11212    fn test_tokens_exclude_locally_defined_in_component() {
11213        let mut parser = HtmlParser::new();
11214        parser
11215            .component_registry_mut()
11216            .register_component(ComponentRegistration::new(
11217                "my-card",
11218                "<div>Card</div>",
11219                Some(":host { --local: 5px; width: var(--external); }"),
11220                true,
11221            ))
11222            .expect("register failed");
11223
11224        let html = "<my-card></my-card>";
11225        parser.parse("test.html", html).expect("parse failed");
11226        let tokens = parser.take_tokens();
11227
11228        assert_eq!(tokens, vec!["external"]);
11229    }
11230
11231    #[test]
11232    fn test_tokens_exclude_entry_root_definitions() {
11233        let mut parser = HtmlParser::new();
11234        parser
11235            .component_registry_mut()
11236            .register_component(ComponentRegistration::new(
11237                "my-btn",
11238                "<button>B</button>",
11239                Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
11240                true,
11241            ))
11242            .expect("register failed");
11243
11244        // Entry HTML defines --color-primary and --radius-m in :root
11245        // Components use them — they should NOT appear in hoisted tokens
11246        let html = r#"<style>
11247            :root {
11248                --color-primary: #0078d4;
11249                --radius-m: 6px;
11250            }
11251            body { color: var(--color-primary); }
11252        </style>
11253        <my-btn></my-btn>"#;
11254        parser.parse("test.html", html).expect("parse failed");
11255        let tokens = parser.take_tokens();
11256
11257        // Both tokens are defined in entry :root, so neither should be hoisted
11258        assert!(
11259            tokens.is_empty(),
11260            "Tokens defined in entry :root should be excluded: {tokens:?}"
11261        );
11262    }
11263
11264    #[test]
11265    fn test_tokens_entry_defs_exclude_but_external_kept() {
11266        let mut parser = HtmlParser::new();
11267        parser
11268            .component_registry_mut()
11269            .register_component(ComponentRegistration::new(
11270                "my-card",
11271                "<div>Card</div>",
11272                Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
11273                true,
11274            ))
11275            .expect("register failed");
11276
11277        // Entry defines --color-primary but NOT --external-spacing
11278        let html = r#"<style>
11279            :root { --color-primary: #0078d4; }
11280        </style>
11281        <my-card></my-card>"#;
11282        parser.parse("test.html", html).expect("parse failed");
11283        let tokens = parser.take_tokens();
11284
11285        // Only --external-spacing should be hoisted (not defined in entry)
11286        assert_eq!(tokens, vec!["external-spacing"]);
11287    }
11288
11289    // ── Route parsing tests ─────────────────────────────────────────────
11290
11291    #[test]
11292    fn test_parse_simple_route() {
11293        let mut parser = HtmlParser::new();
11294        let html = r#"<route path="/profile" component="profile-page" exact />"#;
11295        parser.parse("test.html", html).expect("parse failed");
11296
11297        // Routes are emitted as Fragment::Route
11298        let records = parser.into_fragment_records();
11299        let frags = &records["test.html"].fragments;
11300        assert_eq!(frags.len(), 1);
11301        match frags[0].fragment.as_ref() {
11302            Some(web_ui_fragment::Fragment::Route(r)) => {
11303                assert_eq!(r.path, "/profile");
11304                assert_eq!(r.fragment_id, "profile-page");
11305                assert!(r.exact);
11306            }
11307            other => panic!("expected Fragment::Route, got {:?}", other),
11308        }
11309    }
11310
11311    #[test]
11312    fn test_parse_route_with_params() {
11313        let mut parser = HtmlParser::new();
11314        let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
11315        parser.parse("test.html", html).expect("parse failed");
11316
11317        // Route is emitted as Fragment::Route with correct path
11318        let records = parser.into_fragment_records();
11319        let frags = &records["test.html"].fragments;
11320        match frags[0].fragment.as_ref() {
11321            Some(web_ui_fragment::Fragment::Route(r)) => {
11322                assert_eq!(r.path, "/profile/:id/view/:section");
11323            }
11324            other => panic!("expected Fragment::Route, got {:?}", other),
11325        }
11326    }
11327
11328    #[test]
11329    fn test_parse_route_requires_component() {
11330        let mut parser = HtmlParser::new();
11331        let html = r#"<route path="/old" />"#;
11332        let result = parser.parse("test.html", html);
11333        assert!(result.is_err());
11334    }
11335
11336    #[test]
11337    fn test_parse_multiple_routes() {
11338        let mut parser = HtmlParser::new();
11339        let html = r#"<route path="/" component="app-layout" />
11340            <route path="/dashboard" component="dash-page" exact />
11341            <route path="/contacts" component="contacts-page" exact />"#;
11342        parser.parse("test.html", html).expect("parse failed");
11343
11344        let records = parser.into_fragment_records();
11345        let frags = &records["test.html"].fragments;
11346        assert_eq!(frags.len(), 3);
11347        // All should be Fragment::Route
11348        for frag in frags {
11349            assert!(matches!(
11350                frag.fragment.as_ref(),
11351                Some(web_ui_fragment::Fragment::Route(_))
11352            ));
11353        }
11354    }
11355
11356    #[test]
11357    fn test_parse_route_requires_component_with_body() {
11358        let mut parser = HtmlParser::new();
11359        let html = r#"<route path="/error">
11360            <div class="error"><h1>Not Found</h1></div>
11361        </route>"#;
11362        let result = parser.parse("test.html", html);
11363        assert!(
11364            result.is_err(),
11365            "Route without component attribute should fail"
11366        );
11367    }
11368
11369    #[test]
11370    fn test_parse_multiple_routes_with_fragments() {
11371        let mut parser = HtmlParser::new();
11372        let html = r#"<route path="/" component="home-page" exact />
11373            <route path="/about" component="about-page" exact />
11374            <route path="/contact/:id" component="contact-page" />"#;
11375        parser.parse("test.html", html).expect("parse failed");
11376
11377        let records = parser.into_fragment_records();
11378        let frags = &records["test.html"].fragments;
11379        assert_eq!(frags.len(), 3);
11380
11381        // Verify individual routes
11382        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
11383            assert_eq!(r.path, "/");
11384        }
11385        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
11386            assert_eq!(r.path, "/about");
11387        }
11388        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
11389            assert_eq!(r.path, "/contact/:id");
11390        }
11391    }
11392
11393    #[test]
11394    fn test_parse_route_with_query_allowlist() {
11395        let mut parser = HtmlParser::new();
11396        let html =
11397            r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
11398        parser.parse("test.html", html).expect("parse failed");
11399
11400        let records = parser.into_fragment_records();
11401        let frags = &records["test.html"].fragments;
11402        assert_eq!(frags.len(), 1);
11403        match frags[0].fragment.as_ref() {
11404            Some(web_ui_fragment::Fragment::Route(r)) => {
11405                assert_eq!(r.path, "/compose");
11406                assert_eq!(r.fragment_id, "compose-page");
11407                assert!(r.exact);
11408                assert_eq!(r.allowed_query, "action,to,subject");
11409            }
11410            other => panic!("expected Fragment::Route, got {:?}", other),
11411        }
11412    }
11413
11414    #[test]
11415    fn test_parse_route_without_query_has_empty_allowed_query() {
11416        let mut parser = HtmlParser::new();
11417        let html = r#"<route path="/profile" component="profile-page" exact />"#;
11418        parser.parse("test.html", html).expect("parse failed");
11419
11420        let records = parser.into_fragment_records();
11421        let frags = &records["test.html"].fragments;
11422        match frags[0].fragment.as_ref() {
11423            Some(web_ui_fragment::Fragment::Route(r)) => {
11424                assert!(r.allowed_query.is_empty());
11425            }
11426            other => panic!("expected Fragment::Route, got {:?}", other),
11427        }
11428    }
11429
11430    #[test]
11431    fn test_route_body_rejects_malformed_non_route_html() {
11432        let mut parser = HtmlParser::new();
11433        let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
11434        let result = parser.parse("test.html", html);
11435
11436        assert!(matches!(result, Err(ParserError::Template(diag)) if
11437            diag.to_string().contains("unclosed <span> tag")
11438        ));
11439    }
11440
11441    #[test]
11442    fn test_route_body_rejects_malformed_style_css() {
11443        let mut parser = HtmlParser::new();
11444        let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
11445        let result = parser.parse("test.html", html);
11446
11447        assert!(matches!(result, Err(ParserError::Template(diag)) if
11448            diag.error_code() == Some(codes::INVALID_CSS)
11449                && diag.to_string().contains("Unterminated CSS var() call")
11450        ));
11451    }
11452
11453    #[test]
11454    fn test_outlet_not_captured_by_for_loop() {
11455        let mut parser = HtmlParser::new();
11456        let html = r#"<template shadowrootmode="open">
11457  <ul>
11458    <for each="item in items">
11459      <li>{{item.name}}</li>
11460    </for>
11461  </ul>
11462  <main>
11463    <outlet />
11464  </main>
11465</template>"#;
11466        parser.parse("comp.html", html).expect("parse failed");
11467
11468        let records = parser.into_fragment_records();
11469        let frags = &records["comp.html"].fragments;
11470
11471        // The outlet should be a top-level fragment, NOT inside the for-loop's body
11472        let outlet_count = frags
11473            .iter()
11474            .filter(|f| {
11475                matches!(
11476                    f.fragment.as_ref(),
11477                    Some(web_ui_fragment::Fragment::Outlet(_))
11478                )
11479            })
11480            .count();
11481        assert_eq!(
11482            outlet_count, 1,
11483            "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
11484        );
11485
11486        // Verify outlet comes AFTER the raw "</ul>" text
11487        let outlet_idx = frags
11488            .iter()
11489            .position(|f| {
11490                matches!(
11491                    f.fragment.as_ref(),
11492                    Some(web_ui_fragment::Fragment::Outlet(_))
11493                )
11494            })
11495            .expect("no outlet found");
11496        let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
11497            Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
11498            _ => false,
11499        });
11500        if let Some(ul_idx) = close_ul_idx {
11501            assert!(
11502                outlet_idx > ul_idx,
11503                "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
11504            );
11505        }
11506    }
11507
11508    #[test]
11509    fn test_outlet_position_after_for_not_inside() {
11510        let mut parser = HtmlParser::new();
11511        let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
11512        parser.parse("test.html", html).expect("parse failed");
11513
11514        let records = parser.into_fragment_records();
11515        let frags = &records["test.html"].fragments;
11516
11517        // Outlet should be its own fragment at top level
11518        let has_outlet = frags.iter().any(|f| {
11519            matches!(
11520                f.fragment.as_ref(),
11521                Some(web_ui_fragment::Fragment::Outlet(_))
11522            )
11523        });
11524        assert!(
11525            has_outlet,
11526            "outlet should be in top-level fragments: {frags:?}"
11527        );
11528
11529        // The for-loop body should NOT contain the outlet
11530        let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
11531            Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
11532            _ => None,
11533        });
11534        if let Some(id) = for_id {
11535            let for_frags = &records[&id].fragments;
11536            let outlet_in_for = for_frags.iter().any(|f| {
11537                matches!(
11538                    f.fragment.as_ref(),
11539                    Some(web_ui_fragment::Fragment::Outlet(_))
11540                )
11541            });
11542            assert!(
11543                !outlet_in_for,
11544                "outlet should NOT be inside for-loop body: {for_frags:?}"
11545            );
11546        }
11547    }
11548
11549    #[test]
11550    fn test_style_element_with_handlebars_signal_comment_emits_signal() {
11551        let mut parser = HtmlParser::new();
11552        let html = r#"<html><head><style>
11553:root {
11554    /*{{{tokens.light}}}*/
11555}
11556</style></head><body></body></html>"#;
11557
11558        let result = parser.parse("test.html", html);
11559        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11560        let fragment_records = parser.into_fragment_records();
11561
11562        assert_stream!(
11563            fragment_records,
11564            "test.html",
11565            [
11566                raw("<html><head>"),
11567                structural_matcher("head_start"),
11568                raw("<style>\n:root {\n    "),
11569                raw_text_signal("tokens.light", true),
11570                raw("\n}\n</style>"),
11571                structural_matcher("head_end"),
11572                raw("</head><body>"),
11573                structural_matcher("body_start"),
11574                structural_matcher("body_end"),
11575                raw("</body></html>"),
11576            ]
11577        );
11578    }
11579
11580    #[test]
11581    fn test_style_comment_signal_with_spaces_emits_signal() {
11582        let mut parser = HtmlParser::new();
11583        let html = r#"<html><head><style>
11584:root {
11585    /* {{{tokens.light}}} */
11586}
11587</style></head><body></body></html>"#;
11588
11589        let result = parser.parse("test.html", html);
11590        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11591        let fragment_records = parser.into_fragment_records();
11592
11593        assert_stream!(
11594            fragment_records,
11595            "test.html",
11596            [
11597                raw("<html><head>"),
11598                structural_matcher("head_start"),
11599                raw("<style>\n:root {\n    "),
11600                raw_text_signal("tokens.light", true),
11601                raw("\n}\n</style>"),
11602                structural_matcher("head_end"),
11603                raw("</head><body>"),
11604                structural_matcher("body_start"),
11605                structural_matcher("body_end"),
11606                raw("</body></html>"),
11607            ]
11608        );
11609    }
11610
11611    #[test]
11612    fn test_style_comment_signal_double_brace_emits_signal() {
11613        let mut parser = HtmlParser::new();
11614        let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
11615
11616        let result = parser.parse("test.html", html);
11617        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11618        let fragment_records = parser.into_fragment_records();
11619
11620        assert_stream!(
11621            fragment_records,
11622            "test.html",
11623            [
11624                raw("<html><head>"),
11625                structural_matcher("head_start"),
11626                raw("<style>"),
11627                raw_text_signal("themeCss", false),
11628                raw("</style>"),
11629                structural_matcher("head_end"),
11630                raw("</head><body>"),
11631                structural_matcher("body_start"),
11632                structural_matcher("body_end"),
11633                raw("</body></html>"),
11634            ]
11635        );
11636    }
11637
11638    #[test]
11639    fn test_style_comment_with_extra_text_is_stripped() {
11640        let mut parser = HtmlParser::new();
11641        let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
11642
11643        let result = parser.parse("test.html", html);
11644        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11645        let fragment_records = parser.into_fragment_records();
11646
11647        assert_stream!(
11648            fragment_records,
11649            "test.html",
11650            [
11651                raw("<html><head>"),
11652                structural_matcher("head_start"),
11653                raw("<style></style>"),
11654                structural_matcher("head_end"),
11655                raw("</head><body>"),
11656                structural_matcher("body_start"),
11657                structural_matcher("body_end"),
11658                raw("</body></html>"),
11659            ]
11660        );
11661    }
11662
11663    #[test]
11664    fn test_style_comment_with_multiple_signals_is_stripped() {
11665        let mut parser = HtmlParser::new();
11666        let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
11667
11668        let result = parser.parse("test.html", html);
11669        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11670        let fragment_records = parser.into_fragment_records();
11671
11672        assert_stream!(
11673            fragment_records,
11674            "test.html",
11675            [
11676                raw("<html><head>"),
11677                structural_matcher("head_start"),
11678                raw("<style></style>"),
11679                structural_matcher("head_end"),
11680                raw("</head><body>"),
11681                structural_matcher("body_start"),
11682                structural_matcher("body_end"),
11683                raw("</body></html>"),
11684            ]
11685        );
11686    }
11687
11688    #[test]
11689    fn test_style_bare_handlebars_stays_raw() {
11690        let mut parser = HtmlParser::new();
11691        // Handlebars outside a CSS comment must NOT be parsed as signals.
11692        let html =
11693            "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
11694
11695        let result = parser.parse("test.html", html);
11696        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11697        let fragment_records = parser.into_fragment_records();
11698
11699        assert_stream!(
11700            fragment_records,
11701            "test.html",
11702            [
11703                raw("<html><head>"),
11704                structural_matcher("head_start"),
11705                raw("<style>body { color: {{textColor}}; }</style>"),
11706                structural_matcher("head_end"),
11707                raw("</head><body>"),
11708                structural_matcher("body_start"),
11709                structural_matcher("body_end"),
11710                raw("</body></html>"),
11711            ]
11712        );
11713    }
11714
11715    #[test]
11716    fn test_style_mixed_css_and_comment_signal_emits_signal() {
11717        let mut parser = HtmlParser::new();
11718        let html = r#"<html><head><style>
11719  .a { color: red; }
11720  /*{{themeCss}}*/
11721  .b { color: blue; }
11722</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>\n  .a { color: red; }\n  "),
11735                raw_text_signal("themeCss", false),
11736                raw("\n  .b { color: blue; }\n</style>"),
11737                structural_matcher("head_end"),
11738                raw("</head><body>"),
11739                structural_matcher("body_start"),
11740                structural_matcher("body_end"),
11741                raw("</body></html>"),
11742            ]
11743        );
11744    }
11745
11746    #[test]
11747    fn test_style_legal_comment_preserved_inline_by_default() {
11748        let mut parser = HtmlParser::new();
11749        let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
11750
11751        parser.parse("test.html", html).expect("parse failed");
11752        let fragment_records = parser.into_fragment_records();
11753
11754        assert_stream!(
11755            fragment_records,
11756            "test.html",
11757            [
11758                raw("<html><head>"),
11759                structural_matcher("head_start"),
11760                raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
11761                structural_matcher("head_end"),
11762                raw("</head><body>"),
11763                structural_matcher("body_start"),
11764                structural_matcher("body_end"),
11765                raw("</body></html>"),
11766            ]
11767        );
11768    }
11769
11770    #[test]
11771    fn test_style_legal_comments_none_strips_legal_comment() {
11772        let mut parser = HtmlParser::with_options(ParserOptions {
11773            legal_comments: LegalComments::None,
11774            ..ParserOptions::default()
11775        });
11776        let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
11777
11778        parser.parse("test.html", html).expect("parse failed");
11779        let fragment_records = parser.into_fragment_records();
11780
11781        assert_stream!(
11782            fragment_records,
11783            "test.html",
11784            [
11785                raw("<html><head>"),
11786                structural_matcher("head_start"),
11787                raw("<style> .x { color: red; }</style>"),
11788                structural_matcher("head_end"),
11789                raw("</head><body>"),
11790                structural_matcher("body_start"),
11791                structural_matcher("body_end"),
11792                raw("</body></html>"),
11793            ]
11794        );
11795    }
11796
11797    #[test]
11798    fn test_style_element_plain_css_unchanged() {
11799        let mut parser = HtmlParser::new();
11800        let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
11801
11802        let result = parser.parse("test.html", html);
11803        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11804        let fragment_records = parser.into_fragment_records();
11805
11806        // Plain CSS with no handlebars should remain as a single raw fragment.
11807        assert_stream!(
11808            fragment_records,
11809            "test.html",
11810            [
11811                raw("<html><head>"),
11812                structural_matcher("head_start"),
11813                raw("<style>body { margin: 0; }</style>"),
11814                structural_matcher("head_end"),
11815                raw("</head><body>"),
11816                structural_matcher("body_start"),
11817                structural_matcher("body_end"),
11818                raw("</body></html>"),
11819            ]
11820        );
11821    }
11822
11823    #[test]
11824    fn test_unclosed_style_element_returns_error() {
11825        let mut parser = HtmlParser::new();
11826        let result = parser.parse("index.html", "<style>.x { color: red; ");
11827        assert!(matches!(result, Err(ParserError::Template(diag)) if
11828            diag.to_string().contains("unclosed <style> tag")
11829        ));
11830    }
11831
11832    #[test]
11833    fn test_text_with_binding_after_void_element() {
11834        // Text nodes containing {{binding}} after void elements like <br>
11835        // must be preserved. Regression test for content being dropped.
11836        let (fragments, _) = parse_and_get_fragments(
11837            "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
11838        );
11839        assert_fragments!(
11840            fragments,
11841            [
11842                raw("Part A: "),
11843                signal("value1"),
11844                raw("<button>Click</button><br>Part B: "),
11845                signal("value2"),
11846                raw("<button>Click2</button>"),
11847            ]
11848        );
11849    }
11850}