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 suggest;
23
24pub use asset_filename::{
25    AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
26};
27pub use component_registry::{Component, ComponentRegistration, ComponentRegistry};
28pub use condition_parser::ConditionParser;
29pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
30pub use css_parser::CssParser;
31pub use diagnostic::{codes, Diagnostic, Severity};
32pub use error::{ParserError, Result};
33pub use handlebars_parser::HandlebarsParser;
34pub use webui_tokens::CssFallbackChain;
35
36use crate::component_policy::{
37    parse_component_render_policy, ComponentRenderPolicy, HYDRATE_ATTR as COMPONENT_HYDRATE_ATTR,
38    RENDER_ATTR as COMPONENT_RENDER_ATTR,
39    RESERVE_BLOCK_SIZE_ATTR as COMPONENT_RESERVE_BLOCK_SIZE_ATTR,
40};
41use crate::html_parser::{self as html, Attrs, Element, Event, Walker};
42use crate::plugin::{
43    AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
44    ElementStartContext, FragmentContext, ParserPlugin, ParserPluginArtifacts,
45};
46use std::collections::{HashMap, HashSet};
47use std::ops::Range;
48use webui_protocol::{
49    web_ui_fragment, web_ui_fragment::Fragment, BoundaryPhase, ConditionExpr, FragmentList,
50    WebUIFragment, WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentBoundary,
51    WebUiFragmentRoute,
52};
53
54/// Maximum template size accepted by the parser.
55///
56/// Build-time parsing should never need to ingest arbitrarily large single
57/// templates. This cap prevents accidental or malicious OOM-scale inputs while
58/// staying far above normal component/page sizes.
59const MAX_TEMPLATE_BYTES: usize = 16 * 1024 * 1024;
60
61/// Maximum nested HTML element depth accepted by semantic parsing.
62///
63/// The scanner itself is iterative, but semantic fragment construction still
64/// enters child ranges. This limit keeps pathological nesting from exhausting
65/// the Rust call stack while preserving generous headroom for real templates.
66const MAX_TEMPLATE_DEPTH: usize = 512;
67
68/// Prefix for compiler-owned structural signals.
69///
70/// `}}}` cannot occur in a parsed double- or triple-brace expression: it closes
71/// the binding first. This keeps transport structure distinct from every
72/// authored state key without changing the protobuf schema.
73const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
74
75fn structural_signal(value: impl AsRef<str>) -> WebUIFragment {
76    WebUIFragment::signal(
77        format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
78        true,
79    )
80}
81
82/// How an element affects the HTML insertion mode that decides whether an
83/// unknown element (the generated `<webui-hydrate>` sentinel) is
84/// foster-parented away from its payload script.
85#[derive(Clone, Copy, PartialEq, Eq)]
86enum FosterContext {
87    /// Does not change the current insertion mode.
88    Transparent,
89    /// "in table"/"in table body"/"in row"/"in column group"/"in select":
90    /// unknown elements are foster-parented out of this subtree.
91    Hostile,
92    /// `<td>`/`<th>`/`<caption>` return to "in body" rules, so a boundary
93    /// inside them is safe even though an ancestor is hostile.
94    Barrier,
95}
96
97/// Classify `name` for foster-parenting purposes.
98///
99/// Dispatching on length first means the common non-table element (`div`,
100/// `span`, `p`, `section`, …) costs one integer compare and a fallthrough,
101/// keeping this off the parser's measured hot path.
102#[inline]
103fn foster_context_of(name: &str) -> FosterContext {
104    match name.len() {
105        2 => {
106            if name.eq_ignore_ascii_case("tr") {
107                FosterContext::Hostile
108            } else if name.eq_ignore_ascii_case("td") || name.eq_ignore_ascii_case("th") {
109                FosterContext::Barrier
110            } else {
111                FosterContext::Transparent
112            }
113        }
114        5 => {
115            if name.eq_ignore_ascii_case("table")
116                || name.eq_ignore_ascii_case("tbody")
117                || name.eq_ignore_ascii_case("thead")
118                || name.eq_ignore_ascii_case("tfoot")
119            {
120                FosterContext::Hostile
121            } else {
122                FosterContext::Transparent
123            }
124        }
125        6 => {
126            if name.eq_ignore_ascii_case("select") {
127                FosterContext::Hostile
128            } else {
129                FosterContext::Transparent
130            }
131        }
132        7 => {
133            if name.eq_ignore_ascii_case("caption") {
134                FosterContext::Barrier
135            } else {
136                FosterContext::Transparent
137            }
138        }
139        8 => {
140            if name.eq_ignore_ascii_case("optgroup") || name.eq_ignore_ascii_case("colgroup") {
141                FosterContext::Hostile
142            } else {
143                FosterContext::Transparent
144            }
145        }
146        _ => FosterContext::Transparent,
147    }
148}
149
150/// Return the browser parsing context that prevents a nested boundary sentinel
151/// from becoming an active custom element.
152#[inline]
153fn boundary_parent_scope(name: &str) -> Option<&'static str> {
154    match name.len() {
155        3 if name.eq_ignore_ascii_case("xmp") => Some("<xmp> raw-text content"),
156        5 if name.eq_ignore_ascii_case("title") => Some("<title> text content"),
157        6 if name.eq_ignore_ascii_case("iframe") => Some("<iframe> raw-text content"),
158        6 if name.eq_ignore_ascii_case("script") => Some("<script> raw-text content"),
159        7 if name.eq_ignore_ascii_case("noembed") => Some("<noembed> raw-text content"),
160        8 if name.eq_ignore_ascii_case("template") => Some("<template> inert content"),
161        8 if name.eq_ignore_ascii_case("textarea") => Some("<textarea> text content"),
162        8 if name.eq_ignore_ascii_case("noframes") => Some("<noframes> raw-text content"),
163        8 if name.eq_ignore_ascii_case("noscript") => Some("<noscript> inert content"),
164        9 if name.eq_ignore_ascii_case("plaintext") => Some("<plaintext> text content"),
165        _ => None,
166    }
167}
168
169#[inline]
170fn is_html_text_only_scope(scope: Option<&str>) -> bool {
171    // Explicit allowlist of the raw-text/RCDATA scopes returned by
172    // `boundary_parent_scope`, so a new scope value must be deliberately
173    // added here to become text-only rather than silently defaulting to it.
174    // `<template> inert content` and "component host content" are
175    // intentionally excluded: both parse normal (escaped) child content.
176    matches!(
177        scope,
178        Some(
179            "<xmp> raw-text content"
180                | "<title> text content"
181                | "<iframe> raw-text content"
182                | "<script> raw-text content"
183                | "<noembed> raw-text content"
184                | "<textarea> text content"
185                | "<noframes> raw-text content"
186                | "<noscript> inert content"
187                | "<plaintext> text content"
188        )
189    )
190}
191
192/// Strategy for how component CSS is delivered in rendered output.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
194#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
195pub enum CssStrategy {
196    /// Emit `<link rel="stylesheet" href="component.css">` tags (default).
197    #[default]
198    Link,
199    /// Embed CSS content in `<style>` tags within the component.
200    Style,
201    /// Register each component's CSS module as a `<script type="importmap">`
202    /// data-URI entry. The client runtime applies the registered sheet via
203    /// the document's adopted stylesheets.
204    Module,
205}
206
207impl std::fmt::Display for CssStrategy {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        match self {
210            CssStrategy::Link => write!(f, "link"),
211            CssStrategy::Style => write!(f, "style"),
212            CssStrategy::Module => write!(f, "module"),
213        }
214    }
215}
216
217impl std::str::FromStr for CssStrategy {
218    type Err = String;
219
220    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
221        match s {
222            "link" => Ok(CssStrategy::Link),
223            "style" => Ok(CssStrategy::Style),
224            "module" => Ok(CssStrategy::Module),
225            other => Err(format!(
226                "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
227            )),
228        }
229    }
230}
231
232/// Default DOM strategy for components without an authored declarative Shadow root.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
235pub enum DomStrategy {
236    /// Wrap unwrapped component content in an open declarative Shadow root.
237    #[default]
238    Shadow,
239    /// Render unwrapped component content directly in Light DOM.
240    Light,
241}
242
243impl std::fmt::Display for DomStrategy {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        match self {
246            Self::Shadow => write!(f, "shadow"),
247            Self::Light => write!(f, "light"),
248        }
249    }
250}
251
252impl std::str::FromStr for DomStrategy {
253    type Err = String;
254
255    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
256        match value {
257            "shadow" => Ok(Self::Shadow),
258            "light" => Ok(Self::Light),
259            other => Err(format!(
260                "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
261            )),
262        }
263    }
264}
265
266/// Strategy for preserving legal comments in generated output.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
268#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
269pub enum LegalComments {
270    /// Strip every HTML and CSS comment.
271    None,
272    /// Preserve legal CSS comments inline and strip all other comments.
273    #[default]
274    Inline,
275}
276
277impl std::fmt::Display for LegalComments {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        match self {
280            LegalComments::None => write!(f, "none"),
281            LegalComments::Inline => write!(f, "inline"),
282        }
283    }
284}
285
286impl std::str::FromStr for LegalComments {
287    type Err = String;
288
289    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
290        match s {
291            "none" => Ok(LegalComments::None),
292            "inline" => Ok(LegalComments::Inline),
293            other => Err(format!(
294                "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
295            )),
296        }
297    }
298}
299
300/// Build/output options that affect parser-generated component templates.
301#[derive(Debug, Clone, Default)]
302pub struct ParserOptions {
303    /// Strategy for how component CSS is delivered.
304    pub css_strategy: CssStrategy,
305    /// Fallback DOM strategy for components without an authored Shadow root.
306    pub dom_strategy: DomStrategy,
307    /// Link-mode CSS filename/href options.
308    pub css_link_options: CssLinkOptions,
309    /// Legal comment preservation strategy.
310    pub legal_comments: LegalComments,
311}
312
313impl ParserOptions {
314    /// Create parser output options.
315    ///
316    /// # Errors
317    ///
318    /// Returns [`ParserError::Css`] when Link-mode CSS link options are invalid.
319    pub fn try_new(
320        css_strategy: CssStrategy,
321        dom_strategy: DomStrategy,
322        css_file_name_template: &str,
323        css_public_base: Option<&str>,
324        legal_comments: LegalComments,
325    ) -> Result<Self> {
326        let css_link_options = if css_strategy == CssStrategy::Link {
327            CssLinkOptions::try_new(
328                css_file_name_template.to_string(),
329                css_public_base.map(std::string::ToString::to_string),
330            )?
331        } else {
332            CssLinkOptions::default()
333        };
334
335        Ok(Self {
336            css_strategy,
337            dom_strategy,
338            css_link_options,
339            legal_comments,
340        })
341    }
342}
343
344impl From<DomStrategy> for ParserOptions {
345    fn from(dom_strategy: DomStrategy) -> Self {
346        Self {
347            dom_strategy,
348            ..Self::default()
349        }
350    }
351}
352
353impl From<CssStrategy> for ParserOptions {
354    fn from(css_strategy: CssStrategy) -> Self {
355        Self {
356            css_strategy,
357            ..Self::default()
358        }
359    }
360}
361
362impl From<(CssStrategy, DomStrategy)> for ParserOptions {
363    fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
364        Self {
365            css_strategy,
366            dom_strategy,
367            ..Self::default()
368        }
369    }
370}
371
372/// Framework plugin to load for build-time and render-time processing.
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
375pub enum Plugin {
376    /// Deprecated compatibility alias for the FAST 2 hydration plugin.
377    #[cfg_attr(feature = "cli", value(name = "fast"))]
378    Fast,
379    /// Deprecated FAST 2 hydration plugin with legacy marker output.
380    #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
381    FastV2,
382    /// FAST 3 hydration plugin with compact marker output.
383    #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
384    FastV3,
385    /// WebUI plugin — full component model with shadow DOM support.
386    #[cfg_attr(feature = "cli", value(name = "webui"))]
387    WebUI,
388}
389
390impl std::fmt::Display for Plugin {
391    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392        match self {
393            Plugin::Fast => write!(f, "fast"),
394            Plugin::FastV2 => write!(f, "fast-v2"),
395            Plugin::FastV3 => write!(f, "fast-v3"),
396            Plugin::WebUI => write!(f, "webui"),
397        }
398    }
399}
400
401impl std::str::FromStr for Plugin {
402    type Err = String;
403
404    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
405        match s {
406            "fast" => Ok(Plugin::Fast),
407            "fast-v2" => Ok(Plugin::FastV2),
408            "fast-v3" => Ok(Plugin::FastV3),
409            "webui" => Ok(Plugin::WebUI),
410            other => Err(format!(
411                "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
412            )),
413        }
414    }
415}
416
417/// Counter for generating unique fragment IDs.
418struct FragmentIdCounter {
419    /// Map of counter types to their current values.
420    counters: HashMap<String, usize>,
421}
422
423impl FragmentIdCounter {
424    /// Create a new fragment ID counter.
425    fn new() -> Self {
426        Self {
427            counters: HashMap::new(),
428        }
429    }
430
431    /// Generate a unique fragment ID.
432    fn next_id(&mut self, prefix: &str) -> String {
433        let count = self.counters.entry(prefix.to_string()).or_insert(0);
434        *count += 1;
435        format!("{}-{}", prefix, count)
436    }
437}
438
439struct ParseContext {
440    fragments: Vec<WebUIFragment>,
441    raw_buffer: String,
442}
443
444struct PendingBoundary {
445    declaration_id: u32,
446}
447
448struct BoundaryGraphEdge {
449    target: String,
450}
451
452/// A `<for>` callsite, used to name the offending repeat in a diagnostic.
453struct RepeatSite<'a> {
454    owner: &'a str,
455    item: &'a str,
456    collection: &'a str,
457}
458
459/// A boundary declaration a `<for>` repeat body can reach.
460struct RepeatBoundaryViolation {
461    repeat_owner: String,
462    each: String,
463    boundary_owner: String,
464    boundary_name: String,
465}
466
467/// Push every record a route subtree renders into `targets`.
468fn push_route_records<'a>(targets: &mut Vec<&'a str>, root: &'a WebUiFragmentRoute) {
469    let mut pending = vec![root];
470    while let Some(route) = pending.pop() {
471        targets.push(route.fragment_id.as_str());
472        targets.push(route.content_fragment_id.as_str());
473        targets.push(route.pending_component.as_str());
474        targets.push(route.error_component.as_str());
475        pending.extend(route.children.iter());
476    }
477}
478
479/// Push every record `list` renders through a lexical child edge.
480fn push_child_records<'a>(list: &'a FragmentList, targets: &mut Vec<&'a str>) {
481    for fragment in &list.fragments {
482        match fragment.fragment.as_ref() {
483            Some(Fragment::Component(component)) => targets.push(&component.fragment_id),
484            Some(Fragment::IfCond(if_cond)) => targets.push(&if_cond.fragment_id),
485            Some(Fragment::ForLoop(for_loop)) => targets.push(&for_loop.fragment_id),
486            Some(Fragment::Route(route)) => push_route_records(targets, route),
487            _ => {}
488        }
489    }
490}
491
492#[derive(Default)]
493struct FragmentCssTokens {
494    definitions: Vec<String>,
495    fallback_chains: Vec<CssFallbackChain>,
496}
497
498/// CSS token analysis produced from the parsed template/component graph.
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct CssTokenAnalysis {
501    /// Sorted, deduplicated token candidates stored in the protocol.
502    pub protocol_tokens: Vec<String>,
503    /// Fallback chains that still need theme/literal coverage after local and
504    /// ancestor custom-property definitions are considered.
505    pub fallback_chains: Vec<CssFallbackChain>,
506    /// Source location of each unresolved token's first `var()` usage, keyed by
507    /// token name. Used to point theme-validation diagnostics at the offending
508    /// CSS. Build-time only; never serialized.
509    pub(crate) token_sites: HashMap<String, TokenSite>,
510}
511
512/// Where an unresolved CSS token is first referenced, for diagnostics.
513///
514/// `owner` is a file-like label (`my-card.css`, or the entry file for inline
515/// `<style>`). `position`/`snippet` are populated for component CSS where the
516/// source is a standalone file; inline styles record the owner only.
517#[derive(Debug, Clone, PartialEq, Eq)]
518pub(crate) struct TokenSite {
519    owner: String,
520    position: Option<(usize, usize)>,
521    snippet: Option<String>,
522}
523
524enum ParseOp<'a> {
525    Parse {
526        range: Range<usize>,
527        depth: usize,
528    },
529    EmitClose(&'a str),
530    EndComponent(&'a str),
531    EndHead(&'a str),
532    EndBody(&'a str),
533    CompleteBoundary {
534        boundary: PendingBoundary,
535    },
536    /// Restore [`HtmlParser::foster_context_depth`] to the saved value when
537    /// leaving an element that changed it.
538    ///
539    /// Save/restore of the single overwritten value keeps ancestor tracking
540    /// allocation-free: no ancestor stack is retained.
541    RestoreFosterDepth(u32),
542    /// Restore the enclosing component/raw/inert context after its child range
543    /// has been parsed.
544    RestoreBoundaryParentScope(Option<&'static str>),
545    CompleteFor {
546        parent: ParseContext,
547        item: String,
548        collection: String,
549        fragment_id: String,
550        keep_empty: bool,
551        previous_for_depth: usize,
552    },
553    CompleteIf {
554        parent: ParseContext,
555        condition: ConditionExpr,
556        fragment_id: String,
557    },
558}
559
560enum TokenGraphOp<'a> {
561    EnterFragment(&'a str),
562    EnterComponent(&'a str),
563    EnterRoute(&'a WebUiFragmentRoute),
564    ExitDefinitions(&'a [String]),
565}
566
567impl Default for HtmlParser {
568    fn default() -> Self {
569        Self::new()
570    }
571}
572
573/// Parser for WebUI directives.
574pub struct HtmlParser {
575    /// CSS parser.
576    css_parser: CssParser,
577
578    /// Fragment ID counter.
579    id_counter: FragmentIdCounter,
580
581    /// Condition parser for parsing conditions in directives.
582    condition_parser: ConditionParser,
583
584    /// Handlebars parser for parsing handlebars expressions.
585    handlebars_parser: HandlebarsParser,
586
587    /// Component registry for WebUI components.
588    component_registry: ComponentRegistry,
589
590    /// Map of fragment IDs to their fragments
591    fragment_records: WebUIFragmentRecords,
592
593    /// Buffer for accumulating raw content
594    raw_buffer: String,
595
596    /// Parser output options for generated component templates.
597    options: ParserOptions,
598
599    /// Optional parser plugin for framework-specific behavior.
600    plugin: Option<Box<dyn ParserPlugin>>,
601
602    /// Static component behavior copied from the plugin at construction.
603    component_processing: ComponentProcessing,
604
605    /// Declarative Shadow DOM analysis resolved once per component tag.
606    component_dom_analyses: HashMap<String, ComponentDomAnalysis>,
607
608    /// Top-level fragments parsed by callers. Token graph traversal starts
609    /// from these roots after parsing completes.
610    token_roots: Vec<String>,
611
612    /// CSS custom property definitions and fallback chains from inline
613    /// `<style>` tags, keyed by owning fragment id.
614    fragment_css_tokens: HashMap<String, FragmentCssTokens>,
615
616    /// Fragment IDs currently being parsed, used to reject recursive component
617    /// references before they can recurse through template parsing.
618    in_progress_fragments: HashSet<String>,
619
620    /// The fragment ID (entry file or component tag) currently being parsed.
621    /// Used to name the owning template in authoring [`Diagnostic`]s.
622    current_fragment_id: String,
623
624    /// Next protocol-wide compile-time boundary declaration identity.
625    next_boundary_declaration_id: u32,
626
627    /// Authored boundary names keyed by their owning entry/component template.
628    boundary_names_by_owner: HashMap<String, HashSet<String>>,
629
630    /// `true` while parsing is inside an open `<boundary>`, used to
631    /// reject a nested boundary.
632    in_boundary: bool,
633
634    /// Number of boundary scopes surrounding the current reusable-template
635    /// callsite. Kept separate from lexical `in_boundary` so component syntax
636    /// is analyzed independently while island-owned module entries stay
637    /// excluded from critical preloads.
638    boundary_ancestor_depth: usize,
639
640    /// Number of currently open native `<body>` elements. A depth rather than
641    /// a boolean preserves the parent state while recursively parsing a
642    /// component template.
643    body_depth: usize,
644
645    /// Number of lexical `<for>` scopes in the current template. Boundaries in
646    /// such scopes can occur repeatedly and therefore require an authored key.
647    for_depth: usize,
648
649    /// Number of runtime route-content scopes currently being parsed.
650    route_depth: usize,
651
652    /// Number of enclosing elements whose HTML insertion mode would
653    /// foster-parent an unknown element such as the generated
654    /// `<webui-hydrate>` sentinel.
655    ///
656    /// Non-zero means a `<boundary>` here would be split from its
657    /// payload script by the browser's parser, so it must be rejected at build
658    /// time. `<td>`/`<th>`/`<caption>` reset it to `0` because those switch
659    /// back to "in body" insertion rules.
660    foster_context_depth: u32,
661
662    /// Enclosing component host content or native raw/inert HTML context.
663    ///
664    /// A generated sentinel in any of these contexts is either text or lives
665    /// in an inert fragment, so it cannot commit a streaming boundary.
666    boundary_parent_scope: Option<&'static str>,
667
668    /// `src` of every authored `<script type="module">` outside any
669    /// `<boundary>`, in document order, from the current top-level parse.
670    ///
671    /// These are the page's critical module entries. The build resolves each
672    /// against the projection manifest to emit `<link rel="modulepreload">`
673    /// for the shared chunks they statically import, which the browser's
674    /// preload scanner cannot discover on its own.
675    ///
676    /// Scripts *inside* a boundary are deliberately excluded: an island loader
677    /// is meant to be requested only when the parser reaches its chunk, so
678    /// preloading it would undo the deferral the author asked for.
679    module_entry_srcs: Vec<String>,
680}
681
682struct BuiltComponentTemplate {
683    ssr: String,
684    artifact: Option<String>,
685    uses_shadow_dom: bool,
686    style: Option<OwnedComponentStyle>,
687}
688
689/// Owned form of [`plugin::ComponentStyleDelivery`], held for the lifetime of a
690/// built component template so plugin context can borrow from it.
691enum OwnedComponentStyle {
692    Link(String),
693    Inline(String),
694    Adopted(String),
695}
696
697#[derive(Clone, Copy)]
698struct ComponentStyleInjection<'a> {
699    css_snippet: Option<&'a str>,
700    adopted_specifier: Option<&'a str>,
701}
702
703#[derive(Clone, Copy)]
704struct ComponentTemplateMode {
705    preserve_runtime_attrs: bool,
706    policy_wrapper: bool,
707    // Emit inline CSS after bindings for clients that scan braces.
708    styles_at_end: bool,
709}
710
711impl BuiltComponentTemplate {
712    fn artifact(&self) -> &str {
713        self.artifact.as_deref().unwrap_or(&self.ssr)
714    }
715
716    fn plugin_context<'a>(&'a self, component: &'a Component) -> ComponentBuildContext<'a> {
717        ComponentBuildContext {
718            component,
719            template: self.artifact(),
720            uses_shadow_dom: self.uses_shadow_dom,
721            style: self.style.as_ref().map(|style| match style {
722                OwnedComponentStyle::Link(href) => plugin::ComponentStyleDelivery::Link { href },
723                OwnedComponentStyle::Inline(css) => plugin::ComponentStyleDelivery::Inline { css },
724                OwnedComponentStyle::Adopted(specifier) => {
725                    plugin::ComponentStyleDelivery::Adopted { specifier }
726                }
727            }),
728        }
729    }
730}
731
732#[derive(Debug, Clone, Copy)]
733struct ComponentDomAnalysis {
734    uses_shadow_dom: bool,
735    /// Byte range of the authored declarative Shadow DOM root, when present.
736    authored_shadow_root: Option<(usize, usize)>,
737    /// Content range of a sole bare `<template>` that explicitly selects Light
738    /// DOM.
739    authored_light_root: Option<(usize, usize)>,
740}
741
742struct ShadowRootModeOccurrence<'a> {
743    element_start: usize,
744    element_end: usize,
745    element_name: &'a str,
746    has_closing_tag: bool,
747    dynamically_bound: bool,
748    attr_offset: usize,
749    value: Option<&'a str>,
750}
751
752#[cfg(test)]
753fn analyze_component_dom(
754    tag_name: &str,
755    source: &str,
756    fallback: DomStrategy,
757) -> Result<ComponentDomAnalysis> {
758    analyze_component_dom_with_diagnostic_source(tag_name, source, fallback, None)
759}
760
761fn analyze_component_dom_with_diagnostic_source(
762    tag_name: &str,
763    source: &str,
764    fallback: DomStrategy,
765    diagnostic_source: Option<&str>,
766) -> Result<ComponentDomAnalysis> {
767    let mut ranges = Vec::with_capacity(8);
768    ranges.push((0..source.len(), true));
769    let mut top_level_element_count = 0usize;
770    let mut top_level_root_start = 0usize;
771    let mut has_top_level_content = false;
772    let mut bare_template_root = None;
773    let mut shadow_mode = None;
774    let mut slot_offset = None;
775
776    while let Some((range, top_level)) = ranges.pop() {
777        for event in Walker::new_range(source, range.start, range.end) {
778            match event {
779                Event::Text(text) if top_level && !text.trim().is_empty() => {
780                    has_top_level_content = true;
781                }
782                Event::Declaration(_) if top_level => {
783                    has_top_level_content = true;
784                }
785                Event::Element(element) => {
786                    if top_level {
787                        top_level_element_count += 1;
788                        top_level_root_start = element.start;
789                        bare_template_root = (element.name().eq_ignore_ascii_case("template")
790                            && !element.self_closing()
791                            && element.attrs().next().is_none())
792                        .then_some((element.content_start, element.content_end));
793                    }
794
795                    for attr in element.attrs() {
796                        let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
797                        if attr_name.eq_ignore_ascii_case("shadowrootmode") {
798                            if shadow_mode.is_some() {
799                                return Err(invalid_shadow_root_mode_error(
800                                    tag_name,
801                                    DiagnosticSite::new(
802                                        source,
803                                        diagnostic_source,
804                                        element.start + attr.raw_range.start,
805                                    ),
806                                    "a component may declare only one `shadowrootmode`",
807                                    "keep one `shadowrootmode=\"open\"` attribute on the sole top-level `<template>`",
808                                ));
809                            }
810                            shadow_mode = Some(ShadowRootModeOccurrence {
811                                element_start: element.start,
812                                element_end: element.close_end(),
813                                element_name: element.name(),
814                                has_closing_tag: !element.self_closing()
815                                    && element.close_end() > element.content_end(),
816                                dynamically_bound: attr.name != attr_name,
817                                attr_offset: element.start + attr.raw_range.start,
818                                value: attr.value,
819                            });
820                        }
821                    }
822
823                    if slot_offset.is_none() && element.name().eq_ignore_ascii_case("slot") {
824                        slot_offset = Some(element.start);
825                    }
826
827                    let name = element.name();
828                    let raw_text = name.eq_ignore_ascii_case("script")
829                        || name.eq_ignore_ascii_case("style")
830                        || name.eq_ignore_ascii_case("textarea")
831                        || name.eq_ignore_ascii_case("title");
832                    if element.content_end() > element.inner().start && !raw_text {
833                        ranges.push((element.inner(), false));
834                    }
835                }
836                Event::Text(_)
837                | Event::Comment(_)
838                | Event::Declaration(_)
839                | Event::ClosingTag(_) => {}
840            }
841        }
842    }
843
844    let authored_shadow_root = match shadow_mode {
845        Some(mode) => {
846            let value = mode.value;
847            if mode.dynamically_bound {
848                return Err(invalid_shadow_root_mode_error(
849                    tag_name,
850                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
851                    "`shadowrootmode` must be a static attribute",
852                    "replace the binding with `shadowrootmode=\"open\"` on the sole top-level `<template>`",
853                ));
854            }
855            if value.is_some_and(|value| value.eq_ignore_ascii_case("closed")) {
856                return Err(invalid_shadow_root_mode_error(
857                    tag_name,
858                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
859                    "`shadowrootmode=\"closed\"` is not supported",
860                    "use `shadowrootmode=\"open\"`; WebUI requires an open root for hydration and style delivery",
861                ));
862            }
863            if !value.is_some_and(|value| value.eq_ignore_ascii_case("open")) {
864                return Err(invalid_shadow_root_mode_error(
865                    tag_name,
866                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
867                    "`shadowrootmode` must be `open`",
868                    "set the attribute to `shadowrootmode=\"open\"`",
869                ));
870            }
871            if top_level_element_count != 1
872                || has_top_level_content
873                || mode.element_start != top_level_root_start
874                || !mode.element_name.eq_ignore_ascii_case("template")
875                || !mode.has_closing_tag
876            {
877                return Err(invalid_shadow_root_mode_error(
878                    tag_name,
879                    DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
880                    "the declarative Shadow DOM wrapper must be the sole top-level element",
881                    "wrap the complete component in one top-level `<template shadowrootmode=\"open\">`",
882                ));
883            }
884            Some((mode.element_start, mode.element_end))
885        }
886        None => None,
887    };
888
889    let authored_light_root =
890        if authored_shadow_root.is_none() && top_level_element_count == 1 && !has_top_level_content
891        {
892            bare_template_root
893        } else {
894            None
895        };
896    let uses_shadow_dom = authored_shadow_root.is_some()
897        || (authored_light_root.is_none() && fallback == DomStrategy::Shadow);
898    if !uses_shadow_dom {
899        if let Some(offset) = slot_offset {
900            return Err(light_dom_slot_error(
901                tag_name,
902                DiagnosticSite::new(source, diagnostic_source, offset),
903            ));
904        }
905    }
906
907    Ok(ComponentDomAnalysis {
908        uses_shadow_dom,
909        authored_shadow_root,
910        authored_light_root,
911    })
912}
913
914#[cold]
915#[inline(never)]
916fn invalid_shadow_root_mode_error(
917    tag_name: &str,
918    site: DiagnosticSite<'_>,
919    title: &str,
920    help: &str,
921) -> ParserError {
922    let (source, offset) = site.resolve(DiagnosticTarget::Attribute("shadowrootmode"));
923    Diagnostic::error(title)
924        .code(codes::INVALID_SHADOW_ROOT_MODE)
925        .component(tag_name)
926        .at_offset(source, offset)
927        .snippet(source_line_snippet(source, offset))
928        .help(help)
929        .into()
930}
931
932#[cold]
933#[inline(never)]
934fn light_dom_slot_error(tag_name: &str, site: DiagnosticSite<'_>) -> ParserError {
935    let (source, offset) = site.resolve(DiagnosticTarget::Element("slot"));
936    Diagnostic::error("native `<slot>` projection requires Shadow DOM")
937        .code(codes::LIGHT_DOM_SLOT)
938        .component(tag_name)
939        .element("slot")
940        .at_offset(source, offset)
941        .snippet(source_line_snippet(source, offset))
942        .help(
943            "wrap the complete component template in `<template shadowrootmode=\"open\">` to use `<slot>`",
944        )
945        .into()
946}
947
948#[derive(Clone, Copy)]
949struct DiagnosticSite<'a> {
950    source: &'a str,
951    authored_source: Option<&'a str>,
952    offset: usize,
953}
954
955impl<'a> DiagnosticSite<'a> {
956    fn new(source: &'a str, authored_source: Option<&'a str>, offset: usize) -> Self {
957        Self {
958            source,
959            authored_source,
960            offset,
961        }
962    }
963
964    fn resolve(self, target: DiagnosticTarget) -> (&'a str, usize) {
965        let Some(authored_source) = self.authored_source else {
966            return (self.source, self.offset);
967        };
968        let transformed_offsets = diagnostic_target_offsets(self.source, target);
969        let occurrence = transformed_offsets
970            .iter()
971            .position(|offset| *offset == self.offset)
972            .unwrap_or(0);
973        let authored_offsets = diagnostic_target_offsets(authored_source, target);
974        authored_offsets
975            .get(occurrence)
976            .copied()
977            .map_or((self.source, self.offset), |offset| {
978                (authored_source, offset)
979            })
980    }
981}
982
983#[derive(Clone, Copy)]
984enum DiagnosticTarget {
985    Element(&'static str),
986    Attribute(&'static str),
987}
988
989#[cold]
990#[inline(never)]
991fn diagnostic_target_offsets(source: &str, target: DiagnosticTarget) -> Vec<usize> {
992    let mut offsets = Vec::new();
993    let mut ranges = Vec::with_capacity(4);
994    ranges.push(0..source.len());
995
996    while let Some(range) = ranges.pop() {
997        for event in Walker::new_range(source, range.start, range.end) {
998            let Event::Element(element) = event else {
999                continue;
1000            };
1001            match target {
1002                DiagnosticTarget::Element(name) if element.name().eq_ignore_ascii_case(name) => {
1003                    offsets.push(element.start);
1004                }
1005                DiagnosticTarget::Attribute(name) => {
1006                    for attr in element.attrs() {
1007                        let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1008                        if attr_name.eq_ignore_ascii_case(name) {
1009                            offsets.push(element.start + attr.raw_range.start);
1010                        }
1011                    }
1012                }
1013                DiagnosticTarget::Element(_) => {}
1014            }
1015
1016            let name = element.name();
1017            let raw_text = name.eq_ignore_ascii_case("script")
1018                || name.eq_ignore_ascii_case("style")
1019                || name.eq_ignore_ascii_case("textarea")
1020                || name.eq_ignore_ascii_case("title");
1021            if element.content_end() > element.inner().start && !raw_text {
1022                ranges.push(element.inner());
1023            }
1024        }
1025    }
1026    offsets.sort_unstable();
1027    offsets
1028}
1029
1030struct ComponentTemplateInput<'a> {
1031    tag_name: &'a str,
1032    html: &'a str,
1033    artifact_html: Option<&'a str>,
1034    authored_html: Option<&'a str>,
1035    css_content: Option<&'a str>,
1036    artifact_needed: bool,
1037}
1038
1039fn add_token_definitions(definitions: &[String], available_counts: &mut HashMap<String, usize>) {
1040    for definition in definitions {
1041        let count = available_counts.entry(definition.clone()).or_insert(0);
1042        *count += 1;
1043    }
1044}
1045
1046fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<String, usize>) {
1047    for definition in definitions {
1048        if let Some(count) = available_counts.get_mut(definition) {
1049            if *count == 1 {
1050                available_counts.remove(definition);
1051            } else {
1052                *count -= 1;
1053            }
1054        }
1055    }
1056}
1057
1058/// Accumulators for the token-graph walk: the unresolved fallback chains and
1059/// the per-token source locations, grouped so walk helpers stay within the
1060/// argument-count budget.
1061#[derive(Default)]
1062struct UnresolvedTokens {
1063    chains: Vec<CssFallbackChain>,
1064    sites: HashMap<String, TokenSite>,
1065}
1066
1067fn record_unresolved_requirements(
1068    source: &[CssFallbackChain],
1069    available_counts: &HashMap<String, usize>,
1070    owner: &str,
1071    css_source: Option<&str>,
1072    out: &mut UnresolvedTokens,
1073) {
1074    for requirement in source {
1075        let tokens: Vec<String> = requirement
1076            .tokens
1077            .iter()
1078            .filter(|token| !available_counts.contains_key(*token))
1079            .cloned()
1080            .collect();
1081        if tokens.is_empty() {
1082            continue;
1083        }
1084        for token in &tokens {
1085            if !out.sites.contains_key(token) {
1086                out.sites
1087                    .insert(token.clone(), token_site(owner, css_source, token));
1088            }
1089        }
1090        out.chains.push(CssFallbackChain {
1091            tokens,
1092            has_literal_fallback: requirement.has_literal_fallback,
1093        });
1094    }
1095}
1096
1097/// Build the [`TokenSite`] for `token`. When `css_source` is available (a
1098/// component's standalone CSS), the token's `var()` usage is located for a
1099/// precise `line:column` and snippet; otherwise only the `owner` is recorded.
1100fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
1101    let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
1102        Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
1103        None => (None, None),
1104    };
1105    TokenSite {
1106        owner: owner.to_string(),
1107        position,
1108        snippet,
1109    }
1110}
1111
1112/// Locate the first `var(--token)` **usage** (not a `--token:` definition) in
1113/// `css`, returning its 1-based `(line, column)` and a one-line snippet.
1114///
1115/// Iterative byte scan — no regex, no recursion. Cold-ish (build time, only for
1116/// unresolved tokens).
1117fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
1118    let bytes = css.as_bytes();
1119    let name = token.as_bytes();
1120    let mut index = 0;
1121    while index + 2 + name.len() <= bytes.len() {
1122        let is_prefixed = bytes[index] == b'-'
1123            && bytes[index + 1] == b'-'
1124            && bytes.get(index + 2..index + 2 + name.len()) == Some(name);
1125        if is_prefixed {
1126            let after = index + 2 + name.len();
1127            let is_exact_name = bytes
1128                .get(after)
1129                .is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
1130            if is_exact_name {
1131                let mut cursor = after;
1132                while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
1133                    cursor += 1;
1134                }
1135                // `--token:` is a definition, not the usage we want to point at.
1136                if bytes.get(cursor) != Some(&b':') {
1137                    let (line, column) = diagnostic::line_column(css, index);
1138                    return Some((line, column, source_line_snippet(css, index)));
1139                }
1140            }
1141        }
1142        index += 1;
1143    }
1144    None
1145}
1146
1147/// Extract the trimmed line containing byte `offset`, capped so minified input
1148/// does not produce a wall-of-text snippet.
1149fn source_line_snippet(source: &str, offset: usize) -> String {
1150    const MAX_SNIPPET: usize = 80;
1151    let start = source[..offset].rfind('\n').map_or(0, |n| n + 1);
1152    let end = source[offset..]
1153        .find('\n')
1154        .map_or(source.len(), |n| offset + n);
1155    let line = source[start..end].trim();
1156    if line.chars().count() > MAX_SNIPPET {
1157        let truncated: String = line.chars().take(MAX_SNIPPET).collect();
1158        format!("{truncated}…")
1159    } else {
1160        line.to_string()
1161    }
1162}
1163
1164/// File-like owner label for a component's standalone CSS
1165/// (`my-card` → `my-card.css`), used as the `--> owner:line:column` prefix.
1166fn css_owner_label(tag_name: &str) -> String {
1167    let mut label = String::with_capacity(tag_name.len() + 4);
1168    label.push_str(tag_name);
1169    label.push_str(".css");
1170    label
1171}
1172
1173impl CssTokenAnalysis {
1174    /// Validate the required CSS tokens against a loaded design-token theme.
1175    ///
1176    /// A token is *required* in every theme only when it appears in at least one
1177    /// unresolved `var()` chain with **no** literal CSS fallback. A chain such as
1178    /// `var(--x, 16px)` provides its own value, so `--x` stays in
1179    /// [`protocol_tokens`](Self::protocol_tokens) for runtime resolution (the
1180    /// theme value still wins when present) but does not fail the build when the
1181    /// theme omits it. The chain/theme policy lives in
1182    /// [`webui_tokens::validate_chain_tokens`]; this only adapts its
1183    /// [`webui_tokens::TokenError`] into a structured [`Diagnostic`].
1184    ///
1185    /// # Errors
1186    ///
1187    /// Returns [`ParserError::Template`] with a stable diagnostic code when a
1188    /// required token is missing from a theme. Theme token values are trusted and
1189    /// their transitive references are left to browser CSS semantics.
1190    pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
1191        webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
1192            .map_err(|source| self.theme_token_error(source, theme))
1193    }
1194
1195    /// CSS tokens used **only** with a literal `var()` fallback and defined in no
1196    /// theme — likely typos that callers may surface as non-fatal advisories.
1197    ///
1198    /// Thin wrapper over [`webui_tokens::unthemed_literal_fallback_tokens`].
1199    #[must_use]
1200    pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
1201        webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
1202    }
1203
1204    /// Structured, color-free advisories for CSS tokens used only with a
1205    /// literal `var()` fallback and defined in no theme — almost always typos.
1206    ///
1207    /// Returned as warning-severity [`Diagnostic`]s so hosts render them with
1208    /// the same location/snippet/`help:` layout as errors (the CLI colorizes;
1209    /// other hosts use the plain [`Diagnostic::body`]). Each carries the token's
1210    /// source location and a `did you mean --…?` suggestion.
1211    #[must_use]
1212    pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
1213        let mut warnings = Vec::new();
1214        for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
1215            let help = match closest_theme_token(&token, theme.themes.values()) {
1216                Some(suggestion) => {
1217                    format!("did you mean --{suggestion}? otherwise the literal fallback is used")
1218                }
1219                None => format!(
1220                    "define --{token} in the theme, or keep its literal fallback if intentional"
1221                ),
1222            };
1223            let diag = self.locate_diagnostic(
1224                Diagnostic::warning(format!("unthemed CSS token --{token}"))
1225                    .code(codes::UNTHEMED_TOKEN)
1226                    .help(help),
1227                &token,
1228            );
1229            warnings.push(diag);
1230        }
1231        warnings
1232    }
1233
1234    /// Adapt a [`webui_tokens::TokenError`] into a structured [`Diagnostic`],
1235    /// enriching it with the token's source location and a `did you mean …?`
1236    /// suggestion drawn from the theme's own tokens.
1237    #[cold]
1238    #[inline(never)]
1239    fn theme_token_error(
1240        &self,
1241        source: webui_tokens::TokenError,
1242        theme: &webui_tokens::TokenFile,
1243    ) -> ParserError {
1244        match source {
1245            webui_tokens::TokenError::MissingToken {
1246                theme: theme_name,
1247                token,
1248            } => {
1249                // The error title + snippet already say the token is missing
1250                // from the theme, so the help only adds what isn't obvious: the
1251                // likely-typo suggestion and the local-definition escape hatch.
1252                let help =
1253                    match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
1254                        Some(suggestion) => {
1255                            format!("did you mean --{suggestion}? otherwise define it locally")
1256                        }
1257                        None => {
1258                            "define it locally if it should not come from the theme".to_string()
1259                        }
1260                    };
1261                self.locate_diagnostic(
1262                    Diagnostic::error("missing theme token")
1263                        .code(codes::MISSING_THEME_TOKEN)
1264                        .help(help),
1265                    &token,
1266                )
1267                .into()
1268            }
1269            other => ParserError::Generic(format!("Theme token validation failed: {other}")),
1270        }
1271    }
1272
1273    /// Attach `token`'s recorded source location (owner, `line:column`, snippet)
1274    /// to `diag`. Falls back to a `--token` snippet when no location is known.
1275    fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
1276        let Some(site) = self.token_sites.get(token) else {
1277            return diag.snippet(format!("--{token}"));
1278        };
1279        let mut diag = diag.component(site.owner.clone());
1280        if let Some((line, column)) = site.position {
1281            diag = diag.position(line, column);
1282        }
1283        match &site.snippet {
1284            Some(snippet) => diag.snippet(snippet.clone()),
1285            None => diag.snippet(format!("--{token}")),
1286        }
1287    }
1288}
1289
1290/// Closest theme token to `target` by Levenshtein distance across the given
1291/// theme token maps, considering each theme's keys. Sorted for determinism.
1292#[cold]
1293#[inline(never)]
1294fn closest_theme_token<'a>(
1295    target: &str,
1296    themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
1297) -> Option<String> {
1298    let mut names: Vec<&str> = themes
1299        .flat_map(|theme| theme.keys().map(String::as_str))
1300        .collect();
1301    names.sort_unstable();
1302    names.dedup();
1303    crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
1304}
1305
1306impl HtmlParser {
1307    /// Create a new directive parser with default parser options.
1308    #[must_use]
1309    pub fn new() -> Self {
1310        Self::with_options(ParserOptions::default())
1311    }
1312
1313    /// Create a new directive parser with explicit parser options.
1314    #[must_use]
1315    pub fn with_options(options: impl Into<ParserOptions>) -> Self {
1316        let options = options.into();
1317
1318        Self {
1319            component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
1320            css_parser: CssParser::new(),
1321            id_counter: FragmentIdCounter::new(),
1322            condition_parser: ConditionParser::new(),
1323            handlebars_parser: HandlebarsParser::new(),
1324            raw_buffer: String::new(),
1325            fragment_records: WebUIFragmentRecords::new(),
1326            options,
1327            plugin: None,
1328            component_processing: ComponentProcessing::default(),
1329            component_dom_analyses: HashMap::new(),
1330            token_roots: Vec::new(),
1331            fragment_css_tokens: HashMap::new(),
1332            in_progress_fragments: HashSet::new(),
1333            current_fragment_id: String::new(),
1334            next_boundary_declaration_id: 0,
1335            boundary_names_by_owner: HashMap::new(),
1336            in_boundary: false,
1337            boundary_ancestor_depth: 0,
1338            body_depth: 0,
1339            for_depth: 0,
1340            route_depth: 0,
1341            foster_context_depth: 0,
1342            boundary_parent_scope: None,
1343            module_entry_srcs: Vec::new(),
1344        }
1345    }
1346
1347    /// Create a new parser with a plugin and default parser options.
1348    #[must_use]
1349    pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
1350        Self::with_plugin_options(plugin, ParserOptions::default())
1351    }
1352
1353    /// Create a new parser with a plugin and explicit parser options.
1354    #[must_use]
1355    pub fn with_plugin_options(
1356        mut plugin: Box<dyn ParserPlugin>,
1357        options: impl Into<ParserOptions>,
1358    ) -> Self {
1359        let mut p = Self::with_options(options);
1360        plugin.configure_parser(&p.options);
1361        let component_processing = plugin.component_processing();
1362        p.component_registry
1363            .set_component_source_transform(component_processing.source_transform);
1364        p.component_processing = component_processing;
1365        p.plugin = Some(plugin);
1366        p
1367    }
1368
1369    /// Get a mutable reference to the component registry.
1370    pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
1371        &mut self.component_registry
1372    }
1373
1374    /// Get a shared reference to the component registry.
1375    #[must_use]
1376    pub fn component_registry(&self) -> &ComponentRegistry {
1377        &self.component_registry
1378    }
1379
1380    pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
1381        std::mem::take(&mut self.fragment_records)
1382    }
1383
1384    /// Check if a fragment ID has been parsed (exists in the fragment records).
1385    pub fn has_fragment(&self, fragment_id: &str) -> bool {
1386        self.fragment_records.contains_key(fragment_id)
1387    }
1388
1389    /// Compute transitive boundary presence and conservative declaration
1390    /// repeatability for the complete parsed fragment graph, and reject every
1391    /// boundary a `<for>` repeat body can reach.
1392    ///
1393    /// This runs only after a top-level parse succeeds. Every analysis is
1394    /// iterative: presence propagates through reverse edges, repeat
1395    /// reachability is a multi-source forward walk, and occurrence counts use a
1396    /// two-value (once/many) fixed point. Cycles therefore converge without
1397    /// recursion and are conservatively classified as repeatable.
1398    #[cold]
1399    #[inline(never)]
1400    fn finalize_boundary_metadata(&mut self) -> Result<()> {
1401        if self.next_boundary_declaration_id != 0 {
1402            let (outgoing, direct_boundary_records) = self.boundary_graph();
1403            self.mark_boundary_records(&outgoing, direct_boundary_records);
1404            if let Some(violation) = self.find_repeat_reachable_boundary() {
1405                return Err(Self::repeat_reachable_boundary_error(&violation));
1406            }
1407            let occurrence_counts = self.boundary_occurrence_counts(&outgoing);
1408
1409            let mut unkeyed_repeat: Option<(u32, String, String)> = None;
1410            for (fragment_id, list) in &mut self.fragment_records {
1411                let repeated = occurrence_counts
1412                    .get(fragment_id)
1413                    .is_some_and(|count| *count > 1);
1414                if !repeated {
1415                    continue;
1416                }
1417                for fragment in &mut list.fragments {
1418                    if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_mut() {
1419                        if boundary.phase() == BoundaryPhase::Start {
1420                            boundary.may_repeat = true;
1421                            if boundary.key.is_none()
1422                                && unkeyed_repeat
1423                                    .as_ref()
1424                                    .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1425                            {
1426                                unkeyed_repeat = Some((
1427                                    boundary.declaration_id,
1428                                    boundary.owner_fragment_id.clone(),
1429                                    boundary.name.clone(),
1430                                ));
1431                            }
1432                        }
1433                    }
1434                }
1435            }
1436            if let Some((_, owner, name)) = unkeyed_repeat {
1437                return Err(Self::missing_boundary_key_error(&owner, &name));
1438            }
1439
1440            if let Some((owner, name)) = self.find_transitively_nested_boundary() {
1441                return Err(Self::transitively_nested_boundary_error(&owner, &name));
1442            }
1443        }
1444        self.finalize_component_span_signals();
1445        Ok(())
1446    }
1447
1448    fn finalize_component_span_signals(&mut self) {
1449        const START: &str = "streaming_span_start:";
1450        const END: &str = "streaming_span_end:";
1451
1452        let spanning: HashSet<String> = self
1453            .fragment_records
1454            .iter()
1455            .filter_map(|(id, list)| list.contains_boundary.then_some(id.clone()))
1456            .collect();
1457        for list in self.fragment_records.values_mut() {
1458            let mut normalized = Vec::with_capacity(list.fragments.len());
1459            for fragment in list.fragments.drain(..) {
1460                let remove = match fragment.fragment.as_ref() {
1461                    Some(Fragment::Signal(signal)) => signal
1462                        .value
1463                        .strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1464                        .and_then(|value| {
1465                            value
1466                                .strip_prefix(START)
1467                                .or_else(|| value.strip_prefix(END))
1468                        })
1469                        .is_some_and(|tag| !spanning.contains(tag)),
1470                    _ => false,
1471                };
1472                if remove {
1473                    continue;
1474                }
1475                if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
1476                    if let Some(WebUIFragment {
1477                        fragment: Some(Fragment::Raw(previous)),
1478                    }) = normalized.last_mut()
1479                    {
1480                        previous.value.push_str(&raw.value);
1481                        continue;
1482                    }
1483                }
1484                normalized.push(fragment);
1485            }
1486            list.fragments = normalized;
1487        }
1488    }
1489
1490    fn boundary_graph(&self) -> (HashMap<String, Vec<BoundaryGraphEdge>>, Vec<String>) {
1491        let mut outgoing: HashMap<String, Vec<BoundaryGraphEdge>> =
1492            HashMap::with_capacity(self.fragment_records.len());
1493        let mut direct_boundary_records = Vec::new();
1494
1495        for (fragment_id, list) in &self.fragment_records {
1496            let edges = outgoing.entry(fragment_id.clone()).or_default();
1497            for fragment in &list.fragments {
1498                match fragment.fragment.as_ref() {
1499                    Some(Fragment::Component(component)) => {
1500                        Self::push_boundary_edge(edges, &component.fragment_id);
1501                    }
1502                    Some(Fragment::ForLoop(for_loop)) => {
1503                        Self::push_boundary_edge(edges, &for_loop.fragment_id);
1504                    }
1505                    Some(Fragment::IfCond(if_cond)) => {
1506                        Self::push_boundary_edge(edges, &if_cond.fragment_id);
1507                    }
1508                    Some(Fragment::Boundary(boundary)) => {
1509                        if boundary.phase() == BoundaryPhase::Start {
1510                            direct_boundary_records.push(fragment_id.clone());
1511                        }
1512                    }
1513                    Some(Fragment::Route(route)) => {
1514                        Self::push_route_boundary_edges(edges, route);
1515                    }
1516                    _ => {}
1517                }
1518            }
1519        }
1520        (outgoing, direct_boundary_records)
1521    }
1522
1523    fn push_boundary_edge(edges: &mut Vec<BoundaryGraphEdge>, target: &str) {
1524        if !target.is_empty() {
1525            edges.push(BoundaryGraphEdge {
1526                target: target.to_string(),
1527            });
1528        }
1529    }
1530
1531    fn push_route_boundary_edges(edges: &mut Vec<BoundaryGraphEdge>, root: &WebUiFragmentRoute) {
1532        let mut pending = vec![root];
1533        while let Some(route) = pending.pop() {
1534            Self::push_boundary_edge(edges, &route.fragment_id);
1535            Self::push_boundary_edge(edges, &route.content_fragment_id);
1536            Self::push_boundary_edge(edges, &route.pending_component);
1537            Self::push_boundary_edge(edges, &route.error_component);
1538            pending.extend(route.children.iter());
1539        }
1540    }
1541
1542    fn mark_boundary_records(
1543        &mut self,
1544        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1545        direct_boundary_records: Vec<String>,
1546    ) {
1547        for list in self.fragment_records.values_mut() {
1548            list.contains_boundary = false;
1549        }
1550
1551        let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
1552        for (parent, edges) in outgoing {
1553            for edge in edges {
1554                reverse
1555                    .entry(edge.target.as_str())
1556                    .or_default()
1557                    .push(parent.as_str());
1558            }
1559        }
1560
1561        let mut marked = HashSet::with_capacity(self.fragment_records.len());
1562        let mut pending = direct_boundary_records;
1563        while let Some(fragment_id) = pending.pop() {
1564            if !marked.insert(fragment_id.clone()) {
1565                continue;
1566            }
1567            if let Some(list) = self.fragment_records.get_mut(&fragment_id) {
1568                list.contains_boundary = true;
1569            }
1570            if let Some(parents) = reverse.get(fragment_id.as_str()) {
1571                pending.extend(parents.iter().map(|parent| (*parent).to_string()));
1572            }
1573        }
1574    }
1575
1576    fn boundary_occurrence_counts(
1577        &self,
1578        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1579    ) -> HashMap<String, u8> {
1580        let mut incoming: HashMap<&str, Vec<&str>> = HashMap::new();
1581        for (parent, edges) in outgoing {
1582            for edge in edges {
1583                incoming
1584                    .entry(edge.target.as_str())
1585                    .or_default()
1586                    .push(parent.as_str());
1587            }
1588        }
1589
1590        let mut aggregate = HashMap::with_capacity(self.fragment_records.len());
1591        for root in &self.token_roots {
1592            if !self.fragment_records.contains_key(root) {
1593                continue;
1594            }
1595            let counts = self.boundary_occurrences_from_root(root.as_str(), outgoing, &incoming);
1596            for (fragment_id, count) in counts {
1597                let current = aggregate.entry(fragment_id).or_insert(0);
1598                *current = (*current).max(count);
1599            }
1600        }
1601        aggregate
1602    }
1603
1604    fn boundary_occurrences_from_root(
1605        &self,
1606        root: &str,
1607        outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1608        incoming: &HashMap<&str, Vec<&str>>,
1609    ) -> HashMap<String, u8> {
1610        let mut counts: HashMap<String, u8> = HashMap::with_capacity(self.fragment_records.len());
1611        counts.insert(root.to_string(), 1);
1612        let mut pending = vec![root.to_string()];
1613        while let Some(parent) = pending.pop() {
1614            let Some(edges) = outgoing.get(&parent) else {
1615                continue;
1616            };
1617            for edge in edges {
1618                let next = Self::recompute_boundary_occurrences(
1619                    edge.target.as_str(),
1620                    root,
1621                    incoming,
1622                    &counts,
1623                );
1624                let current = counts.get(&edge.target).copied().unwrap_or(0);
1625                if next > current {
1626                    counts.insert(edge.target.clone(), next);
1627                    pending.push(edge.target.clone());
1628                }
1629            }
1630        }
1631        counts
1632    }
1633
1634    fn recompute_boundary_occurrences(
1635        fragment_id: &str,
1636        root: &str,
1637        incoming: &HashMap<&str, Vec<&str>>,
1638        counts: &HashMap<String, u8>,
1639    ) -> u8 {
1640        let mut count = u8::from(fragment_id == root);
1641        let Some(parents) = incoming.get(fragment_id) else {
1642            return count;
1643        };
1644        for parent in parents {
1645            let parent_count = counts.get(*parent).copied().unwrap_or(0);
1646            count = count.saturating_add(parent_count).min(2);
1647            if count == 2 {
1648                break;
1649            }
1650        }
1651        count
1652    }
1653
1654    /// Locate the lowest-numbered declaration a `<for>` repeat body reaches.
1655    ///
1656    /// Reachability is a single multi-source forward walk seeded with every
1657    /// repeat body record, so a graph with many loops still costs one pass. It
1658    /// follows the same component/condition/loop/route edges the presence
1659    /// analysis uses, plus the route-child records an enclosing route mounts at
1660    /// its component's `<outlet />` — the one runtime edge that has no
1661    /// lexical counterpart.
1662    fn find_repeat_reachable_boundary(&self) -> Option<RepeatBoundaryViolation> {
1663        let mut origin: HashMap<&str, &str> = HashMap::new();
1664        let mut pending: Vec<&str> = Vec::new();
1665        let mut repeats: HashMap<&str, RepeatSite<'_>> = HashMap::new();
1666        let mut seeds: Vec<(&str, RepeatSite<'_>)> = Vec::new();
1667
1668        for (owner, list) in &self.fragment_records {
1669            for fragment in &list.fragments {
1670                let Some(Fragment::ForLoop(for_loop)) = fragment.fragment.as_ref() else {
1671                    continue;
1672                };
1673                if for_loop.fragment_id.is_empty() {
1674                    continue;
1675                }
1676                let body = for_loop.fragment_id.as_str();
1677                seeds.push((
1678                    body,
1679                    RepeatSite {
1680                        owner: owner.as_str(),
1681                        item: for_loop.item.as_str(),
1682                        collection: for_loop.collection.as_str(),
1683                    },
1684                ));
1685            }
1686        }
1687        seeds.sort_unstable_by(|(left_body, left), (right_body, right)| {
1688            (left.owner, left.item, left.collection, *left_body).cmp(&(
1689                right.owner,
1690                right.item,
1691                right.collection,
1692                *right_body,
1693            ))
1694        });
1695        for (body, site) in seeds {
1696            if !origin.contains_key(body) {
1697                repeats.insert(body, site);
1698                origin.insert(body, body);
1699                pending.push(body);
1700            }
1701        }
1702        // `pending` is a LIFO stack. Reverse the sorted seeds so the lowest
1703        // repeat site owns every shared reachable record deterministically.
1704        pending.reverse();
1705        if pending.is_empty() {
1706            return None;
1707        }
1708
1709        let mounts = self.route_mount_targets();
1710        let mut found: Option<(u32, &str, &WebUiFragmentBoundary)> = None;
1711        let mut targets: Vec<&str> = Vec::new();
1712        while let Some(id) = pending.pop() {
1713            let Some(seed) = origin.get(id).copied() else {
1714                continue;
1715            };
1716            let Some(list) = self.fragment_records.get(id) else {
1717                continue;
1718            };
1719            for fragment in &list.fragments {
1720                if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
1721                    if boundary.phase() == BoundaryPhase::Start
1722                        && found
1723                            .as_ref()
1724                            .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1725                    {
1726                        found = Some((boundary.declaration_id, seed, boundary));
1727                    }
1728                }
1729            }
1730            targets.clear();
1731            push_child_records(list, &mut targets);
1732            if let Some(mounted) = mounts.get(id) {
1733                targets.extend(mounted.iter().copied());
1734            }
1735            for target in targets.drain(..) {
1736                if target.is_empty() {
1737                    continue;
1738                }
1739                if !origin.contains_key(target) {
1740                    origin.insert(target, seed);
1741                    pending.push(target);
1742                }
1743            }
1744        }
1745
1746        found.map(|(_, seed, boundary)| {
1747            let site = repeats.get(seed);
1748            RepeatBoundaryViolation {
1749                repeat_owner: site.map_or_else(String::new, |site| site.owner.to_string()),
1750                each: site.map_or_else(String::new, |site| {
1751                    format!("{} in {}", site.item, site.collection)
1752                }),
1753                boundary_owner: boundary.owner_fragment_id.clone(),
1754                boundary_name: boundary.name.clone(),
1755            }
1756        })
1757    }
1758
1759    /// Map every `<outlet />`-hosting record to the child-route records a
1760    /// route can mount there.
1761    ///
1762    /// `<outlet />` is the only render edge with no lexical counterpart: the
1763    /// mounted record is chosen from the enclosing route's children at request
1764    /// time. Resolving it needs the parent route's render closure, so the map
1765    /// is built once and reused by the repeat walk instead of being recomputed
1766    /// per outlet.
1767    fn route_mount_targets(&self) -> HashMap<&str, Vec<&str>> {
1768        let outlet_hosts = self.outlet_host_records();
1769        if outlet_hosts.is_empty() {
1770            return HashMap::new();
1771        }
1772
1773        let mut mounts: HashMap<&str, Vec<&str>> = HashMap::new();
1774        let mut children: Vec<&str> = Vec::new();
1775        let mut closure: HashSet<&str> = HashSet::new();
1776        let mut pending: Vec<&str> = Vec::new();
1777        let mut targets: Vec<&str> = Vec::new();
1778        for list in self.fragment_records.values() {
1779            for fragment in &list.fragments {
1780                let Some(Fragment::Route(root)) = fragment.fragment.as_ref() else {
1781                    continue;
1782                };
1783                let mut routes = vec![root];
1784                while let Some(route) = routes.pop() {
1785                    routes.extend(route.children.iter());
1786                    if route.children.is_empty() {
1787                        continue;
1788                    }
1789                    children.clear();
1790                    for child in &route.children {
1791                        push_route_records(&mut children, child);
1792                    }
1793                    closure.clear();
1794                    pending.clear();
1795                    for host in [
1796                        route.fragment_id.as_str(),
1797                        route.content_fragment_id.as_str(),
1798                    ] {
1799                        if !host.is_empty() && closure.insert(host) {
1800                            pending.push(host);
1801                        }
1802                    }
1803                    while let Some(id) = pending.pop() {
1804                        if outlet_hosts.contains(id) {
1805                            mounts
1806                                .entry(id)
1807                                .or_default()
1808                                .extend(children.iter().copied());
1809                        }
1810                        let Some(list) = self.fragment_records.get(id) else {
1811                            continue;
1812                        };
1813                        targets.clear();
1814                        push_child_records(list, &mut targets);
1815                        for target in targets.drain(..) {
1816                            if !target.is_empty() && closure.insert(target) {
1817                                pending.push(target);
1818                            }
1819                        }
1820                    }
1821                }
1822            }
1823        }
1824        mounts
1825    }
1826
1827    /// Records that render an `<outlet />`.
1828    fn outlet_host_records(&self) -> HashSet<&str> {
1829        self.fragment_records
1830            .iter()
1831            .filter(|(_, list)| {
1832                list.fragments
1833                    .iter()
1834                    .any(|fragment| matches!(fragment.fragment.as_ref(), Some(Fragment::Outlet(_))))
1835            })
1836            .map(|(id, _)| id.as_str())
1837            .collect()
1838    }
1839
1840    /// Locate the lowest-numbered declaration whose inline body reaches another
1841    /// boundary through a component, condition, loop, or route record.
1842    ///
1843    /// Lexical nesting is already rejected while parsing, so this only has to
1844    /// look at the child records referenced between a start and end marker.
1845    fn find_transitively_nested_boundary(&self) -> Option<(String, String)> {
1846        let mut nested: Option<(u32, &str, &str)> = None;
1847        for list in self.fragment_records.values() {
1848            let mut active: Option<&WebUiFragmentBoundary> = None;
1849            for fragment in &list.fragments {
1850                let Some(boundary) = active else {
1851                    if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
1852                        if boundary.phase() == BoundaryPhase::Start {
1853                            active = Some(boundary);
1854                        }
1855                    }
1856                    continue;
1857                };
1858                if let Some(Fragment::Boundary(marker)) = fragment.fragment.as_ref() {
1859                    if marker.phase() == BoundaryPhase::End {
1860                        active = None;
1861                    }
1862                    continue;
1863                }
1864                if !self.fragment_reaches_boundary(fragment) {
1865                    continue;
1866                }
1867                if nested
1868                    .as_ref()
1869                    .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1870                {
1871                    nested = Some((
1872                        boundary.declaration_id,
1873                        boundary.owner_fragment_id.as_str(),
1874                        boundary.name.as_str(),
1875                    ));
1876                }
1877            }
1878        }
1879        nested.map(|(_, owner, name)| (owner.to_string(), name.to_string()))
1880    }
1881
1882    /// Whether a fragment inside a boundary body reaches a nested declaration
1883    /// through one of its child records.
1884    fn fragment_reaches_boundary(&self, fragment: &WebUIFragment) -> bool {
1885        let reaches = |id: &str| {
1886            !id.is_empty()
1887                && self
1888                    .fragment_records
1889                    .get(id)
1890                    .is_some_and(|list| list.contains_boundary)
1891        };
1892        match fragment.fragment.as_ref() {
1893            Some(Fragment::Component(component)) => reaches(&component.fragment_id),
1894            Some(Fragment::ForLoop(for_loop)) => reaches(&for_loop.fragment_id),
1895            Some(Fragment::IfCond(if_cond)) => reaches(&if_cond.fragment_id),
1896            Some(Fragment::Route(route)) => {
1897                let mut pending = vec![route];
1898                while let Some(current) = pending.pop() {
1899                    if reaches(&current.fragment_id)
1900                        || reaches(&current.content_fragment_id)
1901                        || reaches(&current.pending_component)
1902                        || reaches(&current.error_component)
1903                    {
1904                        return true;
1905                    }
1906                    pending.extend(current.children.iter());
1907                }
1908                false
1909            }
1910            _ => false,
1911        }
1912    }
1913
1914    #[cold]
1915    #[inline(never)]
1916    fn transitively_nested_boundary_error(owner: &str, name: &str) -> ParserError {
1917        Diagnostic::error(format!(
1918            "boundary \"{name}\" transitively contains another boundary"
1919        ))
1920        .code(codes::NESTED_BOUNDARY)
1921        .component(owner)
1922        .element("boundary")
1923        .snippet(format!("name=\"{name}\""))
1924        .help("remove the inner declaration or move it outside the enclosing boundary, including declarations reached through components and runtime branches")
1925        .into()
1926    }
1927
1928    /// Build the error for a boundary a `<for>` repeat body reaches through a
1929    /// component, condition, route, or outlet.
1930    #[cold]
1931    #[inline(never)]
1932    fn repeat_reachable_boundary_error(violation: &RepeatBoundaryViolation) -> ParserError {
1933        let RepeatBoundaryViolation {
1934            repeat_owner,
1935            each,
1936            boundary_owner,
1937            boundary_name,
1938        } = violation;
1939        Diagnostic::error(format!(
1940            "boundary \"{boundary_name}\" in {boundary_owner} is reachable from a <for> repeat body"
1941        ))
1942        .code(codes::BOUNDARY_IN_REPEAT)
1943        .component(repeat_owner)
1944        .element("for")
1945        .snippet(format!("each=\"{each}\""))
1946        .help(
1947            "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
1948             whole <for> in one <boundary> to render the list as a single independently paced \
1949             region — this includes declarations reached through components, runtime branches, \
1950             routes, and outlets",
1951        )
1952        .into()
1953    }
1954
1955    /// `src` of every authored `<script type="module">` outside any
1956    /// `<boundary>`, in document order, from the most recent top-level
1957    /// [`HtmlParser::parse`] call.
1958    ///
1959    /// These are the page's critical module entries — the ones whose shared
1960    /// chunks belong in `<link rel="modulepreload">`. Island loaders placed
1961    /// inside a boundary are excluded, because deferring them is the point.
1962    ///
1963    /// A `src` containing `{{` is skipped: it is a binding resolved per
1964    /// request, so no build-time artifact can be matched to it.
1965    #[must_use]
1966    pub fn module_entry_srcs(&self) -> &[String] {
1967        &self.module_entry_srcs
1968    }
1969
1970    /// Iterate component tags and whether each effectively uses Shadow DOM.
1971    ///
1972    /// This remains available independently of parser plugins so protocol
1973    /// builders can persist effective per-component ownership in plugin-free builds.
1974    pub fn component_shadow_dom_usage(&self) -> impl Iterator<Item = (&str, bool)> {
1975        self.component_dom_analyses
1976            .iter()
1977            .map(|(tag_name, analysis)| (tag_name.as_str(), analysis.uses_shadow_dom))
1978    }
1979
1980    /// Iterate non-eager component work-policy codes.
1981    pub fn component_work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
1982        self.component_registry.work_policies()
1983    }
1984
1985    /// Take any post-parse artifacts captured by the parser plugin.
1986    ///
1987    /// # Errors
1988    ///
1989    /// Returns [`ParserError::Template`] if a tracked component contains an
1990    /// invalid `@event` handler or a non-braced `w-ref` binding.
1991    pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
1992        self.plugin
1993            .take()
1994            .map_or(Ok(ParserPluginArtifacts::None), |plugin| plugin.finish())
1995    }
1996
1997    /// Take the accumulated CSS tokens as a sorted, deduplicated `Vec`.
1998    ///
1999    /// Convenience wrapper around [`HtmlParser::token_analysis`].
2000    #[must_use]
2001    pub fn take_tokens(&mut self) -> Vec<String> {
2002        self.token_analysis().protocol_tokens
2003    }
2004
2005    /// Analyze CSS token requirements from the parsed fragment/component graph.
2006    ///
2007    /// Each token candidate in a `var()` fallback chain is removed when that
2008    /// token is defined by CSS in the current fragment or an ancestor
2009    /// component/root. The returned protocol token list is sorted and
2010    /// deduplicated from the remaining unresolved fallback-chain candidates.
2011    #[must_use]
2012    pub fn token_analysis(&self) -> CssTokenAnalysis {
2013        let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
2014        let mut protocol_token_set = HashSet::new();
2015        for chain in &fallback_chains {
2016            for token in &chain.tokens {
2017                protocol_token_set.insert(token.clone());
2018            }
2019        }
2020        let mut protocol_tokens: Vec<String> = protocol_token_set.into_iter().collect();
2021        protocol_tokens.sort();
2022        CssTokenAnalysis {
2023            protocol_tokens,
2024            fallback_chains,
2025            token_sites,
2026        }
2027    }
2028
2029    fn collect_unresolved_fallback_chains(
2030        &self,
2031    ) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
2032        let mut out = UnresolvedTokens::default();
2033        let mut available_counts: HashMap<String, usize> = HashMap::new();
2034        let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
2035        for root in self.token_roots.iter().rev() {
2036            ops.push(TokenGraphOp::EnterFragment(root.as_str()));
2037        }
2038
2039        while let Some(op) = ops.pop() {
2040            match op {
2041                TokenGraphOp::EnterFragment(fragment_id) => {
2042                    self.enter_token_fragment(
2043                        fragment_id,
2044                        &mut available_counts,
2045                        &mut out,
2046                        &mut ops,
2047                    );
2048                }
2049                TokenGraphOp::EnterComponent(tag_name) => {
2050                    self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
2051                }
2052                TokenGraphOp::EnterRoute(route) => {
2053                    self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
2054                }
2055                TokenGraphOp::ExitDefinitions(definitions) => {
2056                    remove_token_definitions(definitions, &mut available_counts);
2057                }
2058            }
2059        }
2060
2061        (out.chains, out.sites)
2062    }
2063
2064    fn enter_token_fragment<'a>(
2065        &'a self,
2066        fragment_id: &'a str,
2067        available_counts: &mut HashMap<String, usize>,
2068        out: &mut UnresolvedTokens,
2069        ops: &mut Vec<TokenGraphOp<'a>>,
2070    ) {
2071        if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
2072            add_token_definitions(&css.definitions, available_counts);
2073            // Inline `<style>` tokens record their owning fragment (the entry
2074            // file). A fragment may carry several `<style>` blocks, so an
2075            // offset into one body is ambiguous — record the owner only.
2076            record_unresolved_requirements(
2077                &css.fallback_chains,
2078                available_counts,
2079                fragment_id,
2080                None,
2081                out,
2082            );
2083            ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
2084        }
2085
2086        let Some(fragments) = self.fragment_records.get(fragment_id) else {
2087            return;
2088        };
2089        for fragment in fragments.fragments.iter().rev() {
2090            match fragment.fragment.as_ref() {
2091                Some(web_ui_fragment::Fragment::Component(component)) => {
2092                    ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
2093                }
2094                Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
2095                    ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
2096                }
2097                Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
2098                    ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
2099                }
2100                Some(web_ui_fragment::Fragment::Route(route)) => {
2101                    ops.push(TokenGraphOp::EnterRoute(route));
2102                }
2103                _ => {}
2104            }
2105        }
2106    }
2107
2108    fn enter_token_component<'a>(
2109        &'a self,
2110        tag_name: &'a str,
2111        available_counts: &mut HashMap<String, usize>,
2112        out: &mut UnresolvedTokens,
2113        ops: &mut Vec<TokenGraphOp<'a>>,
2114    ) {
2115        let Some(component) = self.component_registry.get(tag_name) else {
2116            return;
2117        };
2118        add_token_definitions(&component.css_definitions, available_counts);
2119        record_unresolved_requirements(
2120            &component.css_fallback_chains,
2121            available_counts,
2122            &css_owner_label(tag_name),
2123            self.component_registry.diagnostic_css_content(tag_name),
2124            out,
2125        );
2126        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2127        ops.push(TokenGraphOp::EnterFragment(tag_name));
2128    }
2129
2130    fn enter_token_route<'a>(
2131        &'a self,
2132        route: &'a WebUiFragmentRoute,
2133        available_counts: &mut HashMap<String, usize>,
2134        out: &mut UnresolvedTokens,
2135        ops: &mut Vec<TokenGraphOp<'a>>,
2136    ) {
2137        if !route.content_fragment_id.is_empty() {
2138            ops.push(TokenGraphOp::EnterFragment(
2139                route.content_fragment_id.as_str(),
2140            ));
2141        }
2142        let Some(component) = self.component_registry.get(&route.fragment_id) else {
2143            return;
2144        };
2145        add_token_definitions(&component.css_definitions, available_counts);
2146        record_unresolved_requirements(
2147            &component.css_fallback_chains,
2148            available_counts,
2149            &css_owner_label(&route.fragment_id),
2150            self.component_registry
2151                .diagnostic_css_content(&route.fragment_id),
2152            out,
2153        );
2154        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2155        if !route.error_component.is_empty() {
2156            ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
2157        }
2158        if !route.pending_component.is_empty() {
2159            ops.push(TokenGraphOp::EnterComponent(
2160                route.pending_component.as_str(),
2161            ));
2162        }
2163        for child in route.children.iter().rev() {
2164            ops.push(TokenGraphOp::EnterRoute(child));
2165        }
2166        ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
2167    }
2168
2169    fn record_fragment_css_tokens(
2170        &mut self,
2171        definitions: HashSet<String>,
2172        fallback_chains: Vec<CssFallbackChain>,
2173    ) {
2174        let css = self
2175            .fragment_css_tokens
2176            .entry(self.current_fragment_id.clone())
2177            .or_default();
2178        css.definitions.extend(definitions);
2179        css.definitions.sort();
2180        css.definitions.dedup();
2181        css.fallback_chains.extend(fallback_chains);
2182    }
2183
2184    /// Parse HTML content to generate WebUI fragments.
2185    pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2186        let fragment_key = fragment_id.to_string();
2187        let is_token_root = self.in_progress_fragments.is_empty();
2188        if is_token_root {
2189            self.module_entry_srcs.clear();
2190        }
2191        self.boundary_names_by_owner.remove(&fragment_key);
2192        // Save the caller's fragment id and restore it before returning. A
2193        // component parse recurses through `enter_component_directive`
2194        // (`self.parse(child, …)`); without restoring, the parent would keep
2195        // parsing with `current_fragment_id` pointing at the child, so a later
2196        // diagnostic in the parent would be attributed to the wrong owner.
2197        let previous_fragment_id =
2198            std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
2199        if !self.in_progress_fragments.insert(fragment_key.clone()) {
2200            let err = self
2201                .authoring_error(
2202                    codes::RECURSIVE_TEMPLATE,
2203                    format!("recursive template reference while parsing <{fragment_id}>"),
2204                )
2205                .help(
2206                    "move the recursive usage behind runtime data, or split the component graph \
2207                     so templates do not reference themselves at build time",
2208                )
2209                .into();
2210            self.current_fragment_id = previous_fragment_id;
2211            return Err(err);
2212        }
2213        if is_token_root && !self.token_roots.contains(&fragment_key) {
2214            self.token_roots.push(fragment_key.clone());
2215        }
2216
2217        // Lexical directive/HTML context belongs to one authored template.
2218        // Component parsing may recurse while its callsite is inside a loop or
2219        // boundary, but those callsite properties are computed later by the
2220        // fragment graph analysis rather than leaking into the component's
2221        // authored syntax.
2222        let previous_in_boundary = std::mem::replace(&mut self.in_boundary, false);
2223        let previous_boundary_ancestor_depth = self.boundary_ancestor_depth;
2224        if previous_in_boundary {
2225            self.boundary_ancestor_depth += 1;
2226        }
2227        let previous_body_depth = std::mem::replace(&mut self.body_depth, 0);
2228        let previous_for_depth = std::mem::replace(&mut self.for_depth, 0);
2229        let previous_route_depth = std::mem::replace(&mut self.route_depth, 0);
2230        let previous_foster_depth = std::mem::replace(&mut self.foster_context_depth, 0);
2231        let previous_parent_scope = self.boundary_parent_scope.take();
2232
2233        let mut result = self.parse_inner(fragment_id, html_content);
2234        if is_token_root && result.is_ok() {
2235            result = self.finalize_boundary_metadata();
2236        }
2237
2238        self.in_boundary = previous_in_boundary;
2239        self.boundary_ancestor_depth = previous_boundary_ancestor_depth;
2240        self.body_depth = previous_body_depth;
2241        self.for_depth = previous_for_depth;
2242        self.route_depth = previous_route_depth;
2243        self.foster_context_depth = previous_foster_depth;
2244        self.boundary_parent_scope = previous_parent_scope;
2245        self.in_progress_fragments.remove(&fragment_key);
2246        self.current_fragment_id = previous_fragment_id;
2247        result
2248    }
2249
2250    fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2251        if html_content.len() > MAX_TEMPLATE_BYTES {
2252            return Err(ParserError::Html(format!(
2253                "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.",
2254                html_content.len()
2255            )));
2256        }
2257
2258        // Reset sub-fragments for new parse
2259        self.raw_buffer.clear();
2260        if let Some(ref mut plugin) = self.plugin {
2261            plugin.begin_fragment(FragmentContext { id: fragment_id });
2262        }
2263
2264        let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
2265        self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
2266
2267        self.flush_raw_buffer(&mut entry_fragment);
2268
2269        self.fragment_records.insert(
2270            fragment_id.to_string(),
2271            FragmentList {
2272                fragments: entry_fragment,
2273                contains_boundary: false,
2274            },
2275        );
2276
2277        Ok(())
2278    }
2279
2280    /// Add raw content to the buffer
2281    fn add_raw_fragment(&mut self, content: &str) {
2282        if !content.is_empty() {
2283            self.raw_buffer.push_str(content);
2284        }
2285    }
2286
2287    /// Add a for fragment, flushing raw buffer first
2288    fn add_for_fragment(
2289        &mut self,
2290        item: String,
2291        collection: String,
2292        fragment_id: String,
2293        fragments: &mut Vec<WebUIFragment>,
2294    ) {
2295        self.flush_raw_buffer(fragments);
2296        fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
2297    }
2298
2299    /// Add an if fragment, flushing raw buffer first
2300    fn add_if_fragment(
2301        &mut self,
2302        condition: ConditionExpr,
2303        fragment_id: String,
2304        fragments: &mut Vec<WebUIFragment>,
2305    ) {
2306        self.flush_raw_buffer(fragments);
2307        fragments.push(WebUIFragment::if_cond(condition, fragment_id));
2308    }
2309
2310    /// Add a non-raw fragment, flushing the raw buffer first if needed
2311    fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
2312        self.flush_raw_buffer(fragments);
2313        fragments.push(fragment);
2314    }
2315
2316    /// Flush the raw buffer into fragments if not empty
2317    fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
2318        if !self.raw_buffer.is_empty() {
2319            fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
2320        }
2321    }
2322
2323    /// Returns true when a text node should be emitted into the fragment stream.
2324    ///
2325    /// Pure formatting runs that contain line breaks are still dropped, but
2326    /// inline whitespace-only separators such as the spaces around `&gt;` in
2327    /// `{{sectionName}} &gt; {{topicName}}` must be preserved.
2328    fn should_emit_text_content(content: &str) -> bool {
2329        if content.is_empty() {
2330            return false;
2331        }
2332
2333        if !content.trim().is_empty() {
2334            return true;
2335        }
2336
2337        content.chars().all(char::is_whitespace)
2338            && !content.contains('\n')
2339            && !content.contains('\r')
2340    }
2341
2342    fn parse_range(
2343        &mut self,
2344        source: &str,
2345        range: Range<usize>,
2346        fragments: &mut Vec<WebUIFragment>,
2347        depth: usize,
2348    ) -> Result<()> {
2349        let mut ops = vec![ParseOp::Parse { range, depth }];
2350
2351        while let Some(op) = ops.pop() {
2352            match op {
2353                ParseOp::Parse { range, depth } => {
2354                    if depth > MAX_TEMPLATE_DEPTH {
2355                        return Err(self
2356                            .html_error(
2357                                codes::EXCESSIVE_NESTING,
2358                                format!(
2359                                    "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
2360                                ),
2361                                source,
2362                                range.start,
2363                            )
2364                            .help(
2365                                "split deeply nested markup into components, or reduce generated nesting",
2366                            )
2367                            .into());
2368                    }
2369
2370                    let end = range.end;
2371                    let mut index = range.start;
2372                    while index < end {
2373                        let remaining = &source[index..end];
2374                        if remaining.starts_with("<!--") {
2375                            let Some(close) = html::find_comment_close(remaining) else {
2376                                return Err(self
2377                                    .html_error(
2378                                        codes::UNTERMINATED_HTML_COMMENT,
2379                                        "unterminated HTML comment",
2380                                        source,
2381                                        index,
2382                                    )
2383                                    .help("close the comment with `-->`")
2384                                    .into());
2385                            };
2386                            index += close;
2387                            continue;
2388                        }
2389
2390                        if remaining.starts_with("<!") {
2391                            let Some(close) = html::find_declaration_close(remaining) else {
2392                                return Err(self
2393                                    .html_error(
2394                                        codes::UNTERMINATED_HTML_DECLARATION,
2395                                        "unterminated HTML declaration",
2396                                        source,
2397                                        index,
2398                                    )
2399                                    .help("close the declaration with `>`")
2400                                    .into());
2401                            };
2402                            self.add_raw_fragment(&remaining[..close]);
2403                            index += close;
2404                            continue;
2405                        }
2406
2407                        if remaining.starts_with('<') {
2408                            let Some(tag) = html::parse_tag(remaining) else {
2409                                return Err(self
2410                                    .html_error(
2411                                        codes::MALFORMED_HTML_TAG,
2412                                        "malformed HTML tag",
2413                                        source,
2414                                        index,
2415                                    )
2416                                    .help(
2417                                        "close the tag with `>`, or escape a literal `<` as `&lt;`",
2418                                    )
2419                                    .into());
2420                            };
2421                            if tag.closing {
2422                                return Err(self
2423                                    .html_error(
2424                                        codes::UNEXPECTED_CLOSING_TAG,
2425                                        format!("unexpected closing tag </{}>", tag.name),
2426                                        source,
2427                                        index,
2428                                    )
2429                                    .help("remove it, or add the matching opening tag before it")
2430                                    .into());
2431                            }
2432
2433                            let content_start = index + tag.close + 1;
2434                            let (content_end, close_end) = if tag.self_closing
2435                                || html::is_void_element(tag.name)
2436                            {
2437                                (content_start, content_start)
2438                            } else if let Some((close_start, close_end)) =
2439                                html::find_matching_end(remaining, tag.name, tag.close + 1)
2440                            {
2441                                (index + close_start, index + close_end)
2442                            } else {
2443                                return Err(self
2444                                    .html_error(
2445                                        codes::UNCLOSED_HTML_TAG,
2446                                        format!("unclosed <{}> tag", tag.name),
2447                                        source,
2448                                        index,
2449                                    )
2450                                    .help(format!(
2451                                        "add the matching </{}> closing tag, or make the element self-closing",
2452                                        tag.name
2453                                    ))
2454                                    .into());
2455                            };
2456
2457                            let element = Element {
2458                                source,
2459                                start: index,
2460                                tag,
2461                                content_start,
2462                                content_end,
2463                                close_end,
2464                            };
2465                            if close_end < end {
2466                                ops.push(ParseOp::Parse {
2467                                    range: close_end..end,
2468                                    depth,
2469                                });
2470                            }
2471
2472                            match element.name() {
2473                                "for" => {
2474                                    self.enter_for_directive(&element, fragments, depth, &mut ops)?;
2475                                }
2476                                "if" => {
2477                                    self.enter_if_directive(&element, fragments, depth, &mut ops)?;
2478                                }
2479                                "route" => {
2480                                    self.process_route_directive(&element, fragments, depth)?;
2481                                }
2482                                "outlet" => {
2483                                    self.flush_raw_buffer(fragments);
2484                                    fragments.push(WebUIFragment::outlet());
2485                                }
2486                                "boundary" => {
2487                                    self.enter_boundary_directive(
2488                                        &element, fragments, depth, &mut ops,
2489                                    )?;
2490                                }
2491                                name if name.eq_ignore_ascii_case("body") => {
2492                                    self.enter_body_element(&element, fragments, depth, &mut ops)?;
2493                                }
2494                                name if name.eq_ignore_ascii_case("head") => {
2495                                    self.enter_head_element(&element, fragments, depth, &mut ops)?;
2496                                }
2497                                name if name.eq_ignore_ascii_case("webui-hydrate") => {
2498                                    return Err(self.authored_webui_hydrate_error(&element));
2499                                }
2500                                name if name.eq_ignore_ascii_case("style") => {
2501                                    self.process_style_element(&element, fragments)?;
2502                                }
2503                                _ if self.component_registry.contains(element.name()) => {
2504                                    self.enter_component_directive(
2505                                        &element, fragments, depth, &mut ops,
2506                                    )?;
2507                                }
2508                                _ => {
2509                                    self.check_component_typo(&element)?;
2510                                    self.enter_regular_element(
2511                                        &element, fragments, depth, &mut ops,
2512                                    )?;
2513                                }
2514                            }
2515
2516                            break;
2517                        }
2518
2519                        let next = remaining.find('<').unwrap_or(remaining.len());
2520                        let text_end = if next == 0 {
2521                            remaining
2522                                .chars()
2523                                .next()
2524                                .map_or(remaining.len(), char::len_utf8)
2525                        } else {
2526                            next
2527                        };
2528                        self.process_text(&remaining[..text_end], fragments)?;
2529                        index += text_end;
2530                    }
2531                }
2532                ParseOp::EmitClose(name) => {
2533                    self.add_raw_fragment("</");
2534                    self.add_raw_fragment(name);
2535                    self.add_raw_fragment(">");
2536                }
2537                ParseOp::EndComponent(name) => {
2538                    self.add_raw_fragment("</");
2539                    self.add_raw_fragment(name);
2540                    self.add_raw_fragment(">");
2541                    self.flush_raw_buffer(fragments);
2542                    fragments.push(structural_signal(format!("streaming_span_end:{name}")));
2543                }
2544                ParseOp::EndHead(name) => {
2545                    self.flush_raw_buffer(fragments);
2546                    fragments.push(structural_signal("head_end"));
2547                    self.add_raw_fragment("</");
2548                    self.add_raw_fragment(name);
2549                    self.add_raw_fragment(">");
2550                }
2551                ParseOp::EndBody(name) => {
2552                    self.flush_raw_buffer(fragments);
2553                    fragments.push(structural_signal("body_end"));
2554                    self.add_raw_fragment("</");
2555                    self.add_raw_fragment(name);
2556                    self.add_raw_fragment(">");
2557                    self.body_depth = self.body_depth.saturating_sub(1);
2558                }
2559                ParseOp::CompleteBoundary { boundary } => {
2560                    self.flush_raw_buffer(fragments);
2561                    fragments.push(WebUIFragment::boundary_end(boundary.declaration_id));
2562                    self.in_boundary = false;
2563                }
2564                ParseOp::RestoreFosterDepth(previous) => {
2565                    self.foster_context_depth = previous;
2566                }
2567                ParseOp::RestoreBoundaryParentScope(previous) => {
2568                    self.boundary_parent_scope = previous;
2569                }
2570                ParseOp::CompleteFor {
2571                    parent,
2572                    item,
2573                    collection,
2574                    fragment_id,
2575                    keep_empty,
2576                    previous_for_depth,
2577                } => {
2578                    self.for_depth = previous_for_depth;
2579                    self.flush_raw_buffer(fragments);
2580                    let for_fragment = std::mem::take(fragments);
2581                    *fragments = parent.fragments;
2582                    self.raw_buffer = parent.raw_buffer;
2583
2584                    if !for_fragment.is_empty() {
2585                        self.fragment_records.insert(
2586                            fragment_id.clone(),
2587                            FragmentList {
2588                                fragments: for_fragment,
2589                                contains_boundary: false,
2590                            },
2591                        );
2592                    } else if !keep_empty {
2593                        continue;
2594                    }
2595
2596                    self.add_for_fragment(item, collection, fragment_id, fragments);
2597                }
2598                ParseOp::CompleteIf {
2599                    parent,
2600                    condition,
2601                    fragment_id,
2602                } => {
2603                    self.flush_raw_buffer(fragments);
2604                    let if_fragment = std::mem::take(fragments);
2605                    *fragments = parent.fragments;
2606                    self.raw_buffer = parent.raw_buffer;
2607
2608                    self.fragment_records.insert(
2609                        fragment_id.clone(),
2610                        FragmentList {
2611                            fragments: if_fragment,
2612                            contains_boundary: false,
2613                        },
2614                    );
2615                    self.add_if_fragment(condition, fragment_id, fragments);
2616                }
2617            }
2618        }
2619
2620        Ok(())
2621    }
2622
2623    fn enter_regular_element<'a>(
2624        &mut self,
2625        element: &Element<'a>,
2626        fragments: &mut Vec<WebUIFragment>,
2627        depth: usize,
2628        ops: &mut Vec<ParseOp<'a>>,
2629    ) -> Result<()> {
2630        if !self.in_boundary
2631            && self.boundary_ancestor_depth == 0
2632            && element.name().eq_ignore_ascii_case("script")
2633        {
2634            self.record_module_entry(element);
2635        }
2636        self.add_raw_fragment("<");
2637        self.add_raw_fragment(element.name());
2638
2639        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2640        if let Some(ref mut p) = self.plugin {
2641            if let Some(data) = p.finish_opening_tag(ElementStartContext {
2642                tag_name: element.name(),
2643                binding_count,
2644            }) {
2645                self.add_fragment(WebUIFragment::plugin(data), fragments);
2646            }
2647        }
2648
2649        if element.self_closing() {
2650            self.add_raw_fragment("/>");
2651            return Ok(());
2652        }
2653
2654        self.add_raw_fragment(">");
2655        if element.name().eq_ignore_ascii_case("template")
2656            && element
2657                .attrs()
2658                .find(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
2659                .and_then(|attr| attr.value)
2660                .is_some_and(|mode| mode.eq_ignore_ascii_case("open"))
2661            && self
2662                .component_dom_analyses
2663                .get(&self.current_fragment_id)
2664                .is_some_and(|analysis| analysis.uses_shadow_dom)
2665        {
2666            // This compiler-owned no-DOM hook lets the server install the
2667            // complete tree-local style closure before hydratable children.
2668            // It is a protocol fragment rather than an HTML marker, so client
2669            // ordinal paths are unchanged.
2670            self.flush_raw_buffer(fragments);
2671            fragments.push(structural_signal(format!(
2672                "shadow_styles:{}",
2673                self.current_fragment_id
2674            )));
2675        }
2676        if !element.is_void() {
2677            if element.close_end() > element.content_end() {
2678                ops.push(ParseOp::EmitClose(element.name()));
2679            }
2680            self.enter_foster_context(element.name(), ops);
2681            self.enter_boundary_parent_scope(element, ops);
2682            ops.push(ParseOp::Parse {
2683                range: element.inner(),
2684                depth: depth + 1,
2685            });
2686        }
2687        Ok(())
2688    }
2689
2690    /// Record an authored `<script type="module" src="...">` as a critical
2691    /// module entry, so the build can preload the chunks it statically imports.
2692    ///
2693    /// Only bare, static `src` values qualify. A `{{binding}}` resolves per
2694    /// request and cannot be matched to a build artifact, and a script without
2695    /// `type="module"` has no ES module graph to preload. Both are skipped
2696    /// silently rather than diagnosed, because either is a perfectly valid
2697    /// thing to author — they just carry no preload information.
2698    ///
2699    /// The caller has already excluded scripts inside a `<boundary>`.
2700    fn record_module_entry(&mut self, element: &Element<'_>) {
2701        let Some(src) = element.attr("src") else {
2702            return;
2703        };
2704        if src.is_empty() || src.contains("{{") {
2705            return;
2706        }
2707        if !element
2708            .attr("type")
2709            .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module"))
2710        {
2711            return;
2712        }
2713        // Document order is meaningful and duplicates are near-impossible, so
2714        // this stays a `Vec` scan rather than paying for a set.
2715        if self.module_entry_srcs.iter().any(|seen| seen == src) {
2716            return;
2717        }
2718        self.module_entry_srcs.push(src.to_string());
2719    }
2720
2721    fn enter_head_element<'a>(
2722        &mut self,
2723        element: &Element<'a>,
2724        fragments: &mut Vec<WebUIFragment>,
2725        depth: usize,
2726        ops: &mut Vec<ParseOp<'a>>,
2727    ) -> Result<()> {
2728        if element.name() == "head" {
2729            // Preserve the lowercase legacy protocol shape exactly: authored
2730            // attributes on `<head>` were ignored and the opening was emitted
2731            // as the literal `<head>`.
2732            self.add_raw_fragment("<head>");
2733        } else {
2734            // origin/main treated mixed-case native tags as ordinary elements.
2735            // Keep their authored name and attributes while adding only the
2736            // separate case-insensitive streaming structure.
2737            self.add_raw_fragment("<");
2738            self.add_raw_fragment(element.name());
2739            let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2740            if let Some(ref mut p) = self.plugin {
2741                if let Some(data) = p.finish_opening_tag(ElementStartContext {
2742                    tag_name: element.name(),
2743                    binding_count,
2744                }) {
2745                    self.add_fragment(WebUIFragment::plugin(data), fragments);
2746                }
2747            }
2748            self.add_raw_fragment(">");
2749        }
2750        self.flush_raw_buffer(fragments);
2751        // Mirrors `body_start`: emitted immediately after the raw opening
2752        // `<head>` fragment and before any child content (including
2753        // authored async scripts), so `render_streaming` can preflight on it.
2754        // Raw signals unknown to the legacy (non-streaming) handler are
2755        // ignored there, so this is a no-op for existing output.
2756        fragments.push(structural_signal("head_start"));
2757        ops.push(ParseOp::EndHead(element.name()));
2758        ops.push(ParseOp::Parse {
2759            range: element.inner(),
2760            depth: depth + 1,
2761        });
2762        Ok(())
2763    }
2764
2765    fn enter_body_element<'a>(
2766        &mut self,
2767        element: &Element<'a>,
2768        fragments: &mut Vec<WebUIFragment>,
2769        depth: usize,
2770        ops: &mut Vec<ParseOp<'a>>,
2771    ) -> Result<()> {
2772        self.add_raw_fragment("<");
2773        self.add_raw_fragment(element.name());
2774        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2775        if let Some(ref mut p) = self.plugin {
2776            if let Some(data) = p.finish_opening_tag(ElementStartContext {
2777                tag_name: element.name(),
2778                binding_count,
2779            }) {
2780                self.add_fragment(WebUIFragment::plugin(data), fragments);
2781            }
2782        }
2783        self.add_raw_fragment(">");
2784        self.flush_raw_buffer(fragments);
2785        fragments.push(structural_signal("body_start"));
2786        self.body_depth += 1;
2787        ops.push(ParseOp::EndBody(element.name()));
2788        ops.push(ParseOp::Parse {
2789            range: element.inner(),
2790            depth: depth + 1,
2791        });
2792        Ok(())
2793    }
2794
2795    /// Start an authoring [`Diagnostic`] for the template currently being
2796    /// parsed, naming the owning fragment (entry file or component tag) when
2797    /// known. `code` is the stable machine-readable [`code`](crate::diagnostic::codes).
2798    ///
2799    /// Marked `#[cold]`/`#[inline(never)]`: this only runs while *building* a
2800    /// build error, so keeping it out-of-line preserves hot parse-path layout.
2801    #[cold]
2802    #[inline(never)]
2803    fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
2804        let diag = Diagnostic::error(title).code(code);
2805        if self.current_fragment_id.is_empty() {
2806            diag
2807        } else {
2808            diag.component(self.current_fragment_id.clone())
2809        }
2810    }
2811
2812    /// Like [`HtmlParser::authoring_error`], but also records the source
2813    /// position (line/column) of `element` so the diagnostic can point at the
2814    /// exact spot in the template.
2815    #[cold]
2816    #[inline(never)]
2817    fn authoring_error_at(
2818        &self,
2819        code: &'static str,
2820        title: impl Into<String>,
2821        element: &Element<'_>,
2822    ) -> Diagnostic {
2823        self.authoring_error(code, title)
2824            .at_offset(element.source(), element.start)
2825    }
2826
2827    /// Build a positioned [`Diagnostic`] for a structural HTML well-formedness
2828    /// error (unclosed/malformed tag, unterminated comment, …), naming the
2829    /// owning fragment and the source position from a byte `offset`.
2830    #[cold]
2831    #[inline(never)]
2832    fn html_error(
2833        &self,
2834        code: &'static str,
2835        title: impl Into<String>,
2836        source: &str,
2837        offset: usize,
2838    ) -> Diagnostic {
2839        self.authoring_error(code, title).at_offset(source, offset)
2840    }
2841
2842    /// Build the `help:` line for an unknown component `<name>`.
2843    ///
2844    /// Offers the closest registered component as a "did you mean …?" fix when
2845    /// one is a near typo; otherwise falls back to the generic registration
2846    /// hint.
2847    #[cold]
2848    #[inline(never)]
2849    fn unknown_component_help(&self, name: &str) -> String {
2850        match suggest::closest_match(name, self.component_registry.names()) {
2851            Some(suggestion) => format!(
2852                "did you mean <{suggestion}>? otherwise register the component by adding a \
2853                 matching .html file"
2854            ),
2855            None => "register the component (add a matching .html file) or check the tag name \
2856                     for a typo"
2857                .to_string(),
2858        }
2859    }
2860
2861    /// Suggest a registered component that an unregistered custom-element `tag`
2862    /// likely mistypes.
2863    ///
2864    /// Only same-namespace candidates are considered — the text before the
2865    /// first `-` must match exactly — so a genuine third-party custom element
2866    /// (`<md-button>` when `<mp-button>` is registered) is never flagged, while
2867    /// an in-namespace slip (`<mp-buton>` → `<mp-button>`) is. Returns `None`
2868    /// for non-hyphenated (native) tags and when no near match exists.
2869    fn suggest_component(&self, tag: &str) -> Option<&str> {
2870        let (prefix, _) = tag.split_once('-')?;
2871        let same_namespace = self
2872            .component_registry
2873            .names()
2874            .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
2875        suggest::closest_match(tag, same_namespace)
2876    }
2877
2878    /// Error with a "did you mean …?" hint when `element` is an unregistered
2879    /// custom-element tag that closely matches a registered component in the
2880    /// same namespace. Genuine external custom elements pass through as raw
2881    /// HTML (returns `Ok`).
2882    fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
2883        if let Some(suggestion) = self.suggest_component(element.name()) {
2884            return Err(self
2885                .authoring_error_at(
2886                    codes::UNKNOWN_COMPONENT,
2887                    format!("unknown component <{}>", element.name()),
2888                    element,
2889                )
2890                .help(format!(
2891                    "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
2892                     .html file",
2893                    element.name()
2894                ))
2895                .into());
2896        }
2897        Ok(())
2898    }
2899
2900    /// Name of an attribute on `element` that looks like a typo of `expected`
2901    /// (close edit distance, not an exact match), if any. Used to suggest the
2902    /// intended directive attribute (e.g. `eahc` → `each`).
2903    #[cold]
2904    #[inline(never)]
2905    fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
2906        suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
2907            .filter(|&name| name != expected)
2908    }
2909
2910    /// Promote a standalone [`ParserError::Css`] into a structured authoring
2911    /// [`Diagnostic`] (code [`codes::INVALID_CSS`], owning fragment), so CSS
2912    /// mistakes render and serialize like every other authoring error. The
2913    /// message already carries the in-`<style>` line/column. Non-CSS errors
2914    /// pass through unchanged.
2915    #[cold]
2916    #[inline(never)]
2917    fn css_diagnostic(&self, err: ParserError) -> ParserError {
2918        match err {
2919            ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
2920            other => other,
2921        }
2922    }
2923
2924    /// Build the error for a `<for>` missing its `each` attribute (cold path).
2925    #[cold]
2926    #[inline(never)]
2927    fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
2928        let diag = self
2929            .authoring_error_at(
2930                codes::MISSING_FOR_EACH,
2931                "missing each attribute on <for>",
2932                element,
2933            )
2934            .element("for");
2935        match Self::attr_typo_suggestion(element, "each") {
2936            Some(typo) => diag.help(format!(
2937                "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
2938            )),
2939            None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
2940        }
2941        .into()
2942    }
2943
2944    /// Build the error for a malformed `<for each>` expression (cold path).
2945    #[cold]
2946    #[inline(never)]
2947    fn for_each_invalid_error(&self, element: &Element<'_>, each: &str) -> ParserError {
2948        self.authoring_error_at(
2949            codes::INVALID_FOR_EACH,
2950            "invalid <for> each expression",
2951            element,
2952        )
2953        .element("for")
2954        .snippet(format!("each=\"{each}\""))
2955        .help("use the form each=\"item in collection\", e.g. each=\"todo in todos\"")
2956        .into()
2957    }
2958
2959    /// Build the error for a `<for each>` with disallowed identifier characters
2960    /// (cold path).
2961    #[cold]
2962    #[inline(never)]
2963    fn for_identifier_error(&self, element: &Element<'_>, each: &str) -> ParserError {
2964        self.authoring_error_at(
2965            codes::INVALID_FOR_IDENTIFIER,
2966            "invalid identifier in <for> each expression",
2967            element,
2968        )
2969        .element("for")
2970        .snippet(format!("each=\"{each}\""))
2971        .help("item and collection names may use only letters, digits, '_', '-', and '.'")
2972        .into()
2973    }
2974
2975    fn enter_for_directive<'a>(
2976        &mut self,
2977        element: &Element<'a>,
2978        fragments: &mut Vec<WebUIFragment>,
2979        depth: usize,
2980        ops: &mut Vec<ParseOp<'a>>,
2981    ) -> Result<()> {
2982        let each = element
2983            .attr("each")
2984            .map(ToString::to_string)
2985            .ok_or_else(|| self.for_each_missing_error(element))?;
2986
2987        let mut parts = each.split_whitespace();
2988        let (Some(item), Some(in_kw), Some(collection), None) =
2989            (parts.next(), parts.next(), parts.next(), parts.next())
2990        else {
2991            return Err(self.for_each_invalid_error(element, &each));
2992        };
2993        if in_kw != "in" {
2994            return Err(self.for_each_invalid_error(element, &each));
2995        }
2996
2997        let allowed = |s: &str| {
2998            s.chars()
2999                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
3000        };
3001        if !allowed(item) || !allowed(collection) {
3002            return Err(self.for_identifier_error(element, &each));
3003        }
3004
3005        let custom_fragment_id = element.attr("template").map(ToString::to_string);
3006        let keep_empty = custom_fragment_id.is_some();
3007        let fragment_id = custom_fragment_id.unwrap_or_else(|| self.id_counter.next_id("for"));
3008        let parent = ParseContext {
3009            fragments: std::mem::take(fragments),
3010            raw_buffer: std::mem::take(&mut self.raw_buffer),
3011        };
3012
3013        let previous_for_depth = self.for_depth;
3014        self.for_depth += 1;
3015        ops.push(ParseOp::CompleteFor {
3016            parent,
3017            item: item.to_string(),
3018            collection: collection.to_string(),
3019            fragment_id,
3020            keep_empty,
3021            previous_for_depth,
3022        });
3023        ops.push(ParseOp::Parse {
3024            range: element.inner(),
3025            depth: depth + 1,
3026        });
3027        Ok(())
3028    }
3029
3030    /// Build the error for an `<if>` missing its `condition` attribute (cold
3031    /// path).
3032    #[cold]
3033    #[inline(never)]
3034    fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
3035        let diag = self
3036            .authoring_error_at(
3037                codes::MISSING_IF_CONDITION,
3038                "missing condition attribute on <if>",
3039                element,
3040            )
3041            .element("if");
3042        match Self::attr_typo_suggestion(element, "condition") {
3043            Some(typo) => diag.help(format!(
3044                "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
3045            )),
3046            None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
3047        }
3048        .into()
3049    }
3050
3051    /// Build the error for a malformed `<if condition>` expression (cold path).
3052    #[cold]
3053    #[inline(never)]
3054    fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
3055        self.authoring_error_at(
3056            codes::INVALID_IF_CONDITION,
3057            "invalid <if> condition expression",
3058            element,
3059        )
3060        .element("if")
3061        .snippet(format!("condition=\"{condition}\""))
3062        .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
3063        .into()
3064    }
3065
3066    fn enter_if_directive<'a>(
3067        &mut self,
3068        element: &Element<'a>,
3069        fragments: &mut Vec<WebUIFragment>,
3070        depth: usize,
3071        ops: &mut Vec<ParseOp<'a>>,
3072    ) -> Result<()> {
3073        let condition_str = element
3074            .attr("condition")
3075            .map(ToString::to_string)
3076            .ok_or_else(|| self.if_condition_missing_error(element))?;
3077
3078        let condition = self
3079            .condition_parser
3080            .parse(&condition_str)
3081            .map_err(|_| self.if_condition_invalid_error(element, &condition_str))?;
3082
3083        self.flush_raw_buffer(fragments);
3084        let parent = ParseContext {
3085            fragments: std::mem::take(fragments),
3086            raw_buffer: std::mem::take(&mut self.raw_buffer),
3087        };
3088        let fragment_id = self.id_counter.next_id("if");
3089
3090        ops.push(ParseOp::CompleteIf {
3091            parent,
3092            condition,
3093            fragment_id,
3094        });
3095        ops.push(ParseOp::Parse {
3096            range: element.inner(),
3097            depth: depth + 1,
3098        });
3099        Ok(())
3100    }
3101
3102    /// Track whether `name` opens (or shields from) an HTML insertion mode
3103    /// that foster-parents unknown elements, pushing a restore op when the
3104    /// depth changes.
3105    ///
3106    /// Called for every regular element, so the common `Transparent` case
3107    /// does no work beyond the classification compare — no op is pushed and
3108    /// no field is written.
3109    #[inline]
3110    fn enter_foster_context<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
3111        match foster_context_of(name) {
3112            FosterContext::Transparent => {}
3113            FosterContext::Hostile => {
3114                ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3115                self.foster_context_depth += 1;
3116            }
3117            FosterContext::Barrier => {
3118                if self.foster_context_depth != 0 {
3119                    ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3120                    self.foster_context_depth = 0;
3121                }
3122            }
3123        }
3124    }
3125
3126    #[inline]
3127    fn enter_boundary_parent_scope<'a>(
3128        &mut self,
3129        element: &Element<'a>,
3130        ops: &mut Vec<ParseOp<'a>>,
3131    ) {
3132        if element.name().eq_ignore_ascii_case("template") && element.has_attr("shadowrootmode") {
3133            return;
3134        }
3135        let Some(scope) = boundary_parent_scope(element.name()) else {
3136            return;
3137        };
3138        ops.push(ParseOp::RestoreBoundaryParentScope(
3139            self.boundary_parent_scope,
3140        ));
3141        self.boundary_parent_scope = Some(scope);
3142    }
3143
3144    #[inline]
3145    fn enter_component_content_scope<'a>(&mut self, ops: &mut Vec<ParseOp<'a>>) {
3146        ops.push(ParseOp::RestoreBoundaryParentScope(
3147            self.boundary_parent_scope,
3148        ));
3149        self.boundary_parent_scope = Some("component host content");
3150    }
3151
3152    /// Enter a `<boundary name="…">` directive.
3153    ///
3154    /// `<boundary>` is a reserved, compile-time-only directive (see
3155    /// "Progressive Streaming Hydration" in `DESIGN.md`): it emits no wrapper
3156    /// element. Its children stay inline in the owner's record, bracketed by a
3157    /// [`BoundaryPhase::Start`]/[`BoundaryPhase::End`] fragment pair.
3158    ///
3159    /// # Errors
3160    ///
3161    /// Returns [`ParserError::Template`] when:
3162    /// - the boundary is nested inside another open boundary
3163    ///   ([`codes::NESTED_BOUNDARY`]);
3164    /// - the boundary is outside an open `<body>`
3165    ///   ([`codes::BOUNDARY_OUTSIDE_BODY`]);
3166    /// - the boundary sits in an HTML foster-parenting context such as
3167    ///   `<table>`/`<tbody>`/`<tr>`/`<select>`
3168    ///   ([`codes::BOUNDARY_IN_FOSTER_CONTEXT`]);
3169    /// - the boundary would cut through component host content or native
3170    ///   raw/inert content ([`codes::BOUNDARY_CROSSES_SCOPE`]);
3171    /// - `name` is missing/empty ([`codes::MISSING_BOUNDARY_NAME`]), dynamic
3172    ///   ([`codes::INVALID_BOUNDARY_NAME`]), or a duplicate within its owner
3173    ///   ([`codes::DUPLICATE_BOUNDARY_NAME`]).
3174    /// - the boundary sits inside a `<for>` repeat body
3175    ///   ([`codes::BOUNDARY_IN_REPEAT`]), or an authored key is malformed
3176    ///   ([`codes::INVALID_BOUNDARY_KEY`]).
3177    fn enter_boundary_directive<'a>(
3178        &mut self,
3179        element: &Element<'a>,
3180        fragments: &mut Vec<WebUIFragment>,
3181        depth: usize,
3182        ops: &mut Vec<ParseOp<'a>>,
3183    ) -> Result<()> {
3184        if let Some(scope) = self.boundary_parent_scope {
3185            return Err(self.boundary_scope_error(element, scope));
3186        }
3187        if self.in_boundary {
3188            return Err(self.nested_boundary_error(element));
3189        }
3190        if self.for_depth != 0 {
3191            return Err(self.boundary_in_repeat_error(element));
3192        }
3193        let owner_fragment_id = self.current_fragment_id.clone();
3194        let owner_is_component = self.component_registry.contains(&owner_fragment_id);
3195        if !owner_is_component && self.body_depth == 0 && self.route_depth == 0 {
3196            return Err(self.boundary_outside_body_error(element));
3197        }
3198        if self.foster_context_depth != 0 {
3199            return Err(self.boundary_in_foster_context_error(element));
3200        }
3201
3202        let name = self.validate_boundary_name(element)?;
3203        let inserted = self
3204            .boundary_names_by_owner
3205            .entry(owner_fragment_id.clone())
3206            .or_default()
3207            .insert(name.clone());
3208        if !inserted {
3209            return Err(self.duplicate_boundary_name_error(element, &name));
3210        }
3211        let key = self.validate_boundary_key(element)?;
3212        let declaration_id = self.allocate_boundary_declaration_id(element)?;
3213        self.in_boundary = true;
3214        self.add_fragment(
3215            WebUIFragment {
3216                fragment: Some(Fragment::Boundary(WebUiFragmentBoundary {
3217                    declaration_id,
3218                    owner_fragment_id,
3219                    name,
3220                    key,
3221                    may_repeat: false,
3222                    phase: BoundaryPhase::Start as i32,
3223                })),
3224            },
3225            fragments,
3226        );
3227
3228        ops.push(ParseOp::CompleteBoundary {
3229            boundary: PendingBoundary { declaration_id },
3230        });
3231        ops.push(ParseOp::Parse {
3232            range: element.inner(),
3233            depth: depth + 1,
3234        });
3235        Ok(())
3236    }
3237
3238    /// Validate `<boundary name>`: required, non-empty, and static (no
3239    /// `{{binding}}`).
3240    fn validate_boundary_name(&self, element: &Element<'_>) -> Result<String> {
3241        let name = element.attr("name").unwrap_or_default();
3242        if name.trim().is_empty() {
3243            return Err(self.missing_boundary_name_error(element));
3244        }
3245        if name.contains("{{") {
3246            return Err(self.invalid_boundary_name_error(element, name));
3247        }
3248        Ok(name.to_string())
3249    }
3250
3251    fn validate_boundary_key(&self, element: &Element<'_>) -> Result<Option<String>> {
3252        if !element.has_attr("key") {
3253            return Ok(None);
3254        }
3255        let Some(raw) = element.attr("key") else {
3256            return Err(self.invalid_boundary_key_error(element, ""));
3257        };
3258        let trimmed = raw.trim();
3259        let braced = trimmed.starts_with("{{") || trimmed.ends_with("}}");
3260        let valid = if braced {
3261            trimmed
3262                .strip_prefix("{{")
3263                .and_then(|value| value.strip_suffix("}}"))
3264                .filter(|value| !value.starts_with('{') && !value.ends_with('}'))
3265                .is_some_and(|value| !value.trim().is_empty())
3266        } else {
3267            !trimmed.is_empty()
3268        };
3269        if !valid {
3270            return Err(self.invalid_boundary_key_error(element, raw));
3271        }
3272        Ok(Some(raw.to_string()))
3273    }
3274
3275    fn allocate_boundary_declaration_id(&mut self, element: &Element<'_>) -> Result<u32> {
3276        let declaration_id = self.next_boundary_declaration_id;
3277        let Some(next) = declaration_id.checked_add(1) else {
3278            return Err(self.too_many_boundaries_error(element));
3279        };
3280        self.next_boundary_declaration_id = next;
3281        Ok(declaration_id)
3282    }
3283
3284    /// Build the error for a `<boundary>` missing its `name` attribute
3285    /// (cold path).
3286    #[cold]
3287    #[inline(never)]
3288    fn missing_boundary_name_error(&self, element: &Element<'_>) -> ParserError {
3289        self.authoring_error_at(
3290            codes::MISSING_BOUNDARY_NAME,
3291            "missing name attribute on <boundary>",
3292            element,
3293        )
3294        .element("boundary")
3295        .help("add a unique static name, e.g. <boundary name=\"counter-ready\">")
3296        .into()
3297    }
3298
3299    /// Build the error for a `<boundary name>` that is not a static
3300    /// string literal (cold path).
3301    #[cold]
3302    #[inline(never)]
3303    fn invalid_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3304        self.authoring_error_at(
3305            codes::INVALID_BOUNDARY_NAME,
3306            "invalid name attribute on <boundary>",
3307            element,
3308        )
3309        .element("boundary")
3310        .snippet(format!("name=\"{name}\""))
3311        .help("boundary names must be a static string literal, e.g. name=\"counter-ready\"")
3312        .into()
3313    }
3314
3315    /// Build the error for a duplicate `<boundary name>` within one owner.
3316    #[cold]
3317    #[inline(never)]
3318    fn duplicate_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3319        self.authoring_error_at(
3320            codes::DUPLICATE_BOUNDARY_NAME,
3321            format!("duplicate <boundary> name \"{name}\""),
3322            element,
3323        )
3324        .element("boundary")
3325        .help("boundary names must be unique within their entry or component template; rename one of the declarations")
3326        .into()
3327    }
3328
3329    #[cold]
3330    #[inline(never)]
3331    fn missing_boundary_key_error(owner: &str, name: &str) -> ParserError {
3332        Diagnostic::error(format!(
3333            "boundary \"{name}\" renders more than once and has no key"
3334        ))
3335        .code(codes::MISSING_BOUNDARY_KEY)
3336        .component(owner)
3337        .element("boundary")
3338        .snippet(format!("name=\"{name}\""))
3339        .help(
3340            "the owning template is rendered from more than one callsite, so every occurrence \
3341             needs a distinct key, e.g. key=\"{{sectionId}}\" — or render the declaration from a \
3342             single callsite",
3343        )
3344        .into()
3345    }
3346
3347    /// Build the error for a `<boundary>` authored directly inside `<for>`.
3348    #[cold]
3349    #[inline(never)]
3350    fn boundary_in_repeat_error(&self, element: &Element<'_>) -> ParserError {
3351        let name = element.attr("name").unwrap_or_default();
3352        self.authoring_error_at(
3353            codes::BOUNDARY_IN_REPEAT,
3354            "<boundary> is not valid inside a <for> repeat",
3355            element,
3356        )
3357        .element("boundary")
3358        .snippet(format!("name=\"{name}\""))
3359        .help(
3360            "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
3361             whole <for> in one <boundary> to render the list as a single independently paced \
3362             region",
3363        )
3364        .into()
3365    }
3366
3367    #[cold]
3368    #[inline(never)]
3369    fn invalid_boundary_key_error(&self, element: &Element<'_>, key: &str) -> ParserError {
3370        self.authoring_error_at(
3371            codes::INVALID_BOUNDARY_KEY,
3372            "invalid key expression on <boundary>",
3373            element,
3374        )
3375        .element("boundary")
3376        .snippet(format!("key=\"{key}\""))
3377        .help("use a non-empty expression such as key=\"item.id\" or key=\"{{item.id}}\"")
3378        .into()
3379    }
3380
3381    #[cold]
3382    #[inline(never)]
3383    fn too_many_boundaries_error(&self, element: &Element<'_>) -> ParserError {
3384        self.authoring_error_at(
3385            codes::TOO_MANY_BOUNDARIES,
3386            "too many boundary declarations",
3387            element,
3388        )
3389        .element("boundary")
3390        .help("split this build into smaller protocols so each declaration has a unique u32 ID")
3391        .into()
3392    }
3393
3394    /// Build the error for a `<boundary>` nested inside another open
3395    /// boundary (cold path).
3396    #[cold]
3397    #[inline(never)]
3398    fn nested_boundary_error(&self, element: &Element<'_>) -> ParserError {
3399        self.authoring_error_at(
3400            codes::NESTED_BOUNDARY,
3401            "nested <boundary> is not allowed",
3402            element,
3403        )
3404        .element("boundary")
3405        .help("boundaries cannot be nested; close the enclosing <boundary> before opening another")
3406        .into()
3407    }
3408
3409    /// Build the error for a `<boundary>` that would cut through a native
3410    /// raw/inert scope or component host content.
3411    #[cold]
3412    #[inline(never)]
3413    fn boundary_scope_error(&self, element: &Element<'_>, scope: &str) -> ParserError {
3414        self.authoring_error_at(
3415            codes::BOUNDARY_CROSSES_SCOPE,
3416            format!("<boundary> cannot appear inside {scope}"),
3417            element,
3418        )
3419        .element("boundary")
3420        .help("move the <boundary> outside this raw, inert, or component-host content scope")
3421        .into()
3422    }
3423
3424    /// Build the error for a `<boundary>` outside an open body.
3425    #[cold]
3426    #[inline(never)]
3427    fn boundary_outside_body_error(&self, element: &Element<'_>) -> ParserError {
3428        self.authoring_error_at(
3429            codes::BOUNDARY_OUTSIDE_BODY,
3430            "<boundary> must appear inside <body>",
3431            element,
3432        )
3433        .element("boundary")
3434        .help("move the <boundary> between the opening <body> and its matching </body>")
3435        .into()
3436    }
3437
3438    /// Build the error for a `<boundary>` inside an HTML
3439    /// foster-parenting insertion mode (cold path).
3440    ///
3441    /// The browser would move the generated `<webui-hydrate>` sentinel out of
3442    /// the table while leaving its payload `<script>` behind, so the
3443    /// coordinator could not pair them and would halt hydration for the whole
3444    /// page. Rejecting at build time keeps that failure impossible.
3445    #[cold]
3446    #[inline(never)]
3447    fn boundary_in_foster_context_error(&self, element: &Element<'_>) -> ParserError {
3448        self.authoring_error_at(
3449            codes::BOUNDARY_IN_FOSTER_CONTEXT,
3450            "<boundary> cannot appear inside a table or select context",
3451            element,
3452        )
3453        .element("boundary")
3454        .help(
3455            "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>",
3456        )
3457        .into()
3458    }
3459
3460    #[cold]
3461    #[inline(never)]
3462    fn route_boundary_placement_error(&self, element: &Element<'_>) -> ParserError {
3463        self.authoring_error_at(
3464            codes::INVALID_ROUTE_BOUNDARY_PLACEMENT,
3465            "route boundary must be a direct <route> child",
3466            element,
3467        )
3468        .element("boundary")
3469        .help("move the <boundary> directly under <route>, or place it in the route component template")
3470        .into()
3471    }
3472
3473    /// Build the error for an authored `<webui-hydrate>` (cold path).
3474    /// `<webui-hydrate>` is reserved for the compiler/handler-generated
3475    /// runtime hydration sentinel and must never appear in authored markup.
3476    #[cold]
3477    #[inline(never)]
3478    fn authored_webui_hydrate_error(&self, element: &Element<'_>) -> ParserError {
3479        self.authoring_error_at(
3480            codes::AUTHORED_WEBUI_HYDRATE,
3481            "<webui-hydrate> is reserved for the generated runtime sentinel",
3482            element,
3483        )
3484        .element("webui-hydrate")
3485        .help("remove <webui-hydrate>; it is emitted only by the compiler/handler, never authored")
3486        .into()
3487    }
3488
3489    fn enter_component_directive<'a>(
3490        &mut self,
3491        element: &Element<'a>,
3492        fragments: &mut Vec<WebUIFragment>,
3493        depth: usize,
3494        ops: &mut Vec<ParseOp<'a>>,
3495    ) -> Result<()> {
3496        let needs_template_build = !self.fragment_records.contains_key(element.name());
3497        let cached_dom_analysis = self.component_dom_analyses.get(element.name()).copied();
3498        let has_plugin_artifact = self
3499            .component_registry
3500            .component_artifact_source(element.name())
3501            .is_some();
3502        let authored_html = if needs_template_build || cached_dom_analysis.is_none() {
3503            self.component_registry
3504                .component_authored_source(element.name())
3505        } else {
3506            None
3507        };
3508        let (dom_analysis, template_source) = {
3509            let component = self.component_registry.get(element.name()).ok_or_else(|| {
3510                self.authoring_error_at(
3511                    codes::UNKNOWN_COMPONENT,
3512                    format!("unknown component <{}>", element.name()),
3513                    element,
3514                )
3515                .help(self.unknown_component_help(element.name()))
3516            })?;
3517            (
3518                cached_dom_analysis.map_or_else(
3519                    || {
3520                        let fast_html = (has_plugin_artifact
3521                            && self.options.dom_strategy == DomStrategy::Shadow)
3522                            .then(|| {
3523                                Self::append_shadow_mode_if_missing(component.html_content.clone())
3524                            });
3525                        analyze_component_dom_with_diagnostic_source(
3526                            element.name(),
3527                            fast_html.as_deref().unwrap_or(&component.html_content),
3528                            self.options.dom_strategy,
3529                            authored_html.as_deref(),
3530                        )
3531                    },
3532                    Ok,
3533                )?,
3534                needs_template_build.then(|| {
3535                    (
3536                        component.html_content.clone(),
3537                        component.css_content.clone(),
3538                    )
3539                }),
3540            )
3541        };
3542        if cached_dom_analysis.is_none() {
3543            self.component_dom_analyses
3544                .insert(element.name().to_string(), dom_analysis);
3545        }
3546
3547        self.flush_raw_buffer(fragments);
3548        fragments.push(structural_signal(format!(
3549            "streaming_span_start:{}",
3550            element.name()
3551        )));
3552        self.add_raw_fragment("<");
3553        self.add_raw_fragment(element.name());
3554        if self
3555            .component_registry
3556            .render_policy(element.name())
3557            .is_some_and(ComponentRenderPolicy::is_interaction_hydration)
3558        {
3559            self.add_raw_fragment(" data-webui-interaction");
3560        }
3561
3562        let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
3563        if let Some(ref mut p) = self.plugin {
3564            if let Some(data) = p.finish_opening_tag(ElementStartContext {
3565                tag_name: element.name(),
3566                binding_count,
3567            }) {
3568                self.add_fragment(WebUIFragment::plugin(data), fragments);
3569            }
3570        }
3571
3572        // Emit a compiler-owned streamed SSR root signal immediately before the
3573        // component opening tag closes. Ordinary rendering ignores it byte-for-byte;
3574        // streaming rendering consumes it to inject ` data-ws` inside the opening tag
3575        // (so the marker exists before custom-element upgrade). Placing the signal
3576        // between the attribute run and the closing `>` is required because an
3577        // attribute cannot be injected once `>` has already been flushed.
3578        self.flush_raw_buffer(fragments);
3579        fragments.push(structural_signal(format!(
3580            "streaming_root:{}",
3581            element.name()
3582        )));
3583
3584        if element.self_closing() {
3585            self.add_raw_fragment("/>");
3586        } else {
3587            self.add_raw_fragment(">");
3588        }
3589
3590        self.flush_raw_buffer(fragments);
3591
3592        if let Some((html_content, css_content)) = template_source {
3593            let artifact_html = self
3594                .component_registry
3595                .component_artifact_source(element.name())
3596                .map(str::to_string);
3597            let built = self.build_component_templates(ComponentTemplateInput {
3598                tag_name: element.name(),
3599                html: &html_content,
3600                artifact_html: artifact_html.as_deref(),
3601                authored_html: authored_html.as_deref(),
3602                css_content: css_content.as_deref(),
3603                artifact_needed: self.plugin.is_some(),
3604            })?;
3605
3606            if let Some(ref mut p) = self.plugin {
3607                let component_data = self
3608                    .component_registry
3609                    .get(element.name())
3610                    .ok_or_else(|| {
3611                        ParserError::NotFound(format!(
3612                            "component <{}> disappeared during CSS compilation",
3613                            element.name()
3614                        ))
3615                    })?
3616                    .clone();
3617                p.component_built(built.plugin_context(&component_data))?;
3618            }
3619
3620            self.parse(element.name(), &built.ssr)?;
3621        }
3622
3623        fragments.push(WebUIFragment::component(element.name().to_string()));
3624
3625        if element.self_closing() {
3626            fragments.push(structural_signal(format!(
3627                "streaming_span_end:{}",
3628                element.name()
3629            )));
3630        } else {
3631            ops.push(ParseOp::EndComponent(element.name()));
3632            self.enter_component_content_scope(ops);
3633            ops.push(ParseOp::Parse {
3634                range: element.inner(),
3635                depth: depth + 1,
3636            });
3637        }
3638
3639        Ok(())
3640    }
3641
3642    fn analyze_component_dom(
3643        &mut self,
3644        tag_name: &str,
3645        source: &str,
3646        authored_source: Option<&str>,
3647    ) -> Result<ComponentDomAnalysis> {
3648        if let Some(analysis) = self.component_dom_analyses.get(tag_name) {
3649            return Ok(*analysis);
3650        }
3651        let analysis = analyze_component_dom_with_diagnostic_source(
3652            tag_name,
3653            source,
3654            self.options.dom_strategy,
3655            authored_source,
3656        )?;
3657        self.component_dom_analyses
3658            .insert(tag_name.to_string(), analysis);
3659        Ok(analysis)
3660    }
3661
3662    fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
3663        if !Self::should_emit_text_content(content) {
3664            return Ok(());
3665        }
3666
3667        let parsed_fragments = self.handlebars_parser.parse(content)?;
3668        for fragment in parsed_fragments {
3669            if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
3670                if Self::should_emit_text_content(&raw.value) {
3671                    self.add_raw_fragment(&raw.value);
3672                }
3673            } else if let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() {
3674                if signal.raw && is_html_text_only_scope(self.boundary_parent_scope) {
3675                    self.add_fragment(
3676                        WebUIFragment::raw_text_signal(signal.value.as_str(), true),
3677                        fragments,
3678                    );
3679                } else {
3680                    self.add_fragment(fragment, fragments);
3681                }
3682            } else {
3683                self.add_fragment(fragment, fragments);
3684            }
3685        }
3686        Ok(())
3687    }
3688
3689    fn process_style_content(
3690        &mut self,
3691        css: &str,
3692        comments: &[crate::css_parser::CssComment],
3693        fragments: &mut Vec<WebUIFragment>,
3694    ) {
3695        let mut last_end = 0usize;
3696
3697        for comment in comments {
3698            if comment.start_byte < last_end {
3699                if comment.end_byte > last_end {
3700                    last_end = comment.end_byte;
3701                }
3702                continue;
3703            }
3704
3705            self.add_raw_fragment(&css[last_end..comment.start_byte]);
3706            let comment_text = &css[comment.start_byte..comment.end_byte];
3707            if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
3708                self.add_fragment(fragment, fragments);
3709            } else if comment.preserve {
3710                self.add_raw_fragment(comment_text);
3711            }
3712            last_end = comment.end_byte;
3713        }
3714
3715        self.add_raw_fragment(&css[last_end..]);
3716    }
3717
3718    fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
3719        let signal = comment_policy::parse_css_signal_comment(comment)?;
3720        Some(WebUIFragment::raw_text_signal(signal.path, signal.raw))
3721    }
3722
3723    /// Check if an attribute value is a pure handlebars expression (e.g., "{{name}}" or
3724    /// "{{name}}" with quotes). Returns the inner signal name (borrowed) if so.
3725    fn extract_single_handlebars(value: &str) -> Option<&str> {
3726        let trimmed = value.trim();
3727        if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
3728            let inner = trimmed[2..trimmed.len() - 2].trim();
3729            // Verify there's no other {{ in the middle (i.e., it's truly a single expression)
3730            if !inner.contains("{{") && !inner.is_empty() {
3731                return Some(inner);
3732            }
3733        }
3734        None
3735    }
3736
3737    /// Check if an attribute value contains any handlebars expressions.
3738    fn contains_handlebars(value: &str) -> bool {
3739        value.contains("{{")
3740    }
3741
3742    fn process_tag_attributes(
3743        &mut self,
3744        attrs: Attrs<'_>,
3745        fragments: &mut Vec<WebUIFragment>,
3746        is_component: bool,
3747    ) -> Result<u32> {
3748        let mut first_dynamic_emitted = false;
3749        let mut binding_count: u32 = 0;
3750        for attr in attrs {
3751            let attr_name = attr.name;
3752            let attr_value = attr.value;
3753            let is_property_binding = attr_name.starts_with(':')
3754                && attr_value
3755                    .and_then(Self::extract_single_handlebars)
3756                    .is_some();
3757
3758            // `:property="{{expr}}"` is WebUI-owned syntax. Plugins may classify
3759            // FAST's single-brace `:property="{expr}"`, but cannot discard the
3760            // server-side scope transfer before parser-core processes it.
3761            if !is_property_binding {
3762                if let Some(ref mut p) = self.plugin {
3763                    match p.process_attribute(AttributeContext { name: attr_name }) {
3764                        AttributeAction::Keep => {}
3765                        AttributeAction::Skip => continue,
3766                        AttributeAction::SkipAndCountBinding => {
3767                            binding_count += 1;
3768                            continue;
3769                        }
3770                    }
3771                }
3772            }
3773
3774            if let Some(bool_name) = attr_name.strip_prefix('?') {
3775                if is_component {
3776                    if let Some(condition) = self.parse_boolean_condition(attr_value) {
3777                        let frag = Self::maybe_mark_attr_start(
3778                            WebUIFragment::attribute_boolean(bool_name, condition),
3779                            &mut first_dynamic_emitted,
3780                        );
3781                        self.add_fragment(frag, fragments);
3782                        binding_count += 1;
3783                    }
3784                } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
3785                    binding_count += 1;
3786                }
3787            } else if let Some(prop_name) = attr_name.strip_prefix(':') {
3788                if !is_component {
3789                    return Err(ParserError::Parse(format!(
3790                        ":{prop_name} complex binding is only allowed on custom elements. \
3791                         Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
3792                    )));
3793                }
3794                if Self::is_blocked_complex_property(prop_name) {
3795                    return Err(ParserError::Parse(format!(
3796                        ":{prop_name} is not allowed as a complex attribute binding \
3797                         because it enables arbitrary HTML injection. \
3798                         Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
3799                    )));
3800                }
3801                if let Some(val) = attr_value {
3802                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
3803                        let frag = Self::maybe_mark_attr_start(
3804                            WebUIFragment::attribute_complex(attr_name, signal_name),
3805                            &mut first_dynamic_emitted,
3806                        );
3807                        self.add_fragment(frag, fragments);
3808                        binding_count += 1;
3809                    }
3810                }
3811            } else if is_component && Self::is_skipped_attribute(attr_name) {
3812                if let Some(val) = attr_value {
3813                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
3814                        let frag = WebUIFragment {
3815                            fragment: Some(web_ui_fragment::Fragment::Attribute(
3816                                WebUIFragmentAttribute {
3817                                    name: attr_name.to_string(),
3818                                    value: signal_name.to_string(),
3819                                    attr_skip: true,
3820                                    ..Default::default()
3821                                },
3822                            )),
3823                        };
3824                        self.add_fragment(frag, fragments);
3825                        binding_count += 1;
3826                    } else if Self::contains_handlebars(val) {
3827                        let template_id = self.id_counter.next_id("attr");
3828                        let parsed = self.handlebars_parser.parse(val)?;
3829                        self.fragment_records.insert(
3830                            template_id.clone(),
3831                            FragmentList {
3832                                fragments: parsed,
3833                                contains_boundary: false,
3834                            },
3835                        );
3836                        let frag = WebUIFragment {
3837                            fragment: Some(web_ui_fragment::Fragment::Attribute(
3838                                WebUIFragmentAttribute {
3839                                    name: attr_name.to_string(),
3840                                    template: template_id,
3841                                    attr_skip: true,
3842                                    ..Default::default()
3843                                },
3844                            )),
3845                        };
3846                        self.add_fragment(frag, fragments);
3847                        binding_count += 1;
3848                    } else {
3849                        let frag = WebUIFragment {
3850                            fragment: Some(web_ui_fragment::Fragment::Attribute(
3851                                WebUIFragmentAttribute {
3852                                    name: attr_name.to_string(),
3853                                    value: val.to_string(),
3854                                    raw_value: true,
3855                                    attr_skip: true,
3856                                    ..Default::default()
3857                                },
3858                            )),
3859                        };
3860                        self.add_fragment(frag, fragments);
3861                    }
3862                }
3863            } else if let Some(val) = attr_value {
3864                if Self::contains_handlebars(val) {
3865                    if is_component {
3866                        if let Some(signal_name) = Self::extract_single_handlebars(val) {
3867                            let frag = Self::maybe_mark_attr_start(
3868                                WebUIFragment::attribute(attr_name, signal_name),
3869                                &mut first_dynamic_emitted,
3870                            );
3871                            self.add_fragment(frag, fragments);
3872                            binding_count += 1;
3873                        } else {
3874                            let template_id = self.id_counter.next_id("attr");
3875                            let parsed = self.handlebars_parser.parse(val)?;
3876                            self.fragment_records.insert(
3877                                template_id.clone(),
3878                                FragmentList {
3879                                    fragments: parsed,
3880                                    contains_boundary: false,
3881                                },
3882                            );
3883                            let frag = Self::maybe_mark_attr_start(
3884                                WebUIFragment::attribute_template(attr_name, template_id),
3885                                &mut first_dynamic_emitted,
3886                            );
3887                            self.add_fragment(frag, fragments);
3888                            binding_count += 1;
3889                        }
3890                    } else {
3891                        self.process_dynamic_attribute(attr_name, val, fragments)?;
3892                        binding_count += 1;
3893                    }
3894                } else if is_component {
3895                    let frag = Self::maybe_mark_attr_start(
3896                        WebUIFragment {
3897                            fragment: Some(web_ui_fragment::Fragment::Attribute(
3898                                WebUIFragmentAttribute {
3899                                    name: attr_name.to_string(),
3900                                    value: val.to_string(),
3901                                    raw_value: true,
3902                                    ..Default::default()
3903                                },
3904                            )),
3905                        },
3906                        &mut first_dynamic_emitted,
3907                    );
3908                    self.add_fragment(frag, fragments);
3909                } else {
3910                    self.add_raw_fragment(" ");
3911                    self.add_raw_fragment(attr.raw);
3912                }
3913            } else {
3914                self.add_raw_fragment(" ");
3915                self.add_raw_fragment(attr_name);
3916            }
3917        }
3918        Ok(binding_count)
3919    }
3920
3921    /// Set `attr_start = true` on the first non-skipped attribute fragment for
3922    /// a component element.
3923    fn maybe_mark_attr_start(
3924        mut frag: WebUIFragment,
3925        first_dynamic_emitted: &mut bool,
3926    ) -> WebUIFragment {
3927        if !*first_dynamic_emitted {
3928            if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
3929                a.attr_start = true;
3930            }
3931            *first_dynamic_emitted = true;
3932        }
3933        frag
3934    }
3935
3936    /// Process a boolean attribute (?prefix). Silently drops if value is not a
3937    /// pure handlebars expression.
3938    fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
3939        if let Some(val) = value {
3940            if let Some(expr_str) = Self::extract_single_handlebars(val) {
3941                return Some(
3942                    self.condition_parser
3943                        .parse(expr_str)
3944                        .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
3945                );
3946            }
3947        }
3948
3949        None
3950    }
3951
3952    fn process_boolean_attribute(
3953        &mut self,
3954        name: &str,
3955        value: Option<&str>,
3956        fragments: &mut Vec<WebUIFragment>,
3957    ) -> Result<bool> {
3958        if let Some(condition) = self.parse_boolean_condition(value) {
3959            self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
3960            return Ok(true);
3961        }
3962        // Invalid boolean attribute — silently drop (no output at all)
3963        Ok(false)
3964    }
3965
3966    /// Process a dynamic attribute (regular name with handlebars in value).
3967    fn process_dynamic_attribute(
3968        &mut self,
3969        name: &str,
3970        value: &str,
3971        fragments: &mut Vec<WebUIFragment>,
3972    ) -> Result<()> {
3973        if let Some(signal_name) = Self::extract_single_handlebars(value) {
3974            // Pure handlebars — simple attribute fragment
3975            self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
3976        } else {
3977            // Mixed static + dynamic — create a template sub-stream
3978            let template_id = self.id_counter.next_id("attr");
3979            let parsed = self.handlebars_parser.parse(value)?;
3980
3981            self.fragment_records.insert(
3982                template_id.clone(),
3983                FragmentList {
3984                    fragments: parsed,
3985                    contains_boundary: false,
3986                },
3987            );
3988
3989            self.add_fragment(
3990                WebUIFragment::attribute_template(name, template_id),
3991                fragments,
3992            );
3993        }
3994        Ok(())
3995    }
3996
3997    fn process_style_element(
3998        &mut self,
3999        element: &Element<'_>,
4000        fragments: &mut Vec<WebUIFragment>,
4001    ) -> Result<()> {
4002        self.add_raw_fragment(element.opening());
4003        let inner = element.inner();
4004        let style_content = &element.source()[inner.start..inner.end];
4005        let (_tokens, defs, requirements, comments) = self
4006            .css_parser
4007            .extract_tokens_definitions_requirements_and_comments(
4008                style_content,
4009                self.options.legal_comments,
4010            )
4011            .map_err(|e| self.css_diagnostic(e))?;
4012        self.record_fragment_css_tokens(defs, requirements);
4013        self.process_style_content(style_content, &comments, fragments);
4014        if element.close_end() > element.content_end() {
4015            // Reconstruct the closing tag from the parsed name so the emitted
4016            // case mirrors the source (e.g. `<STYLE>` closes with `</STYLE>`),
4017            // matching how regular elements are closed via `ParseOp::EmitClose`.
4018            self.add_raw_fragment("</");
4019            self.add_raw_fragment(element.name());
4020            self.add_raw_fragment(">");
4021        }
4022        Ok(())
4023    }
4024
4025    /// Process a `<route>` directive.
4026    ///
4027    /// Emits a `Fragment::Route` protocol fragment. The handler renders
4028    /// `<webui-route>` elements with server-side route matching — matched
4029    /// routes get `active` + component content, non-matched get `display:none`.
4030    fn process_route_directive(
4031        &mut self,
4032        element: &Element<'_>,
4033        fragments: &mut Vec<WebUIFragment>,
4034        depth: usize,
4035    ) -> Result<()> {
4036        let attrs = Self::route_attrs_from_element(element);
4037        let path = attrs.path.clone();
4038        let component = attrs.component.clone();
4039        route_parser::validate_attributes(&attrs)?;
4040
4041        let route_params: std::collections::HashSet<String> =
4042            route_parser::extract_params(&path)?.into_iter().collect();
4043        if !attrs.cache_tags.is_empty() {
4044            route_parser::validate_tag_placeholders(
4045                &attrs.cache_tags,
4046                &route_params,
4047                "cache-tags",
4048                &path,
4049            )?;
4050        }
4051        if !attrs.invalidates.is_empty() {
4052            route_parser::validate_tag_placeholders(
4053                &attrs.invalidates,
4054                &route_params,
4055                "invalidates",
4056                &path,
4057            )?;
4058        }
4059
4060        if !attrs.pending_component.is_empty() {
4061            self.ensure_route_component_parsed(&attrs.pending_component)?;
4062        }
4063        if !attrs.error_component.is_empty() {
4064            self.ensure_route_component_parsed(&attrs.error_component)?;
4065        }
4066        self.ensure_route_component_parsed(&component)?;
4067        self.flush_raw_buffer(fragments);
4068        let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4069
4070        let mut all_params = std::collections::HashSet::new();
4071        all_params.extend(route_params);
4072        self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
4073        let children =
4074            self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
4075
4076        let mut route_fragment =
4077            route_parser::build_route_fragment(&attrs, component.clone(), children);
4078        route_fragment.content_fragment_id = content_fragment_id;
4079        fragments.push(WebUIFragment::route_from(route_fragment));
4080
4081        Ok(())
4082    }
4083
4084    fn parse_route_boundary_content(
4085        &mut self,
4086        route: &Element<'_>,
4087        depth: usize,
4088    ) -> Result<String> {
4089        let inner = route.inner();
4090        if !route.source()[inner.clone()].contains("<boundary") {
4091            return Ok(String::new());
4092        }
4093        let saved_buffer = std::mem::take(&mut self.raw_buffer);
4094        let previous_route_depth = self.route_depth;
4095        self.route_depth += 1;
4096        let mut content = Vec::new();
4097        let parse_result: Result<()> = (|| {
4098            for event in Walker::new_range(route.source(), inner.start, inner.end) {
4099                if let Event::Element(element) = event {
4100                    if element.name() == "boundary" {
4101                        self.parse_range(
4102                            element.source(),
4103                            element.start..element.close_end(),
4104                            &mut content,
4105                            depth + 1,
4106                        )?;
4107                    }
4108                }
4109            }
4110            self.flush_raw_buffer(&mut content);
4111            Ok(())
4112        })();
4113        self.route_depth = previous_route_depth;
4114        self.raw_buffer = saved_buffer;
4115        parse_result?;
4116
4117        if content.is_empty() {
4118            return Ok(String::new());
4119        }
4120        let fragment_id = self.id_counter.next_id("route-content");
4121        self.fragment_records.insert(
4122            fragment_id.clone(),
4123            FragmentList {
4124                fragments: content,
4125                contains_boundary: false,
4126            },
4127        );
4128        Ok(fragment_id)
4129    }
4130
4131    /// Parse nested `<route>` children into route fragments.
4132    ///
4133    /// `depth` is the nesting level of the routes produced here (top-level
4134    /// `<route>` children are depth 1). Nested routes are validated against
4135    /// [`MAX_TEMPLATE_DEPTH`] so pathological route trees cannot exhaust the
4136    /// call stack via `parse_route_as_fragment` recursion.
4137    fn parse_child_routes(
4138        &mut self,
4139        source: &str,
4140        range: Range<usize>,
4141        ancestor_params: &std::collections::HashSet<String>,
4142        depth: usize,
4143    ) -> Result<Vec<WebUiFragmentRoute>> {
4144        if depth > MAX_TEMPLATE_DEPTH {
4145            return Err(self
4146                .html_error(
4147                    codes::EXCESSIVE_NESTING,
4148                    format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4149                    source,
4150                    range.start,
4151                )
4152                .help("flatten deeply nested <route> trees before building")
4153                .into());
4154        }
4155
4156        let mut children = Vec::new();
4157        for event in Walker::new_range(source, range.start, range.end) {
4158            match event {
4159                Event::Element(element) => {
4160                    self.validate_closed_element(&element)?;
4161                    self.reject_boundary_or_hydrate_in_route(&element, true)?;
4162                    if element.name() == "route" {
4163                        children.push(self.parse_route_as_fragment(
4164                            &element,
4165                            ancestor_params,
4166                            depth,
4167                        )?);
4168                    } else if element.name().eq_ignore_ascii_case("style") {
4169                        self.validate_style_element(&element)?;
4170                    } else if !element.self_closing() && !element.is_void() {
4171                        self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
4172                    }
4173                }
4174                Event::Text(text) => {
4175                    if text.contains('<') {
4176                        return Err(self
4177                            .authoring_error(
4178                                codes::MALFORMED_HTML_TAG,
4179                                "malformed HTML tag in <route>",
4180                            )
4181                            .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
4182                            .into());
4183                    }
4184                }
4185                Event::Comment(comment_range) => {
4186                    self.validate_comment_range(source, comment_range)?;
4187                }
4188                Event::Declaration(declaration_range) => {
4189                    self.validate_declaration_range(source, declaration_range)?;
4190                }
4191                Event::ClosingTag(closing_range) => {
4192                    return Err(self.unexpected_closing_tag_error(source, closing_range));
4193                }
4194            }
4195        }
4196
4197        Ok(children)
4198    }
4199
4200    fn validate_route_nesting_depth(
4201        &self,
4202        source: &str,
4203        range: Range<usize>,
4204        depth: usize,
4205    ) -> Result<()> {
4206        let mut stack = vec![(range, depth)];
4207        while let Some((range, depth)) = stack.pop() {
4208            if depth > MAX_TEMPLATE_DEPTH {
4209                return Err(self
4210                    .html_error(
4211                        codes::EXCESSIVE_NESTING,
4212                        format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4213                        source,
4214                        range.start,
4215                    )
4216                    .help("flatten deeply nested <route> trees before building")
4217                    .into());
4218            }
4219
4220            for event in Walker::new_range(source, range.start, range.end) {
4221                if let Event::Element(element) = event {
4222                    self.validate_closed_element(&element)?;
4223                    if element.name() == "route" && !element.self_closing() {
4224                        stack.push((element.inner(), depth + 1));
4225                    }
4226                }
4227            }
4228        }
4229        Ok(())
4230    }
4231
4232    fn validate_ignored_route_html(
4233        &mut self,
4234        source: &str,
4235        range: Range<usize>,
4236        depth: usize,
4237    ) -> Result<()> {
4238        let mut stack = vec![(range, depth)];
4239        while let Some((range, depth)) = stack.pop() {
4240            if depth > MAX_TEMPLATE_DEPTH {
4241                return Err(self
4242                    .html_error(
4243                        codes::EXCESSIVE_NESTING,
4244                        format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4245                        source,
4246                        range.start,
4247                    )
4248                    .help("split deeply nested markup into components, or reduce generated nesting")
4249                    .into());
4250            }
4251
4252            for event in Walker::new_range(source, range.start, range.end) {
4253                match event {
4254                    Event::Element(element) => {
4255                        self.validate_closed_element(&element)?;
4256                        self.reject_boundary_or_hydrate_in_route(&element, false)?;
4257                        if element.name().eq_ignore_ascii_case("style") {
4258                            self.validate_style_element(&element)?;
4259                        } else if !element.self_closing() && !element.is_void() {
4260                            stack.push((element.inner(), depth + 1));
4261                        }
4262                    }
4263                    Event::Text(text) => {
4264                        if text.contains('<') {
4265                            return Err(self
4266                                .authoring_error(
4267                                    codes::MALFORMED_HTML_TAG,
4268                                    "malformed HTML tag in <route>",
4269                                )
4270                                .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
4271                                .into());
4272                        }
4273                    }
4274                    Event::Comment(comment_range) => {
4275                        self.validate_comment_range(source, comment_range)?;
4276                    }
4277                    Event::Declaration(declaration_range) => {
4278                        self.validate_declaration_range(source, declaration_range)?;
4279                    }
4280                    Event::ClosingTag(closing_range) => {
4281                        return Err(self.unexpected_closing_tag_error(source, closing_range));
4282                    }
4283                }
4284            }
4285        }
4286        Ok(())
4287    }
4288
4289    /// Reject `<webui-hydrate>` in route markup and boundaries that are not
4290    /// direct route children.
4291    ///
4292    /// `<route>` children are validated by a separate well-formedness walker
4293    /// ([`Self::parse_child_routes`]/[`Self::validate_ignored_route_html`]),
4294    /// not the main fragment dispatch in [`Self::parse_range`], so both
4295    /// reserved directives need an explicit check here to produce the same
4296    /// structured diagnostics as everywhere else instead of silently passing
4297    /// through as ignored HTML.
4298    fn reject_boundary_or_hydrate_in_route(
4299        &self,
4300        element: &Element<'_>,
4301        allow_boundary: bool,
4302    ) -> Result<()> {
4303        if element.name() == "boundary" && !allow_boundary {
4304            Err(self.route_boundary_placement_error(element))
4305        } else if element.name().eq_ignore_ascii_case("webui-hydrate") {
4306            Err(self.authored_webui_hydrate_error(element))
4307        } else {
4308            Ok(())
4309        }
4310    }
4311
4312    fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
4313        if !element.self_closing()
4314            && !element.is_void()
4315            && element.close_end() == element.content_end()
4316        {
4317            return Err(self
4318                .html_error(
4319                    codes::UNCLOSED_HTML_TAG,
4320                    format!("unclosed <{}> tag", element.name()),
4321                    element.source(),
4322                    element.start,
4323                )
4324                .help(format!(
4325                    "add the matching </{}> closing tag, or make the element self-closing",
4326                    element.name()
4327                ))
4328                .into());
4329        }
4330        Ok(())
4331    }
4332
4333    fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
4334        let inner = element.inner();
4335        let style_content = &element.source()[inner.start..inner.end];
4336        self.css_parser
4337            .extract_tokens_definitions_requirements_and_comments(
4338                style_content,
4339                self.options.legal_comments,
4340            )
4341            .map_err(|e| self.css_diagnostic(e))?;
4342        Ok(())
4343    }
4344
4345    fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4346        let start = range.start;
4347        if source[range].ends_with("-->") {
4348            return Ok(());
4349        }
4350        Err(self
4351            .html_error(
4352                codes::UNTERMINATED_HTML_COMMENT,
4353                "unterminated HTML comment in <route>",
4354                source,
4355                start,
4356            )
4357            .help("close the comment with `-->`")
4358            .into())
4359    }
4360
4361    fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4362        let start = range.start;
4363        if source[range].ends_with('>') {
4364            return Ok(());
4365        }
4366        Err(self
4367            .html_error(
4368                codes::UNTERMINATED_HTML_DECLARATION,
4369                "unterminated HTML declaration in <route>",
4370                source,
4371                start,
4372            )
4373            .help("close the declaration with `>`")
4374            .into())
4375    }
4376
4377    fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
4378        let start = range.start;
4379        let title = match html::parse_tag(&source[range]) {
4380            Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
4381            None => "unexpected closing tag in <route>".to_string(),
4382        };
4383        self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
4384            .help("remove it, or add the matching opening tag before it")
4385            .into()
4386    }
4387
4388    fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
4389        route_parser::RouteAttributes {
4390            path: element.attr("path").unwrap_or_default().to_string(),
4391            component: element.attr("component").unwrap_or_default().to_string(),
4392            exact: element.has_attr("exact"),
4393            query: element.attr("query").unwrap_or_default().to_string(),
4394            keep_alive: element.has_attr("keep-alive"),
4395            cache_tags: element
4396                .attr("cache-tags")
4397                .map(route_parser::parse_tag_list)
4398                .unwrap_or_default(),
4399            invalidates: element
4400                .attr("invalidates")
4401                .map(route_parser::parse_tag_list)
4402                .unwrap_or_default(),
4403            pending_component: element.attr("pending").unwrap_or_default().to_string(),
4404            error_component: element.attr("error").unwrap_or_default().to_string(),
4405        }
4406    }
4407
4408    fn parse_route_as_fragment(
4409        &mut self,
4410        element: &Element<'_>,
4411        ancestor_params: &std::collections::HashSet<String>,
4412        depth: usize,
4413    ) -> Result<WebUiFragmentRoute> {
4414        let attrs = Self::route_attrs_from_element(element);
4415        let path = attrs.path.clone();
4416        let component = attrs.component.clone();
4417
4418        route_parser::validate_attributes(&attrs)?;
4419
4420        let own_params: std::collections::HashSet<String> =
4421            route_parser::extract_params(&path)?.into_iter().collect();
4422        let mut all_params = ancestor_params.clone();
4423        all_params.extend(own_params.iter().cloned());
4424        if !attrs.cache_tags.is_empty() {
4425            route_parser::validate_tag_placeholders(
4426                &attrs.cache_tags,
4427                &all_params,
4428                "cache-tags",
4429                &path,
4430            )?;
4431        }
4432        if !attrs.invalidates.is_empty() {
4433            route_parser::validate_tag_placeholders(
4434                &attrs.invalidates,
4435                &all_params,
4436                "invalidates",
4437                &path,
4438            )?;
4439        }
4440
4441        if !attrs.pending_component.is_empty() {
4442            self.ensure_route_component_parsed(&attrs.pending_component)?;
4443        }
4444        if !attrs.error_component.is_empty() {
4445            self.ensure_route_component_parsed(&attrs.error_component)?;
4446        }
4447
4448        self.ensure_route_component_parsed(&component)?;
4449        let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4450        let children =
4451            self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
4452
4453        let mut route = route_parser::build_route_fragment(&attrs, component, children);
4454        route.content_fragment_id = content_fragment_id;
4455        Ok(route)
4456    }
4457
4458    /// Ensure a route-referenced component is parsed and registered.
4459    fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
4460        if component.is_empty()
4461            || !self.component_registry.contains(component)
4462            || self.fragment_records.contains_key(component)
4463        {
4464            return Ok(());
4465        }
4466
4467        let component_data = self
4468            .component_registry
4469            .get(component)
4470            .ok_or_else(|| {
4471                self.authoring_error(
4472                    codes::UNKNOWN_COMPONENT,
4473                    format!("unknown component <{component}>"),
4474                )
4475                .help(self.unknown_component_help(component))
4476            })?
4477            .clone();
4478        let artifact_html = self
4479            .component_registry
4480            .component_artifact_source(component)
4481            .map(str::to_string);
4482        let authored_html = self.component_registry.component_authored_source(component);
4483
4484        let built = self.build_component_templates(ComponentTemplateInput {
4485            tag_name: component,
4486            html: &component_data.html_content,
4487            artifact_html: artifact_html.as_deref(),
4488            authored_html: authored_html.as_deref(),
4489            css_content: component_data.css_content.as_deref(),
4490            artifact_needed: self.plugin.is_some(),
4491        })?;
4492
4493        if let Some(ref mut p) = self.plugin {
4494            let component_data = self
4495                .component_registry
4496                .get(component)
4497                .ok_or_else(|| {
4498                    ParserError::NotFound(format!(
4499                        "component <{component}> disappeared during CSS compilation"
4500                    ))
4501                })?
4502                .clone();
4503            p.component_built(built.plugin_context(&component_data))?;
4504        }
4505
4506        let saved_buffer = std::mem::take(&mut self.raw_buffer);
4507        self.parse(component, &built.ssr)?;
4508        self.raw_buffer = saved_buffer;
4509
4510        Ok(())
4511    }
4512
4513    /// Skipped attribute names for components.
4514    const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
4515    /// Skipped attribute prefixes for components.
4516    const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
4517    const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
4518
4519    fn is_skipped_attribute(name: &str) -> bool {
4520        Self::SKIPPED_ATTRIBUTES.contains(&name)
4521            || Self::SKIPPED_ATTRIBUTE_PREFIXES
4522                .iter()
4523                .any(|prefix| name.starts_with(prefix))
4524    }
4525
4526    /// Properties that must never be set via `:attr` complex bindings.
4527    /// These enable XSS (HTML injection) or arbitrary code execution.
4528    fn is_blocked_complex_property(name: &str) -> bool {
4529        matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
4530    }
4531
4532    /// Build both SSR-facing and plugin-facing component template views.
4533    fn build_component_templates(
4534        &mut self,
4535        input: ComponentTemplateInput<'_>,
4536    ) -> Result<BuiltComponentTemplate> {
4537        let tag_name = input.tag_name;
4538        let synthesize_fast_shadow =
4539            input.artifact_html.is_some() && self.options.dom_strategy == DomStrategy::Shadow;
4540        let fast_html = synthesize_fast_shadow
4541            .then(|| Self::append_shadow_mode_if_missing(input.html.to_string()));
4542        let html = fast_html.as_deref().unwrap_or(input.html);
4543        let css_content = input.css_content;
4544        let dom_analysis = self.analyze_component_dom(tag_name, html, input.authored_html)?;
4545        let is_light = !dom_analysis.uses_shadow_dom;
4546        let mut inline_style_ranges = Vec::new();
4547        if is_light {
4548            let mut comment_ranges = Vec::new();
4549            Self::collect_html_comment_and_style_ranges(
4550                html,
4551                &mut comment_ranges,
4552                &mut inline_style_ranges,
4553            )?;
4554            if let Some(css) = css_content {
4555                css_light::validate_global_css(tag_name, css)?;
4556            }
4557            for (start, end) in &inline_style_ranges {
4558                css_light::validate_global_css(tag_name, &html[*start..*end])?;
4559            }
4560        }
4561        if self.component_registry.contains(tag_name) {
4562            self.component_registry
4563                .prepare_policy_css(tag_name, dom_analysis.uses_shadow_dom)?;
4564        }
4565        let css_content = self
4566            .component_registry
4567            .get(tag_name)
4568            .and_then(|component| component.css_content.as_deref())
4569            .or(css_content);
4570        let adopted_specifier = match self.options.css_strategy {
4571            CssStrategy::Module if css_content.is_some() => Some(tag_name),
4572            _ => None,
4573        };
4574        // CSS resources are installed by the handler from the precomputed
4575        // tree-local closure. Keeping template serialization style-free avoids
4576        // duplicating Light descendants and lets repeated Shadow instances each
4577        // receive an exact, claimable resource set.
4578        //
4579        // The resolved delivery is reported to the plugin as build context; a
4580        // plugin whose client runtime builds its own roots from the captured
4581        // template decides for itself what to do with it. Light CSS is
4582        // Document/tree-owned and is installed by the precomputed closure.
4583        let style = if dom_analysis.uses_shadow_dom && self.plugin.is_some() {
4584            self.component_style_delivery(tag_name, css_content)
4585        } else {
4586            None
4587        };
4588
4589        let runtime_attr_source = match dom_analysis.authored_shadow_root {
4590            Some((start, end)) => &html[start..end],
4591            None => html,
4592        };
4593        let artifact_source = if input.artifact_needed {
4594            input.artifact_html.or_else(|| {
4595                Self::template_has_stripped_runtime_attrs(runtime_attr_source).then_some(html)
4596            })
4597        } else {
4598            None
4599        };
4600        // A `w-render`/`w-hydrate` component authors its policy on a plain
4601        // `<template>` wrapper. The policy lands on the host element as
4602        // generated CSS, so the wrapper itself must not survive into a Light
4603        // template, where its contents would never render.
4604        let policy_wrapper = parse_component_render_policy(tag_name, html)?.is_authored();
4605        // Keep root runtime attributes only when the plugin processes them.
4606        let preserve_root_bindings = self.component_processing.process_root_template_attributes;
4607        // Keep CSS braces after client bindings when requested by the plugin.
4608        let styles_at_end = matches!(self.options.css_strategy, CssStrategy::Style)
4609            && self.component_processing.inline_styles_after_content;
4610        let ssr = self.process_component_template_with_mode(
4611            html,
4612            ComponentStyleInjection {
4613                css_snippet: None,
4614                adopted_specifier,
4615            },
4616            dom_analysis,
4617            ComponentTemplateMode {
4618                preserve_runtime_attrs: preserve_root_bindings,
4619                policy_wrapper,
4620                styles_at_end,
4621            },
4622        )?;
4623        let artifact = match artifact_source {
4624            Some(source) => {
4625                let fast_artifact = synthesize_fast_shadow
4626                    .then(|| Self::append_shadow_mode_if_missing(source.to_string()));
4627                let source = fast_artifact.as_deref().unwrap_or(source);
4628                let artifact_dom_analysis = analyze_component_dom_with_diagnostic_source(
4629                    tag_name,
4630                    source,
4631                    self.options.dom_strategy,
4632                    input.authored_html,
4633                )?;
4634                Some(self.process_component_template_with_mode(
4635                    source,
4636                    ComponentStyleInjection {
4637                        css_snippet: None,
4638                        adopted_specifier,
4639                    },
4640                    artifact_dom_analysis,
4641                    ComponentTemplateMode {
4642                        // Client artifacts preserve runtime-only attributes
4643                        // (`@event`, `:bind`, `?cond`) for the plugin runtime.
4644                        preserve_runtime_attrs: true,
4645                        policy_wrapper,
4646                        styles_at_end,
4647                    },
4648                )?)
4649            }
4650            None => None,
4651        };
4652        Ok(BuiltComponentTemplate {
4653            ssr,
4654            artifact,
4655            uses_shadow_dom: dom_analysis.uses_shadow_dom,
4656            style,
4657        })
4658    }
4659
4660    /// Resolve how a component's compiled CSS reaches the browser.
4661    ///
4662    /// Build-time only, once per component definition, and only when a plugin
4663    /// can observe it.
4664    fn component_style_delivery(
4665        &self,
4666        tag_name: &str,
4667        css_content: Option<&str>,
4668    ) -> Option<OwnedComponentStyle> {
4669        let css = css_content?;
4670        Some(match self.options.css_strategy {
4671            CssStrategy::Link => {
4672                OwnedComponentStyle::Link(self.options.css_link_options.resolve(tag_name, css).href)
4673            }
4674            CssStrategy::Style => OwnedComponentStyle::Inline(css.trim().to_string()),
4675            CssStrategy::Module => OwnedComponentStyle::Adopted(tag_name.to_string()),
4676        })
4677    }
4678
4679    /// Process component template HTML for SSR output.
4680    ///
4681    /// An authored declarative Shadow DOM wrapper is the source of truth.
4682    ///
4683    /// - **Dev supplied `<template shadowrootmode="open">`:** preserved verbatim — including
4684    ///   `shadowrootmode`, `shadowrootadoptedstylesheets`, signal fragments,
4685    ///   and any other custom attributes. SSR strips runtime-only attributes
4686    ///   (`@event`, `:bind`, `?cond`) from the opening tag, since those are
4687    ///   protocol metadata and never appear in HTML output. Plugin-facing
4688    ///   artifacts preserve them. If a CSS snippet is supplied, it is injected
4689    ///   immediately inside the opening tag (before the dev's children) so
4690    ///   styles still apply. For `CssStrategy::Module`, the parser appends
4691    ///   `shadowrootadoptedstylesheets="<tag>"` when it is missing.
4692    ///
4693    /// - **Sole bare `<template>` root:** unwraps the wrapper and emits its
4694    ///   contents as explicit Light DOM.
4695    ///
4696    /// - **No authored root:** follows the configured fallback, generating an
4697    ///   open Shadow wrapper for `DomStrategy::Shadow` or emitting direct Light
4698    ///   DOM for `DomStrategy::Light`.
4699    ///
4700    /// Performance: zero recursion, zero regex. The dev-template path uses
4701    /// quote-aware scanners for opening-tag queries and pre-sizes the output
4702    /// buffer to avoid reallocation in the hot path.
4703    #[cfg(test)]
4704    fn process_component_template(
4705        &mut self,
4706        html: &str,
4707        css_snippet: Option<&str>,
4708        adopted_specifier: Option<&str>,
4709    ) -> Result<String> {
4710        let dom_analysis = analyze_component_dom("component", html, self.options.dom_strategy)?;
4711        self.process_component_template_with_mode(
4712            html,
4713            ComponentStyleInjection {
4714                css_snippet,
4715                adopted_specifier,
4716            },
4717            dom_analysis,
4718            ComponentTemplateMode {
4719                preserve_runtime_attrs: false,
4720                policy_wrapper: false,
4721                styles_at_end: false,
4722            },
4723        )
4724    }
4725
4726    fn process_component_template_with_mode(
4727        &mut self,
4728        html: &str,
4729        style: ComponentStyleInjection<'_>,
4730        dom_analysis: ComponentDomAnalysis,
4731        mode: ComponentTemplateMode,
4732    ) -> Result<String> {
4733        let trimmed_end = html.trim_end();
4734        let (trimmed, content_start) = html::leading_content(trimmed_end);
4735        let snippet = style.css_snippet.unwrap_or_default();
4736
4737        let processed = if let Some((root_start, root_end)) = dom_analysis.authored_shadow_root {
4738            let trim_start = content_start;
4739            let trim_end = html.trim_end().len();
4740            if root_start < trim_start || root_end > trim_end || root_start >= root_end {
4741                return Err(ParserError::Html(
4742                    "invalid declarative Shadow DOM source range".to_string(),
4743                ));
4744            }
4745            let root = &html[root_start..root_end];
4746            // Compiler-owned policy attributes are stripped from both views;
4747            // runtime attributes survive only in the captured client template.
4748            let base = self.strip_template_build_attrs(root, mode.preserve_runtime_attrs);
4749            let with_adopted = Self::append_adopted_attr_if_missing(base, style.adopted_specifier);
4750            let root =
4751                Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end);
4752            let mut result =
4753                String::with_capacity(trimmed.len() + root.len() - (root_end - root_start));
4754            result.push_str(&html[trim_start..root_start]);
4755            result.push_str(&root);
4756            result.push_str(&html[root_end..trim_end]);
4757            result
4758        } else if let Some((content_start, content_end)) = dom_analysis.authored_light_root {
4759            let base = html[content_start..content_end].to_string();
4760            if snippet.is_empty() {
4761                base
4762            } else {
4763                let mut result = String::with_capacity(snippet.len() + base.len());
4764                result.push_str(snippet);
4765                result.push_str(&base);
4766                result
4767            }
4768        } else if dom_analysis.uses_shadow_dom {
4769            if mode.policy_wrapper {
4770                let base = self.strip_template_build_attrs(trimmed, mode.preserve_runtime_attrs);
4771                let with_shadow = Self::append_shadow_mode_if_missing(base);
4772                let with_adopted =
4773                    Self::append_adopted_attr_if_missing(with_shadow, style.adopted_specifier);
4774                Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end)
4775            } else {
4776                let adopted = style.adopted_specifier.unwrap_or_default();
4777                let adopted_extra = if adopted.is_empty() {
4778                    0
4779                } else {
4780                    Self::adopted_attr_len(adopted)
4781                };
4782                let mut result =
4783                    String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
4784                result.push_str("<template shadowrootmode=\"open\"");
4785                if !adopted.is_empty() {
4786                    Self::push_adopted_attr(&mut result, adopted);
4787                }
4788                result.push('>');
4789                if mode.styles_at_end {
4790                    result.push_str(trimmed);
4791                    result.push_str(snippet);
4792                } else {
4793                    result.push_str(snippet);
4794                    result.push_str(trimmed);
4795                }
4796                result.push_str("</template>");
4797                result
4798            }
4799        } else {
4800            // A policy component authors its `w-render`/`w-hydrate` on a plain
4801            // `<template>` wrapper. The policy is applied to the host element
4802            // through generated CSS, so a Light template must drop the wrapper
4803            // or its contents would never render.
4804            let base = if mode.policy_wrapper {
4805                Self::unwrap_component_template(trimmed)
4806            } else {
4807                trimmed.to_string()
4808            };
4809            if snippet.is_empty() {
4810                base
4811            } else {
4812                let mut result = String::with_capacity(snippet.len() + base.len());
4813                result.push_str(snippet);
4814                result.push_str(&base);
4815                result
4816            }
4817        };
4818
4819        self.strip_template_comments(processed)
4820    }
4821
4822    fn inject_css_snippet_into_template(html: String, snippet: &str, at_end: bool) -> String {
4823        if snippet.is_empty() {
4824            return html;
4825        }
4826
4827        // Trail the snippet after the template body (before the closing
4828        // `</template>`) so a client runtime that scans the body for `{`/`}`
4829        // bindings (FAST's declarative TemplateParser) never mistakes raw CSS
4830        // rule blocks for bindings. Styles apply regardless of shadow-root
4831        // position. Falls back to opening-tag injection when no closing tag is
4832        // present so styles are never silently dropped.
4833        if at_end {
4834            if let Some(close_start) = html.rfind("</template>") {
4835                let mut result = String::with_capacity(html.len() + snippet.len());
4836                result.push_str(&html[..close_start]);
4837                result.push_str(snippet);
4838                result.push_str(&html[close_start..]);
4839                return result;
4840            }
4841        }
4842
4843        match html::find_tag_close(&html) {
4844            Some(open_end) => {
4845                let mut result = String::with_capacity(html.len() + snippet.len());
4846                result.push_str(&html[..=open_end]);
4847                result.push_str(snippet);
4848                result.push_str(&html[open_end + 1..]);
4849                result
4850            }
4851            // Malformed opening tag (no closing `>`): emit as-is rather than
4852            // panic. The downstream parser surfaces the error.
4853            None => html,
4854        }
4855    }
4856
4857    fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
4858        let Some(adopted) = adopted_specifier else {
4859            return html;
4860        };
4861        let Some(tag) = html::parse_tag(&html) else {
4862            return html;
4863        };
4864        if !tag.name.eq_ignore_ascii_case("template")
4865            || tag.closing
4866            || tag.attrs().any(|attr| {
4867                attr.name
4868                    .eq_ignore_ascii_case(Self::ADOPTED_STYLESHEETS_ATTR)
4869            })
4870        {
4871            return html;
4872        }
4873
4874        let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
4875        result.push_str(&html[..tag.close]);
4876        Self::push_adopted_attr(&mut result, adopted);
4877        result.push_str(&html[tag.close..]);
4878        result
4879    }
4880
4881    fn append_shadow_mode_if_missing(html: String) -> String {
4882        let Some(tag) = html::parse_tag(&html) else {
4883            return html;
4884        };
4885        if !tag.name.eq_ignore_ascii_case("template")
4886            || tag.closing
4887            || tag
4888                .attrs()
4889                .any(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
4890        {
4891            return html;
4892        }
4893
4894        let mut result = String::with_capacity(html.len() + 22);
4895        result.push_str(&html[..tag.close]);
4896        result.push_str(" shadowrootmode=\"open\"");
4897        result.push_str(&html[tag.close..]);
4898        result
4899    }
4900
4901    fn unwrap_component_template(html: &str) -> String {
4902        let Some(tag) = html::parse_tag(html) else {
4903            return html.to_string();
4904        };
4905        let inner_start = tag.close + 1;
4906        let inner_end = html.rfind("</template>").unwrap_or(html.len());
4907        if inner_start >= inner_end {
4908            return String::new();
4909        }
4910        html[inner_start..inner_end].to_string()
4911    }
4912
4913    fn push_adopted_attr(out: &mut String, adopted: &str) {
4914        out.push(' ');
4915        out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
4916        out.push_str("=\"");
4917        out.push_str(adopted);
4918        out.push('"');
4919    }
4920
4921    fn adopted_attr_len(adopted: &str) -> usize {
4922        4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
4923    }
4924
4925    fn template_has_stripped_runtime_attrs(html: &str) -> bool {
4926        let (trimmed, _) = html::leading_content(html);
4927        let Some(tag) = html::parse_tag(trimmed) else {
4928            return false;
4929        };
4930        if !tag.name.eq_ignore_ascii_case("template") || tag.closing {
4931            return false;
4932        }
4933        tag.attrs().any(|attr| {
4934            attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
4935        })
4936    }
4937
4938    fn strip_template_comments(&mut self, html: String) -> Result<String> {
4939        let mut ranges = Vec::new();
4940        let mut style_ranges = Vec::new();
4941        Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
4942
4943        for (style_start, style_end) in style_ranges {
4944            let css = &html[style_start..style_end];
4945            let (_tokens, _defs, _requirements, comments) = self
4946                .css_parser
4947                .extract_tokens_definitions_requirements_and_comments(
4948                    css,
4949                    self.options.legal_comments,
4950                )
4951                .map_err(|e| self.css_diagnostic(e))?;
4952            for comment in comments {
4953                let comment_text = &css[comment.start_byte..comment.end_byte];
4954                if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
4955                    continue;
4956                }
4957                ranges.push((
4958                    style_start + comment.start_byte,
4959                    style_start + comment.end_byte,
4960                ));
4961            }
4962        }
4963
4964        if ranges.is_empty() {
4965            return Ok(html);
4966        }
4967
4968        Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
4969    }
4970
4971    /// Collect HTML comment ranges and `<style>` raw-text ranges in one scan.
4972    fn collect_html_comment_and_style_ranges(
4973        html: &str,
4974        comment_ranges: &mut Vec<(usize, usize)>,
4975        style_ranges: &mut Vec<(usize, usize)>,
4976    ) -> Result<()> {
4977        let mut index = 0usize;
4978        while index < html.len() {
4979            let remaining = &html[index..];
4980            if remaining.starts_with("<!--") {
4981                let Some(end) = html::find_comment_close(remaining) else {
4982                    return Err(Diagnostic::error("unterminated HTML comment")
4983                        .code(codes::UNTERMINATED_HTML_COMMENT)
4984                        .at_offset(html, index)
4985                        .help("close the comment with `-->`")
4986                        .into());
4987                };
4988                comment_ranges.push((index, index + end));
4989                index += end;
4990                continue;
4991            }
4992
4993            if remaining.starts_with('<') {
4994                if let Some((style_start, style_end, close_end)) =
4995                    html::style_element_bounds(remaining)
4996                {
4997                    style_ranges.push((index + style_start, index + style_end));
4998                    index += close_end;
4999                    continue;
5000                }
5001
5002                if let Some(tag) = html::parse_tag(remaining) {
5003                    index += tag.close + 1;
5004                    continue;
5005                }
5006            }
5007
5008            let Some(ch) = remaining.chars().next() else {
5009                break;
5010            };
5011            index += ch.len_utf8();
5012        }
5013        Ok(())
5014    }
5015
5016    /// Strip compiler-owned component policy attributes and, for the SSR view,
5017    /// runtime attributes starting with `@`, `:`, or `?` from the opening
5018    /// `<template>` tag.
5019    ///
5020    /// Uses the same quote-aware tag scanner as the main HTML pipeline.
5021    fn strip_template_build_attrs(&mut self, html: &str, preserve_runtime_attrs: bool) -> String {
5022        let Some(tag) = html::parse_tag(html) else {
5023            return html.to_string();
5024        };
5025
5026        let mut removals: Vec<(usize, usize)> = Vec::new();
5027        for attr in tag.attrs() {
5028            let runtime_attr = attr.name.starts_with('@')
5029                || attr.name.starts_with(':')
5030                || attr.name.starts_with('?');
5031            let policy_attr = matches!(
5032                attr.name,
5033                COMPONENT_RENDER_ATTR | COMPONENT_HYDRATE_ATTR | COMPONENT_RESERVE_BLOCK_SIZE_ATTR
5034            );
5035            if policy_attr || (!preserve_runtime_attrs && runtime_attr) {
5036                let mut start = attr.raw_range.start;
5037                while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
5038                    start -= 1;
5039                }
5040                removals.push((start, attr.raw_range.end));
5041            }
5042        }
5043
5044        if removals.is_empty() {
5045            return html.to_string();
5046        }
5047
5048        // Rebuild the string, skipping removed ranges
5049        let mut result = String::with_capacity(html.len());
5050        let mut pos = 0;
5051        for (start, end) in &removals {
5052            result.push_str(&html[pos..*start]);
5053            pos = *end;
5054        }
5055        result.push_str(&html[pos..]);
5056        result
5057    }
5058}
5059
5060#[cfg(test)]
5061mod tests {
5062    use webui_test_utils::*;
5063
5064    use super::*;
5065
5066    #[derive(Default)]
5067    struct ContextArtifactPlugin {
5068        artifacts: Vec<crate::plugin::ComponentTemplateArtifact>,
5069    }
5070
5071    impl ParserPlugin for ContextArtifactPlugin {
5072        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5073            self.artifacts
5074                .push(crate::plugin::ComponentTemplateArtifact::template(
5075                    context.component.tag_name.clone(),
5076                    context.template.to_string(),
5077                    context.uses_shadow_dom,
5078                ));
5079            Ok(())
5080        }
5081
5082        fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
5083            Ok(ParserPluginArtifacts::ComponentTemplates(self.artifacts))
5084        }
5085    }
5086
5087    fn structural_matcher(value: &str) -> FragmentMatcher {
5088        signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
5089    }
5090
5091    mod fast_parser;
5092
5093    fn boundary_matcher(name: &str, declaration: u32) -> FragmentMatcher {
5094        webui_test_utils::boundary(name, declaration)
5095    }
5096
5097    fn boundary_end_matcher(declaration: u32) -> FragmentMatcher {
5098        webui_test_utils::boundary_end(declaration)
5099    }
5100
5101    #[test]
5102    fn unwrapped_component_uses_configured_dom_strategy() {
5103        assert!(
5104            analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Shadow)
5105                .expect("valid component")
5106                .uses_shadow_dom
5107        );
5108        assert!(
5109            !analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Light)
5110                .expect("valid component")
5111                .uses_shadow_dom
5112        );
5113    }
5114
5115    #[test]
5116    fn bare_template_explicitly_selects_light_dom_over_shadow_fallback() {
5117        let analysis = analyze_component_dom(
5118            "x-card",
5119            "<template><div>content</div></template>",
5120            DomStrategy::Shadow,
5121        )
5122        .expect("valid explicit Light wrapper");
5123        assert!(!analysis.uses_shadow_dom);
5124        assert!(analysis.authored_light_root.is_some());
5125
5126        let attributed = analyze_component_dom(
5127            "x-card",
5128            r#"<template data-purpose="content"><div>content</div></template>"#,
5129            DomStrategy::Shadow,
5130        )
5131        .expect("ordinary template remains valid");
5132        assert!(attributed.uses_shadow_dom);
5133        assert!(attributed.authored_light_root.is_none());
5134    }
5135
5136    #[test]
5137    fn plugin_artifacts_preserve_resolved_shadow_mode() {
5138        let component_html = r#"<template shadowrootmode="open"><p>shadow</p></template>"#;
5139        {
5140            let mut parser = HtmlParser::with_plugin_options(
5141                Box::new(ContextArtifactPlugin::default()),
5142                CssStrategy::Style,
5143            );
5144            parser
5145                .component_registry_mut()
5146                .register_component(ComponentRegistration::new(
5147                    "x-card",
5148                    component_html,
5149                    None,
5150                    false,
5151                ))
5152                .expect("register component");
5153            parser
5154                .parse("index.html", "<x-card></x-card>")
5155                .expect("parse component");
5156
5157            let ParserPluginArtifacts::ComponentTemplates(artifacts) =
5158                parser.take_plugin_artifacts().expect("consume artifacts")
5159            else {
5160                panic!("expected component artifacts");
5161            };
5162            assert!(artifacts[0].uses_shadow_dom);
5163        }
5164    }
5165
5166    #[test]
5167    fn open_shadow_wrapper_marks_component_shadow() {
5168        let html =
5169            "<!-- lead --><template shadowrootmode='open'><slot></slot></template><!-- tail -->";
5170        assert!(
5171            analyze_component_dom("x-card", html, DomStrategy::Light)
5172                .expect("valid Shadow wrapper")
5173                .uses_shadow_dom
5174        );
5175    }
5176
5177    #[test]
5178    fn closed_shadow_wrapper_is_rejected() {
5179        let error = analyze_component_dom(
5180            "x-card",
5181            "<template shadowrootmode=\"closed\"><p>content</p></template>",
5182            DomStrategy::Shadow,
5183        )
5184        .expect_err("closed mode must fail");
5185        assert!(matches!(
5186            error,
5187            ParserError::Template(ref diagnostic)
5188                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5189                    && diagnostic.component_name() == Some("x-card")
5190        ));
5191    }
5192
5193    #[test]
5194    fn shadow_wrapper_must_be_the_only_top_level_element() {
5195        let error = analyze_component_dom(
5196            "x-card",
5197            "<template shadowrootmode=\"open\"><p>content</p></template><span>extra</span>",
5198            DomStrategy::Light,
5199        )
5200        .expect_err("multiple roots must fail");
5201        assert!(matches!(
5202            error,
5203            ParserError::Template(ref diagnostic)
5204                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5205        ));
5206    }
5207
5208    #[test]
5209    fn shadowrootmode_rejects_invalid_value_and_placement() {
5210        for html in [
5211            "<template shadowrootmode=\"invalid\"><p>content</p></template>",
5212            "<template shadowrootmode=\" open \"><p>content</p></template>",
5213            "<template :shadowrootmode=\"{{mode}}\"><p>content</p></template>",
5214            "<template shadowrootmode=\"open\" />",
5215            "<div><template shadowrootmode=\"open\"><p>content</p></template></div>",
5216            "<div shadowrootmode=\"open\">content</div>",
5217        ] {
5218            let error = analyze_component_dom("x-card", html, DomStrategy::Shadow)
5219                .expect_err("invalid shadowrootmode must fail");
5220            assert!(matches!(
5221                error,
5222                ParserError::Template(ref diagnostic)
5223                    if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5224                        && diagnostic.component_name() == Some("x-card")
5225                        && diagnostic.help_text().is_some()
5226                        && diagnostic.snippet_text().is_some()
5227            ));
5228        }
5229    }
5230
5231    #[test]
5232    fn light_component_slot_is_rejected() {
5233        let source = "<div><slot name=\"label\"></slot></div>";
5234        let error = analyze_component_dom("x-card", source, DomStrategy::Light)
5235            .expect_err("Light slot must fail");
5236        assert!(matches!(
5237            error,
5238            ParserError::Template(ref diagnostic)
5239                if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5240                    && diagnostic.help_text().is_some_and(|help| help.contains("shadowrootmode"))
5241        ));
5242        assert!(
5243            analyze_component_dom("x-card", source, DomStrategy::Shadow)
5244                .expect("implicit Shadow components support slots")
5245                .uses_shadow_dom
5246        );
5247
5248        let explicit_light = "<template><slot name=\"label\"></slot></template>";
5249        let error = analyze_component_dom("x-card", explicit_light, DomStrategy::Shadow)
5250            .expect_err("bare template explicitly selects Light");
5251        assert!(matches!(
5252            error,
5253            ParserError::Template(ref diagnostic)
5254                if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5255        ));
5256    }
5257
5258    #[test]
5259    fn webui_plugin_records_authored_shadow_wrapper() {
5260        let mut parser =
5261            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5262        parser
5263            .component_registry_mut()
5264            .register_component(ComponentRegistration::new(
5265                "x-card",
5266                "<!-- lead --><template shadowrootmode=\"open\" @click=\"{onClick()}\"><slot></slot></template><!-- tail -->",
5267                None,
5268                true,
5269            ))
5270            .expect("register component");
5271        parser
5272            .parse("index.html", "<x-card>projected</x-card>")
5273            .expect("parse component");
5274
5275        let ParserPluginArtifacts::ComponentTemplates(templates) =
5276            parser.take_plugin_artifacts().expect("plugin artifacts")
5277        else {
5278            panic!("expected component templates");
5279        };
5280        assert!(templates[0].uses_shadow_dom);
5281        assert!(templates[0].template_json.contains("\"sd\":1"));
5282        assert!(templates[0]
5283            .template_json
5284            .contains(r#""re":[["click","onClick",[]]]"#));
5285    }
5286
5287    /// Records the build context so tests can assert what the parser reports
5288    /// without asserting how any particular plugin reacts to it.
5289    struct StyleContextPlugin {
5290        captured: std::rc::Rc<std::cell::RefCell<Vec<(bool, Option<String>, String)>>>,
5291    }
5292
5293    impl crate::plugin::ParserPlugin for StyleContextPlugin {
5294        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5295            self.captured.borrow_mut().push((
5296                context.uses_shadow_dom,
5297                context.style.map(|style| format!("{style:?}")),
5298                context.template.to_string(),
5299            ));
5300            Ok(())
5301        }
5302    }
5303
5304    fn capture_style_context(
5305        strategy: CssStrategy,
5306        dom_strategy: DomStrategy,
5307        template: &str,
5308        css: Option<&str>,
5309    ) -> (bool, Option<String>, String) {
5310        let captured = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
5311        let mut parser = HtmlParser::with_plugin_options(
5312            Box::new(StyleContextPlugin {
5313                captured: std::rc::Rc::clone(&captured),
5314            }),
5315            ParserOptions {
5316                css_strategy: strategy,
5317                dom_strategy,
5318                ..ParserOptions::default()
5319            },
5320        );
5321        parser
5322            .component_registry_mut()
5323            .register_component(ComponentRegistration::new("x-card", template, css, true))
5324            .expect("register component");
5325        parser
5326            .parse("index.html", "<x-card></x-card>")
5327            .expect("parse component");
5328        let captured = captured.borrow();
5329        captured.first().cloned().expect("component registered")
5330    }
5331
5332    /// The parser reports the resolved delivery as data and injects nothing:
5333    /// a plugin that ignores `context.style` gets a style-free template even
5334    /// for an authored Shadow component.
5335    #[test]
5336    fn parser_reports_shadow_style_delivery_without_injecting_it() {
5337        for (strategy, expected) in [
5338            (CssStrategy::Link, "Link { href: \"x-card.css\" }"),
5339            (CssStrategy::Style, "Inline { css: \".card{color:red}\" }"),
5340            (CssStrategy::Module, "Adopted { specifier: \"x-card\" }"),
5341        ] {
5342            let (uses_shadow_dom, style, template) = capture_style_context(
5343                strategy,
5344                DomStrategy::Shadow,
5345                "<template shadowrootmode=\"open\" shadowrootadoptedstylesheets=\"x-card\"><div class=\"card\"></div></template>",
5346                Some(".card{color:red}"),
5347            );
5348            assert!(uses_shadow_dom, "{strategy:?} should be shadow");
5349            assert_eq!(style.as_deref(), Some(expected), "{strategy:?} delivery");
5350            assert!(
5351                !template.contains("<style>") && !template.contains("<link"),
5352                "{strategy:?} template must stay injection-free: {template}"
5353            );
5354        }
5355    }
5356
5357    /// Light CSS belongs to the owning CSS tree, so there is nothing for a
5358    /// plugin to place inside a runtime-created root.
5359    #[test]
5360    fn parser_reports_no_style_delivery_for_light_components() {
5361        let (uses_shadow_dom, style, _) = capture_style_context(
5362            CssStrategy::Style,
5363            DomStrategy::Light,
5364            "<div class=\"card\"></div>",
5365            Some(".card{color:red}"),
5366        );
5367        assert!(!uses_shadow_dom);
5368        assert_eq!(style, None);
5369    }
5370
5371    /// A component without CSS reports no delivery at all.
5372    #[test]
5373    fn parser_reports_no_style_delivery_without_css() {
5374        let (uses_shadow_dom, style, _) = capture_style_context(
5375            CssStrategy::Style,
5376            DomStrategy::Shadow,
5377            "<template shadowrootmode=\"open\"><div></div></template>",
5378            None,
5379        );
5380        assert!(uses_shadow_dom);
5381        assert_eq!(style, None);
5382    }
5383
5384    #[test]
5385    fn test_plugin_display_names() {
5386        assert_eq!(Plugin::Fast.to_string(), "fast");
5387        assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
5388        assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
5389        assert_eq!(Plugin::WebUI.to_string(), "webui");
5390    }
5391
5392    #[test]
5393    fn test_plugin_from_str_names() {
5394        assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
5395        assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
5396        assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
5397        assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
5398    }
5399
5400    #[test]
5401    fn test_plugin_from_str_unknown_mentions_supported_names() {
5402        let err = "legacy".parse::<Plugin>().unwrap_err();
5403        assert!(
5404            err.contains("fast-v3"),
5405            "error should mention fast-v3: {err}"
5406        );
5407        assert!(
5408            err.contains("fast-v2"),
5409            "error should mention fast-v2: {err}"
5410        );
5411        assert!(err.contains("fast"), "error should mention fast: {err}");
5412    }
5413
5414    #[test]
5415    fn test_parse_signal() {
5416        let mut parser = HtmlParser::new();
5417        let html = "Hello, {{name}}!";
5418        let result = parser.parse("test.html", html);
5419
5420        assert!(result.is_ok());
5421        let fragment_records = parser.into_fragment_records();
5422
5423        assert_stream!(
5424            fragment_records,
5425            "test.html",
5426            [raw("Hello, "), signal("name"), raw("!"),]
5427        );
5428    }
5429
5430    #[test]
5431    fn test_parse_raw_signal() {
5432        let mut parser = HtmlParser::new();
5433        let html = "Hello, {{{html_content}}}!";
5434        let result = parser.parse("test.html", html);
5435
5436        assert!(result.is_ok());
5437        let fragment_records = parser.into_fragment_records();
5438
5439        assert_stream!(
5440            fragment_records,
5441            "test.html",
5442            [raw("Hello, "), signal_raw("html_content"), raw("!"),]
5443        );
5444    }
5445
5446    #[test]
5447    fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
5448        let mut parser = HtmlParser::new();
5449        let html = "<nav>{{sectionName}} &gt; {{topicName}}</nav>";
5450        let result = parser.parse("test.html", html);
5451
5452        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5453        let fragment_records = parser.into_fragment_records();
5454
5455        assert_stream!(
5456            fragment_records,
5457            "test.html",
5458            [
5459                raw("<nav>"),
5460                signal("sectionName"),
5461                raw(" &gt; "),
5462                signal("topicName"),
5463                raw("</nav>"),
5464            ]
5465        );
5466    }
5467
5468    #[test]
5469    fn test_invalid_for_each_is_template_diagnostic() {
5470        let mut parser = HtmlParser::new();
5471        let err = parser
5472            .parse(
5473                "index.html",
5474                "<div>\n  <for each=\"person inpeople\"><p>x</p></for>\n</div>",
5475            )
5476            .expect_err("invalid for-each must error");
5477        assert!(matches!(err, ParserError::Template(_)));
5478        let msg = err.to_string();
5479        assert!(msg.contains("invalid <for> each expression"), "{msg}");
5480        assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
5481        // The <for> sits on line 2, column 3 (after the two-space indent),
5482        // reported rustc-style as `--> index.html:2:3`.
5483        assert!(
5484            msg.contains("--> index.html:2:3"),
5485            "missing line:column — {msg}"
5486        );
5487        assert!(msg.contains("help:"), "{msg}");
5488    }
5489
5490    #[test]
5491    fn test_invalid_if_condition_is_template_diagnostic() {
5492        let mut parser = HtmlParser::new();
5493        let err = parser
5494            .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
5495            .expect_err("invalid condition must error");
5496        assert!(matches!(err, ParserError::Template(_)));
5497        let msg = err.to_string();
5498        assert!(msg.contains("invalid <if> condition expression"), "{msg}");
5499        assert!(
5500            msg.contains("--> index.html:1:1"),
5501            "missing line:column — {msg}"
5502        );
5503    }
5504
5505    #[test]
5506    fn test_parse_for_directive() {
5507        let mut parser = HtmlParser::new();
5508        let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
5509
5510        let result = parser.parse("test.html", html);
5511        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5512        let fragment_records = parser.into_fragment_records();
5513
5514        assert_stream!(
5515            fragment_records,
5516            "test.html",
5517            [for_loop("item", "items", "for-1"),]
5518        );
5519
5520        // Verify the sub-fragment contains our item content
5521        assert_stream!(
5522            fragment_records,
5523            "for-1",
5524            [
5525                raw("<div class=\"item\">"),
5526                signal("item.name"),
5527                raw("</div>"),
5528            ]
5529        );
5530    }
5531
5532    #[test]
5533    fn test_parse_if_directive() {
5534        let mut parser = HtmlParser::new();
5535        let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
5536
5537        let result = parser.parse("test.html", html);
5538        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5539        let fragment_records = parser.into_fragment_records();
5540
5541        assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
5542
5543        // Verify the sub-fragment contains our content
5544        assert_stream!(
5545            fragment_records,
5546            "if-1",
5547            [
5548                raw("<div>Welcome back, "),
5549                signal("username"),
5550                raw("!</div>"),
5551            ]
5552        );
5553    }
5554
5555    #[test]
5556    fn test_component_directive() {
5557        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5558        parser
5559            .component_registry
5560            .register_component(ComponentRegistration::new(
5561                "my-component",
5562                "<div>My Component</div>",
5563                Some("div { color: blue; }"),
5564                true,
5565            ))
5566            .expect("Failed to register component");
5567
5568        let result = parser.parse("test.html", "<my-component></my-component>");
5569        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5570        let records = parser.into_fragment_records();
5571
5572        assert_stream!(
5573            records,
5574            "test.html",
5575            [
5576                raw("<my-component"),
5577                structural_matcher("streaming_root:my-component"),
5578                raw(">"),
5579                component("my-component"),
5580                raw("</my-component>"),
5581            ]
5582        );
5583
5584        // Component template stream should contain the component content with
5585        // no compiler-owned Light DOM markers.
5586        let comp = &records["my-component"].fragments;
5587        assert_eq!(comp.len(), 1);
5588        assert!(
5589            matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5590                !raw.value.contains("<template shadowrootmode")
5591                    && raw.value.contains("<div")
5592                    && raw.value.contains(">My Component</div>"))
5593        );
5594    }
5595
5596    #[test]
5597    fn shadow_component_emits_style_hook_inside_declarative_root() {
5598        let mut parser = HtmlParser::new();
5599        parser
5600            .component_registry
5601            .register_component(ComponentRegistration::new(
5602                "my-component",
5603                r#"<template shadowrootmode="open"><div>My Component</div></template>"#,
5604                Some("div { color: blue; }"),
5605                true,
5606            ))
5607            .expect("register");
5608
5609        parser
5610            .parse("test.html", "<my-component></my-component>")
5611            .expect("parse");
5612        let records = parser.into_fragment_records();
5613        assert_stream!(
5614            records,
5615            "my-component",
5616            [
5617                raw("<template shadowrootmode=\"open\">"),
5618                structural_matcher("shadow_styles:my-component"),
5619                raw("<div>My Component</div></template>"),
5620            ]
5621        );
5622        assert!(
5623            records["my-component"]
5624                .fragments
5625                .iter()
5626                .all(|fragment| !matches!(
5627                    fragment.fragment.as_ref(),
5628                    Some(Fragment::Raw(raw)) if raw.value.contains("color: blue")
5629                )),
5630            "component CSS is delivered by the handler closure"
5631        );
5632    }
5633
5634    #[test]
5635    fn authored_shadow_attribute_name_is_ascii_case_insensitive_for_style_hook() {
5636        let mut parser = HtmlParser::new();
5637        parser
5638            .component_registry
5639            .register_component(ComponentRegistration::new(
5640                "my-component",
5641                r#"<template shadowRootMode="open"><slot></slot></template>"#,
5642                None,
5643                true,
5644            ))
5645            .expect("register");
5646
5647        parser
5648            .parse("test.html", "<my-component></my-component>")
5649            .expect("parse");
5650        let records = parser.into_fragment_records();
5651        assert_stream!(
5652            records,
5653            "my-component",
5654            [
5655                raw(r#"<template shadowRootMode="open">"#),
5656                structural_matcher("shadow_styles:my-component"),
5657                raw("<slot></slot></template>"),
5658            ]
5659        );
5660    }
5661
5662    #[test]
5663    fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
5664        // The compiler-owned streamed SSR root signal must sit between the
5665        // component's attribute run and the closing `>`, carrying the tag,
5666        // so the streaming handler can inject ` data-ws` inside the tag before
5667        // custom-element upgrade. Ordinary rendering ignores the signal.
5668        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5669        parser
5670            .component_registry
5671            .register_component(ComponentRegistration::new(
5672                "my-widget",
5673                "<div>w</div>",
5674                None,
5675                true,
5676            ))
5677            .expect("register");
5678
5679        let result = parser.parse("test.html", "<my-widget></my-widget>");
5680        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5681        let records = parser.into_fragment_records();
5682
5683        assert_stream!(
5684            records,
5685            "test.html",
5686            [
5687                raw("<my-widget"),
5688                structural_matcher("streaming_root:my-widget"),
5689                raw(">"),
5690                component("my-widget"),
5691                raw("</my-widget>"),
5692            ]
5693        );
5694    }
5695
5696    #[test]
5697    fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
5698        // Each distinct component host emits its own streaming_root signal,
5699        // including nested hosts and hosts repeated across sibling positions.
5700        use webui_protocol::web_ui_fragment::Fragment;
5701        let mut parser = HtmlParser::new();
5702        for name in ["outer-box", "inner-pill"] {
5703            parser
5704                .component_registry
5705                .register_component(ComponentRegistration::new(
5706                    name,
5707                    "<div>content</div>",
5708                    None,
5709                    true,
5710                ))
5711                .expect("register");
5712        }
5713
5714        let result = parser.parse(
5715            "test.html",
5716            "<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
5717        );
5718        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5719        let records = parser.into_fragment_records();
5720        let fragments = &records["test.html"].fragments;
5721
5722        let signals: Vec<&str> = fragments
5723            .iter()
5724            .filter_map(|f| match f.fragment.as_ref() {
5725                Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
5726                _ => None,
5727            })
5728            .collect();
5729        assert_eq!(
5730            signals,
5731            [
5732                "}}}webui:streaming_root:outer-box",
5733                "}}}webui:streaming_root:inner-pill",
5734                "}}}webui:streaming_root:inner-pill",
5735            ]
5736        );
5737
5738        // Every streaming_root signal is immediately followed by a `>` raw and
5739        // preceded by a raw ending in the host tag name (never a bare `<tag>`).
5740        for (i, frag) in fragments.iter().enumerate() {
5741            let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
5742                continue;
5743            };
5744            if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
5745                continue;
5746            }
5747            match fragments[i + 1].fragment.as_ref() {
5748                Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
5749                other => panic!("expected raw(\">\") after signal, got {other:?}"),
5750            }
5751        }
5752    }
5753
5754    #[test]
5755    fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
5756        let mut parser = HtmlParser::new();
5757        parser
5758            .component_registry
5759            .register_component(ComponentRegistration::new(
5760                "mp-button",
5761                "<button>b</button>",
5762                None,
5763                true,
5764            ))
5765            .expect("Failed to register component");
5766
5767        // `<mp-buton>` is a same-namespace one-character typo of `mp-button`.
5768        let err = parser
5769            .parse("test.html", "<mp-buton></mp-buton>")
5770            .expect_err("a near-typo component tag should error");
5771        let ParserError::Template(diag) = err else {
5772            panic!("expected ParserError::Template, got {err:?}");
5773        };
5774        assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
5775        let help = diag
5776            .help_text()
5777            .expect("diagnostic should carry a help line");
5778        assert!(
5779            help.contains("did you mean <mp-button>?"),
5780            "expected a suggestion, got: {help}"
5781        );
5782    }
5783
5784    #[test]
5785    fn external_custom_element_in_other_namespace_passes_through() {
5786        let mut parser = HtmlParser::new();
5787        parser
5788            .component_registry
5789            .register_component(ComponentRegistration::new(
5790                "mp-button",
5791                "<button>b</button>",
5792                None,
5793                true,
5794            ))
5795            .expect("Failed to register component");
5796
5797        // `<md-button>` is a different namespace (md- vs mp-): a genuine
5798        // third-party custom element must pass through as raw HTML, not error.
5799        let result = parser.parse("test.html", "<md-button></md-button>");
5800        assert!(
5801            result.is_ok(),
5802            "external custom element should pass through, got: {:?}",
5803            result.err()
5804        );
5805    }
5806
5807    #[test]
5808    fn for_missing_each_suggests_typoed_attribute() {
5809        let mut parser = HtmlParser::new();
5810        // `eahc` is a transposition of the required `each` attribute.
5811        let err = parser
5812            .parse("test.html", "<for eahc=\"todo in todos\"></for>")
5813            .expect_err("a <for> without `each` should error");
5814        let ParserError::Template(diag) = err else {
5815            panic!("expected ParserError::Template, got {err:?}");
5816        };
5817        assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
5818        let help = diag
5819            .help_text()
5820            .expect("diagnostic should carry a help line");
5821        assert!(
5822            help.contains("found `eahc`") && help.contains("did you mean"),
5823            "expected an attribute-typo suggestion, got: {help}"
5824        );
5825    }
5826
5827    #[test]
5828    fn parent_diagnostic_owner_survives_nested_component_parse() {
5829        // Regression: a component parse recurses through `self.parse(child, …)`,
5830        // which used to leave `current_fragment_id` pointing at the child. A
5831        // later error in the PARENT template must still be attributed to the
5832        // parent (here `index.html`), not the nested component.
5833        let mut parser = HtmlParser::new();
5834        parser
5835            .component_registry
5836            .register_component(ComponentRegistration::new(
5837                "my-card",
5838                "<div>card</div>",
5839                None,
5840                true,
5841            ))
5842            .expect("register");
5843
5844        // A registered component first, then a broken <for> in the same template.
5845        let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
5846        let err = parser
5847            .parse("index.html", html)
5848            .expect_err("the malformed <for> should error");
5849        let ParserError::Template(diag) = err else {
5850            panic!("expected ParserError::Template, got {err:?}");
5851        };
5852        assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
5853        assert_eq!(
5854            diag.component_name(),
5855            Some("index.html"),
5856            "parent diagnostic must be owned by index.html, not the nested component"
5857        );
5858    }
5859
5860    #[test]
5861    fn test_component_directive_with_slots() {
5862        let mut parser = HtmlParser::new();
5863        parser
5864            .component_registry
5865            .register_component(ComponentRegistration::new(
5866                "my-component",
5867                "<div>My Component</div>",
5868                Some("div { color: blue; }"),
5869                true,
5870            ))
5871            .expect("Failed to register component");
5872
5873        let result = parser.parse(
5874            "test.html",
5875            "Hello<my-component><p>World</p></my-component>",
5876        );
5877        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5878        let records = parser.into_fragment_records();
5879        let fragments = &records["test.html"].fragments;
5880
5881        // Entry: raw(Hello<my-component>) + component + raw(<p>World</p></my-component>)
5882        assert!(fragments.len() >= 3);
5883        // First fragment should contain "Hello" and "<my-component" (the opening
5884        // tag is split by the compiler-owned streaming_root signal before `>`).
5885        assert!(
5886            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
5887        );
5888        // Should have component fragment
5889        assert!(fragments.iter().any(|f| matches!(
5890            f.fragment.as_ref(),
5891            Some(Fragment::Component(c)) if c.fragment_id == "my-component"
5892        )));
5893        // Should end with closing tag
5894        let last = fragments.last().unwrap();
5895        assert!(
5896            matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
5897        );
5898    }
5899
5900    #[test]
5901    fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
5902        // Reproduces commerce app: mp-app has a shadow DOM template containing
5903        // child components (mp-navbar, mp-cart-panel, mp-footer) plus an <outlet>.
5904        // The parser must emit Fragment::Component entries for ALL child components
5905        // so the inventory walk finds them.
5906        let mut parser = HtmlParser::new();
5907
5908        parser
5909            .component_registry
5910            .register_component(ComponentRegistration::new(
5911                "app-shell",
5912                r#"<template shadowrootmode="open">
5913                  <my-navbar></my-navbar>
5914                  <main><outlet /></main>
5915                  <cart-panel></cart-panel>
5916                  <my-footer></my-footer>
5917                </template>"#,
5918                Some(":host{display:flex}"),
5919                true,
5920            ))
5921            .expect("register app-shell");
5922        parser
5923            .component_registry
5924            .register_component(ComponentRegistration::new(
5925                "my-navbar",
5926                "<nav>Nav</nav>",
5927                Some("nav{color:red}"),
5928                true,
5929            ))
5930            .expect("register my-navbar");
5931        parser
5932            .component_registry
5933            .register_component(ComponentRegistration::new(
5934                "cart-panel",
5935                "<aside>Cart</aside>",
5936                Some("aside{color:green}"),
5937                true,
5938            ))
5939            .expect("register cart-panel");
5940        parser
5941            .component_registry
5942            .register_component(ComponentRegistration::new(
5943                "my-footer",
5944                "<footer>Footer</footer>",
5945                Some("footer{color:blue}"),
5946                true,
5947            ))
5948            .expect("register my-footer");
5949
5950        parser
5951            .parse("index.html", "<app-shell></app-shell>")
5952            .expect("parse index.html");
5953        let records = parser.into_fragment_records();
5954
5955        // app-shell's fragment list must contain Component entries for all children
5956        let app_frags = &records["app-shell"].fragments;
5957        let component_ids: Vec<&str> = app_frags
5958            .iter()
5959            .filter_map(|f| match f.fragment.as_ref() {
5960                Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
5961                _ => None,
5962            })
5963            .collect();
5964
5965        assert!(
5966            component_ids.contains(&"my-navbar"),
5967            "app-shell fragments should contain my-navbar: {component_ids:?}"
5968        );
5969        assert!(
5970            component_ids.contains(&"cart-panel"),
5971            "app-shell fragments should contain cart-panel: {component_ids:?}"
5972        );
5973        assert!(
5974            component_ids.contains(&"my-footer"),
5975            "app-shell fragments should contain my-footer: {component_ids:?}"
5976        );
5977    }
5978
5979    // ── Component template wrapping tests ────────────────────────────
5980
5981    #[test]
5982    fn test_component_no_double_wrap_template() {
5983        // Developer-authored <template foo="bar"> must be preserved verbatim
5984        // in Light DOM. The framework only strips runtime-only attrs;
5985        // every other attribute is the developer's responsibility.
5986        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5987        parser
5988            .component_registry
5989            .register_component(ComponentRegistration::new(
5990                "custom-element",
5991                r#"<template foo="bar"><div>content</div></template>"#,
5992                None,
5993                true,
5994            ))
5995            .expect("register");
5996        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
5997        assert!(result.is_ok());
5998        let records = parser.into_fragment_records();
5999
6000        assert_fragments!(
6001            records["index.html"].fragments,
6002            [
6003                raw("<custom-element"),
6004                structural_matcher("streaming_root:custom-element"),
6005                raw(">"),
6006                component("custom-element"),
6007                raw("Hello</custom-element>"),
6008            ]
6009        );
6010
6011        assert_stream!(
6012            records,
6013            "custom-element",
6014            [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6015        );
6016    }
6017
6018    #[test]
6019    fn test_component_styled_no_double_wrap() {
6020        // Light DOM with a developer-supplied ordinary <template> wrapper
6021        // preserves the wrapper verbatim. CSS is delivered by the owning
6022        // closure rather than spliced into the template.
6023        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6024        parser
6025            .component_registry
6026            .register_component(ComponentRegistration::new(
6027                "custom-element",
6028                r#"<template foo="bar"><div>content</div></template>"#,
6029                Some("div { color: red; }"),
6030                true,
6031            ))
6032            .expect("register");
6033        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6034        assert!(result.is_ok());
6035        let records = parser.into_fragment_records();
6036
6037        assert_stream!(
6038            records,
6039            "custom-element",
6040            [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6041        );
6042    }
6043
6044    #[test]
6045    fn test_component_strip_runtime_attrs() {
6046        // Runtime-only attributes (`@event`, `:bind`, `?cond`) are stripped
6047        // from the opening <template> tag, but the wrapper itself is
6048        // preserved. The declarative Shadow DOM attribute remains.
6049        let mut parser = HtmlParser::new();
6050        parser
6051            .component_registry
6052            .register_component(ComponentRegistration::new(
6053                "custom-element",
6054                r#"<template shadowrootmode="open" @click={foo} :bar="baz" ?bool="true"><div>content</div></template>"#,
6055                None,
6056                true,
6057            ))
6058            .expect("register");
6059        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6060        assert!(result.is_ok());
6061        let records = parser.into_fragment_records();
6062
6063        assert_stream!(
6064            records,
6065            "custom-element",
6066            [
6067                raw(r#"<template shadowrootmode="open">"#),
6068                structural_matcher("shadow_styles:custom-element"),
6069                raw("<div>content</div></template>"),
6070            ]
6071        );
6072    }
6073
6074    #[test]
6075    fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
6076        let mut parser = HtmlParser::new();
6077        parser
6078            .component_registry
6079            .register_component(ComponentRegistration::new(
6080                "custom-element",
6081                r#"<template shadowrootmode="open" data-note="@click={foo}" @click={foo}><div>content</div></template>"#,
6082                None,
6083                true,
6084            ))
6085            .expect("register");
6086        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6087        assert!(result.is_ok());
6088        let records = parser.into_fragment_records();
6089
6090        assert_stream!(
6091            records,
6092            "custom-element",
6093            [
6094                raw(r#"<template shadowrootmode="open" data-note="@click={foo}">"#),
6095                structural_matcher("shadow_styles:custom-element"),
6096                raw("<div>content</div></template>"),
6097            ]
6098        );
6099    }
6100
6101    #[test]
6102    fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
6103        let mut parser = HtmlParser::new();
6104        parser
6105            .component_registry
6106            .register_component(ComponentRegistration::new(
6107                "custom-element",
6108                r#"<template shadowrootmode="open" @click={foo} @click={bar}><div>content</div></template>"#,
6109                None,
6110                true,
6111            ))
6112            .expect("register");
6113        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6114        assert!(result.is_ok());
6115        let records = parser.into_fragment_records();
6116
6117        assert_stream!(
6118            records,
6119            "custom-element",
6120            [
6121                raw(r#"<template shadowrootmode="open">"#),
6122                structural_matcher("shadow_styles:custom-element"),
6123                raw("<div>content</div></template>"),
6124            ]
6125        );
6126    }
6127
6128    #[test]
6129    fn test_component_with_slots_and_attrs() {
6130        let mut parser = HtmlParser::new();
6131        parser
6132            .component_registry
6133            .register_component(ComponentRegistration::new(
6134                "custom-element",
6135                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6136                None,
6137                true,
6138            ))
6139            .expect("register");
6140        let result = parser.parse(
6141            "index.html",
6142            r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
6143        );
6144        assert!(result.is_ok());
6145        let records = parser.into_fragment_records();
6146        assert_fragments!(
6147            records["index.html"].fragments,
6148            [
6149                raw("<custom-element"),
6150                attr_raw_start("appearance", "subtle"),
6151                structural_matcher("streaming_root:custom-element"),
6152                raw(">"),
6153                component("custom-element"),
6154                raw("Hello World</custom-element>"),
6155            ]
6156        );
6157    }
6158
6159    #[test]
6160    fn test_component_legacy_no_styles() {
6161        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6162        parser
6163            .component_registry
6164            .register_component(ComponentRegistration::new(
6165                "custom-element",
6166                "<div>Custom Element</div>",
6167                None,
6168                true,
6169            ))
6170            .expect("register");
6171        let result = parser.parse("index.html", "<custom-element></custom-element>");
6172        assert!(result.is_ok());
6173        let records = parser.into_fragment_records();
6174
6175        assert_fragments!(
6176            records["index.html"].fragments,
6177            [
6178                raw("<custom-element"),
6179                structural_matcher("streaming_root:custom-element"),
6180                raw(">"),
6181                component("custom-element"),
6182                raw("</custom-element>"),
6183            ]
6184        );
6185
6186        assert_stream!(
6187            records,
6188            "custom-element",
6189            [raw("<div>Custom Element</div>"),]
6190        );
6191    }
6192
6193    #[test]
6194    fn test_component_self_closing() {
6195        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6196        parser
6197            .component_registry
6198            .register_component(ComponentRegistration::new(
6199                "custom-widget",
6200                "<div>Widget Content</div>",
6201                None,
6202                true,
6203            ))
6204            .expect("register");
6205        let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
6206        assert!(result.is_ok());
6207        let records = parser.into_fragment_records();
6208
6209        assert_fragments!(
6210            records["index.html"].fragments,
6211            [
6212                raw("<custom-widget"),
6213                attr_start("config", "settings"),
6214                structural_matcher("streaming_root:custom-widget"),
6215                raw("/>"),
6216                component("custom-widget"),
6217            ]
6218        );
6219    }
6220
6221    #[test]
6222    fn test_component_nested_self_closing_in_slot() {
6223        let mut parser = HtmlParser::new();
6224        parser
6225            .component_registry
6226            .register_component(ComponentRegistration::new(
6227                "custom-icon",
6228                "<template shadowrootmode=\"open\"><svg><slot></slot></svg></template>",
6229                None,
6230                true,
6231            ))
6232            .expect("register");
6233        let result = parser.parse(
6234            "index.html",
6235            r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
6236        );
6237        assert!(result.is_ok());
6238        let records = parser.into_fragment_records();
6239
6240        assert_fragments!(
6241            records["index.html"].fragments,
6242            [
6243                raw("<custom-icon"),
6244                structural_matcher("streaming_root:custom-icon"),
6245                raw(">"),
6246                component("custom-icon"),
6247                raw("<use"),
6248                attr_template("href", "attr-1"),
6249                raw("/></custom-icon>"),
6250            ]
6251        );
6252
6253        assert_stream!(
6254            records,
6255            "custom-icon",
6256            [
6257                raw("<template shadowrootmode=\"open\">"),
6258                structural_matcher("shadow_styles:custom-icon"),
6259                raw("<svg><slot></slot></svg></template>"),
6260            ]
6261        );
6262    }
6263
6264    #[test]
6265    fn test_component_leading_boolean_attr_start() {
6266        let mut parser = HtmlParser::new();
6267        parser
6268            .component_registry
6269            .register_component(ComponentRegistration::new(
6270                "custom-element",
6271                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6272                None,
6273                true,
6274            ))
6275            .expect("register");
6276        let result = parser.parse(
6277            "index.html",
6278            r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
6279        );
6280        assert!(result.is_ok());
6281        let records = parser.into_fragment_records();
6282
6283        assert_fragments!(
6284            records["index.html"].fragments,
6285            [
6286                raw("<custom-element"),
6287                // First dynamic attr: boolean with attrStart
6288                bool_attr_start("disabled", "isDisabled"),
6289                // Static attr after dynamic: rawValue
6290                attr_raw("title", "Hello"),
6291                structural_matcher("streaming_root:custom-element"),
6292                raw(">"),
6293                component("custom-element"),
6294                raw("</custom-element>"),
6295            ]
6296        );
6297    }
6298
6299    #[test]
6300    fn test_component_boolean_predicate_preserves_condition_tree() {
6301        let mut parser = HtmlParser::new();
6302        parser
6303            .component_registry
6304            .register_component(ComponentRegistration::new(
6305                "custom-element",
6306                r#"<template shadowrootmode="open"><slot></slot></template>"#,
6307                None,
6308                true,
6309            ))
6310            .expect("register");
6311        let result = parser.parse(
6312            "index.html",
6313            r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
6314        );
6315        assert!(result.is_ok());
6316
6317        let records = parser.into_fragment_records();
6318        let fragments = &records["index.html"].fragments;
6319        match fragments
6320            .get(1)
6321            .and_then(|fragment| fragment.fragment.as_ref())
6322        {
6323            Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
6324                assert_eq!(attr.name, "disabled");
6325                assert!(attr.attr_start);
6326                match attr
6327                    .condition_tree
6328                    .as_ref()
6329                    .and_then(|condition| condition.expr.as_ref())
6330                {
6331                    Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
6332                        assert_eq!(pred.left, "page");
6333                        assert_eq!(pred.operator, 3);
6334                        assert_eq!(pred.right, "'dashboard'");
6335                    }
6336                    other => panic!("expected predicate condition tree, got {:?}", other),
6337                }
6338            }
6339            other => panic!("expected attribute fragment, got {:?}", other),
6340        }
6341    }
6342
6343    #[test]
6344    fn test_component_meta_link_tags() {
6345        let (fragments, _) = parse_and_get_fragments(
6346            r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
6347        );
6348        assert!(fragments.len() >= 7);
6349        assert!(
6350            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
6351        );
6352        assert!(
6353            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
6354                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
6355        );
6356        assert!(
6357            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
6358        );
6359        assert!(
6360            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
6361        );
6362        assert!(
6363            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
6364        );
6365        assert!(
6366            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
6367                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
6368        );
6369        assert!(
6370            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
6371        );
6372    }
6373
6374    #[test]
6375    fn test_nested_directives() {
6376        let mut parser = HtmlParser::new();
6377        let html = r#"<for each="category in categories">
6378            <if condition="category.hasItems">
6379                <for each="item in category.items">
6380                   {{item.title}}
6381                </for>
6382            </if>
6383        </for>"#;
6384
6385        let result = parser.parse("test.html", html);
6386
6387        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6388        let fragment_records = parser.into_fragment_records();
6389
6390        assert_stream!(
6391            fragment_records,
6392            "test.html",
6393            [for_loop("category", "categories", "for-1"),]
6394        );
6395
6396        assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
6397
6398        assert_stream!(
6399            fragment_records,
6400            "if-1",
6401            [for_loop("item", "category.items", "for-2"),]
6402        );
6403
6404        assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
6405    }
6406
6407    #[test]
6408    fn test_complex_directives() {
6409        let mut parser = HtmlParser::new();
6410        let html = r#"<for each="category in categories">
6411            <div class="category">
6412                <h2>{{category.name}}</h2>
6413                <if condition="category.hasItems">
6414                    <ul>
6415                        <for each="item in category.items">
6416                            <li>{{item.title}}</li>
6417                        </for>
6418                    </ul>
6419                </if>
6420            </div>
6421        </for>"#;
6422
6423        let result = parser.parse("test.html", html);
6424
6425        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6426        let fragment_records = parser.into_fragment_records();
6427
6428        assert_stream!(
6429            fragment_records,
6430            "test.html",
6431            [for_loop("category", "categories", "for-1"),]
6432        );
6433
6434        // Verify for fragments contains the category.name signal
6435        assert_stream!(
6436            fragment_records,
6437            "for-1",
6438            [
6439                raw("<div class=\"category\"><h2>"),
6440                signal("category.name"),
6441                raw("</h2>"),
6442                if_cond("if-1"),
6443                raw("</div>"),
6444            ]
6445        );
6446
6447        // Verify nested if condition.
6448        assert_stream!(
6449            fragment_records,
6450            "if-1",
6451            [
6452                raw("<ul>"),
6453                for_loop("item", "category.items", "for-2"),
6454                raw("</ul>"),
6455            ]
6456        );
6457
6458        // Verify nested for each.
6459        assert_stream!(
6460            fragment_records,
6461            "for-2",
6462            [raw("<li>"), signal("item.title"), raw("</li>"),]
6463        );
6464    }
6465
6466    // ── Attribute fragment tests ─────────────────────────────────────────
6467
6468    /// Helper to parse HTML and return the fragments for the entry stream.
6469    fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6470        let mut parser = HtmlParser::new();
6471        let result = parser.parse("index.html", html);
6472        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6473        let records = parser.into_fragment_records();
6474        let fragments = records
6475            .get("index.html")
6476            .expect("Failed to get index.html fragment")
6477            .fragments
6478            .clone();
6479        (fragments, records)
6480    }
6481
6482    /// Helper to parse HTML with a pre-registered component.
6483    fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6484        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6485        parser
6486            .component_registry
6487            .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
6488            .expect("register");
6489        let result = parser.parse("index.html", html);
6490        assert!(result.is_ok(), "Parse error: {:?}", result.err());
6491        let records = parser.into_fragment_records();
6492        let fragments = records
6493            .get("index.html")
6494            .expect("Failed to get index.html fragment")
6495            .fragments
6496            .clone();
6497        (fragments, records)
6498    }
6499
6500    #[test]
6501    fn test_attribute_handlebars_in_href() {
6502        // Port of: 'should process handlebars from attributes as signals'
6503        let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
6504        assert_fragments!(
6505            fragments,
6506            [
6507                raw("<a"),
6508                attr("href", "url"),
6509                raw(">"),
6510                signal("name"),
6511                raw("</a>"),
6512            ]
6513        );
6514    }
6515
6516    #[test]
6517    fn test_attribute_boolean_with_handlebars() {
6518        // Port of: 'should process boolean attribute with handlebars expression'
6519        let (fragments, _) =
6520            parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
6521        assert_fragments!(
6522            fragments,
6523            [
6524                raw("<button"),
6525                bool_attr("disabled", "isDisabled"),
6526                raw(">Click</button>"),
6527            ]
6528        );
6529    }
6530
6531    #[test]
6532    fn test_attribute_multiple_boolean() {
6533        // Port of: 'should process multiple boolean attributes'
6534        // <input ?checked={{isChecked}} ?disabled={{isDisabled}} />
6535        let (fragments, _) =
6536            parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
6537
6538        assert_fragments!(
6539            fragments,
6540            [
6541                raw("<input"),
6542                bool_attr("checked", "isChecked"),
6543                bool_attr("disabled", "isDisabled"),
6544                raw("/>"),
6545            ]
6546        );
6547    }
6548
6549    #[test]
6550    fn test_attribute_boolean_and_regular_together() {
6551        // Port of: 'should process a boolean attribute and a regular attribute together'
6552        // <button ?disabled="{{isDisabled}}" type="button">Hi</button>
6553        let (fragments, _) = parse_and_get_fragments(
6554            r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6555        );
6556
6557        assert_fragments!(
6558            fragments,
6559            [
6560                raw("<button"),
6561                bool_attr("disabled", "isDisabled"),
6562                raw(" type=\"button\">Hi</button>"),
6563            ]
6564        );
6565    }
6566
6567    #[test]
6568    fn test_attribute_boolean_sandwiched() {
6569        // Port of: 'should process a boolean attribute sandwiched between regular attributes'
6570        // <button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>
6571        let (fragments, _) = parse_and_get_fragments(
6572            r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6573        );
6574
6575        assert_fragments!(
6576            fragments,
6577            [
6578                raw("<button"),
6579                attr("version", "edition"),
6580                bool_attr("disabled", "isDisabled"),
6581                raw(" type=\"button\">Hi</button>"),
6582            ]
6583        );
6584    }
6585
6586    #[test]
6587    fn test_attribute_boolean_ending() {
6588        // Port of: 'should process html ending with boolean attribute correctly'
6589        // <button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>
6590        let (fragments, _) = parse_and_get_fragments(
6591            r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
6592        );
6593
6594        assert_fragments!(
6595            fragments,
6596            [
6597                raw("<button"),
6598                attr("version", "edition"),
6599                bool_attr("disabled", "isDisabled"),
6600                raw(">Hi</button>"),
6601            ]
6602        );
6603    }
6604
6605    #[test]
6606    fn test_attribute_boolean_dotted_path() {
6607        // Port of: 'should process boolean attribute with dotted path'
6608        // <div ?checked={{layout.isPinned}}>Content</div>
6609        let (fragments, _) =
6610            parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
6611
6612        assert_fragments!(
6613            fragments,
6614            [
6615                raw("<div"),
6616                bool_attr("checked", "layout.isPinned"),
6617                raw(">Content</div>"),
6618            ]
6619        );
6620    }
6621
6622    #[test]
6623    fn test_attribute_colon_prefixed_complex() {
6624        // Port of: 'should process colon-prefixed attribute with handlebars'
6625        // <my-component :config="{{settings}}"></my-component>
6626        let (fragments, _) = parse_with_component(
6627            "my-component",
6628            r#"<my-component :config="{{settings}}"></my-component>"#,
6629        );
6630
6631        assert_fragments!(
6632            fragments,
6633            [
6634                raw("<my-component"),
6635                attr_complex_start(":config", "settings"),
6636                structural_matcher("streaming_root:my-component"),
6637                raw(">"),
6638                component("my-component"),
6639                raw("</my-component>"),
6640            ]
6641        );
6642    }
6643
6644    #[test]
6645    fn test_attribute_multiple_colon_prefixed() {
6646        // Port of: 'should process multiple colon-prefixed complex attributes'
6647        // <my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>
6648        let (fragments, _) = parse_with_component(
6649            "my-component",
6650            r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
6651        );
6652
6653        assert_fragments!(
6654            fragments,
6655            [
6656                raw("<my-component"),
6657                attr_complex_start(":prop1", "val1"),
6658                attr_complex(":prop2", "val2"),
6659                structural_matcher("streaming_root:my-component"),
6660                raw(">"),
6661                component("my-component"),
6662                raw("</my-component>"),
6663            ]
6664        );
6665    }
6666
6667    #[test]
6668    fn test_blocked_complex_property_innerhtml() {
6669        let mut parser = HtmlParser::new();
6670        let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
6671        assert!(
6672            result.is_err(),
6673            "Expected error for :innerHTML on native element"
6674        );
6675        let err = result.unwrap_err().to_string();
6676        assert!(
6677            err.contains("only allowed on custom elements"),
6678            "Error: {err}"
6679        );
6680    }
6681
6682    #[test]
6683    fn test_blocked_complex_on_native_element() {
6684        let mut parser = HtmlParser::new();
6685        let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
6686        assert!(
6687            result.is_err(),
6688            "Expected error for :data on native element"
6689        );
6690        let err = result.unwrap_err().to_string();
6691        assert!(
6692            err.contains("only allowed on custom elements"),
6693            "Error: {err}"
6694        );
6695    }
6696
6697    #[test]
6698    fn test_blocked_complex_property_on_component() {
6699        let mut parser = HtmlParser::new();
6700        parser
6701            .component_registry
6702            .register_component(ComponentRegistration::new(
6703                "my-widget",
6704                "<div></div>",
6705                None,
6706                true,
6707            ))
6708            .expect("register");
6709        let result = parser.parse(
6710            "index.html",
6711            r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
6712        );
6713        assert!(
6714            result.is_err(),
6715            "Expected error for :innerHTML on component"
6716        );
6717        let err = result.unwrap_err().to_string();
6718        assert!(err.contains("HTML injection"), "Error: {err}");
6719    }
6720
6721    #[test]
6722    fn test_allowed_complex_property() {
6723        // :config on a component should still work
6724        let (fragments, _) = parse_with_component(
6725            "my-component",
6726            r#"<my-component :config="{{settings}}"></my-component>"#,
6727        );
6728        assert_fragments!(
6729            fragments,
6730            [
6731                raw("<my-component"),
6732                attr_complex_start(":config", "settings"),
6733                structural_matcher("streaming_root:my-component"),
6734                raw(">"),
6735                component("my-component"),
6736                raw("</my-component>"),
6737            ]
6738        );
6739    }
6740
6741    #[test]
6742    fn test_attribute_mixed_normal_boolean_colon() {
6743        // Port of: 'should process mixed normal, boolean, and colon-prefixed attributes'
6744        // <my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>
6745        let (fragments, _) = parse_with_component(
6746            "my-component",
6747            r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
6748        );
6749
6750        assert_fragments!(
6751            fragments,
6752            [
6753                raw("<my-component"),
6754                attr_raw_start("id", "comp"),
6755                attr_complex(":config", "settings"),
6756                bool_attr("enabled", "isEnabled"),
6757                structural_matcher("streaming_root:my-component"),
6758                raw(">"),
6759                component("my-component"),
6760                raw("</my-component>"),
6761            ]
6762        );
6763    }
6764
6765    #[test]
6766    fn test_attribute_reject_boolean_without_handlebars() {
6767        // Port of: 'should reject boolean attribute without handlebars'
6768        // <button ?checked="name"></button>
6769        let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
6770
6771        // Boolean attribute is silently dropped
6772        assert_fragments!(fragments, [raw("<button></button>"),]);
6773    }
6774
6775    #[test]
6776    fn test_attribute_reject_boolean_with_partial_handlebars() {
6777        // Port of: 'should reject boolean attribute with partial handlebars'
6778        // <button ?checked="Hello {{name}}"></button>
6779        let (fragments, _) =
6780            parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
6781
6782        // Boolean attribute is silently dropped
6783        assert_fragments!(fragments, [raw("<button></button>"),]);
6784    }
6785
6786    #[test]
6787    fn test_attribute_reject_boolean_with_plain_value() {
6788        // Port of: 'should reject boolean attribute with plain value'
6789        // <button ?disabled="true">Click</button>
6790        let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
6791
6792        // Boolean attribute is silently dropped
6793        assert_fragments!(fragments, [raw("<button>Click</button>"),]);
6794    }
6795
6796    #[test]
6797    fn test_attribute_boolean_predicate_equal() {
6798        // Boolean attribute with == predicate expression
6799        let (fragments, _) =
6800            parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
6801        assert_fragments!(
6802            fragments,
6803            [
6804                raw("<div"),
6805                bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
6806                raw(">X</div>"),
6807            ]
6808        );
6809    }
6810
6811    #[test]
6812    fn test_attribute_boolean_predicate_greater_than() {
6813        // Boolean attribute with > predicate expression
6814        let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
6815        assert_fragments!(
6816            fragments,
6817            [
6818                raw("<span"),
6819                bool_attr_predicate("hidden", "num", 1, "9"),
6820                raw(">X</span>"),
6821            ]
6822        );
6823    }
6824
6825    #[test]
6826    fn test_attribute_boolean_predicate_not_equal() {
6827        // Boolean attribute with != predicate expression
6828        let (fragments, _) =
6829            parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
6830        assert_fragments!(
6831            fragments,
6832            [
6833                raw("<a"),
6834                bool_attr_predicate("data-active", "status", 4, "'inactive'"),
6835                raw(">X</a>"),
6836            ]
6837        );
6838    }
6839
6840    #[test]
6841    fn test_attribute_boolean_negation() {
6842        // Boolean attribute with negated expression
6843        let (fragments, _) =
6844            parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
6845        assert_fragments!(
6846            fragments,
6847            [
6848                raw("<button"),
6849                bool_attr_not("disabled", "isReady"),
6850                raw(">X</button>"),
6851            ]
6852        );
6853    }
6854
6855    #[test]
6856    fn test_attribute_mixed_static_dynamic() {
6857        // Port of: 'should process mixed attributes correctly'
6858        // <textarea value="hello {{world}}">Hi</textarea>
6859        let (fragments, records) =
6860            parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
6861
6862        assert_fragments!(
6863            fragments,
6864            [
6865                raw("<textarea"),
6866                attr_template("value", "attr-1"),
6867                raw(">Hi</textarea>"),
6868            ]
6869        );
6870
6871        // Verify the template sub-stream
6872        assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
6873    }
6874
6875    // ── Body signal tests ─────────────────────────────────────────────
6876
6877    #[test]
6878    fn test_body_signals() {
6879        let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
6880        assert_fragments!(
6881            fragments,
6882            [
6883                raw("<body>"),
6884                structural_matcher("body_start"),
6885                raw("<app-shell></app-shell>"),
6886                structural_matcher("body_end"),
6887                raw("</body>"),
6888            ]
6889        );
6890    }
6891
6892    // ── Empty for handling tests ──────────────────────────────────────
6893
6894    #[test]
6895    fn test_empty_for_produces_nothing() {
6896        let (fragments, records) =
6897            parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
6898        assert_fragments!(fragments, [raw("<div></div>"),]);
6899        assert!(!records.contains_key("for-1"));
6900    }
6901
6902    // ── Self-closing / void element tests ─────────────────────────────
6903
6904    #[test]
6905    fn test_self_closing_svg_path() {
6906        let (fragments, _) =
6907            parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
6908        assert_fragments!(
6909            fragments,
6910            [raw(
6911                r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
6912            ),]
6913        );
6914    }
6915
6916    #[test]
6917    fn test_html5_void_elements() {
6918        let (fragments, _) = parse_and_get_fragments(
6919            r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
6920        );
6921        assert_fragments!(
6922            fragments,
6923            [raw(
6924                r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
6925            ),]
6926        );
6927    }
6928
6929    #[test]
6930    fn test_self_closing_with_dynamic_attributes() {
6931        let (fragments, _) =
6932            parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
6933        assert_fragments!(
6934            fragments,
6935            [
6936                raw("<img"),
6937                attr("src", "imageUrl"),
6938                attr("alt", "imageAlt"),
6939                raw("/>"),
6940            ]
6941        );
6942    }
6943
6944    #[test]
6945    fn test_self_closing_with_boolean_attributes() {
6946        let (fragments, _) = parse_and_get_fragments(
6947            r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
6948        );
6949        assert_fragments!(
6950            fragments,
6951            [
6952                raw("<input type=\"checkbox\""),
6953                bool_attr("checked", "isSelected"),
6954                bool_attr("disabled", "isDisabled"),
6955                raw("/>"),
6956            ]
6957        );
6958    }
6959
6960    #[test]
6961    fn test_multiple_self_closing_in_sequence() {
6962        let (fragments, _) =
6963            parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
6964        assert_fragments!(
6965            fragments,
6966            [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
6967        );
6968    }
6969
6970    #[test]
6971    fn test_self_closing_with_mixed_content() {
6972        let (fragments, _) =
6973            parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
6974        assert_fragments!(
6975            fragments,
6976            [
6977                raw("<div>Text before<img"),
6978                attr("src", "url"),
6979                raw("/>Text after</div>"),
6980            ]
6981        );
6982    }
6983
6984    #[test]
6985    fn test_self_closing_svg_elements() {
6986        let (fragments, _) = parse_and_get_fragments(
6987            r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
6988        );
6989        assert_fragments!(
6990            fragments,
6991            [
6992                raw("<svg><circle"),
6993                attr("cx", "x"),
6994                attr("cy", "y"),
6995                raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
6996            ]
6997        );
6998    }
6999
7000    #[test]
7001    fn test_self_closing_inside_for_loop() {
7002        let (fragments, records) = parse_and_get_fragments(
7003            r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
7004        );
7005        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7006        assert_stream!(
7007            records,
7008            "for-1",
7009            [raw("<img"), attr("src", "item.url"), raw("/>"),]
7010        );
7011    }
7012
7013    #[test]
7014    fn test_self_closing_whitespace_variations() {
7015        let (fragments, _) =
7016            parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
7017        assert_fragments!(
7018            fragments,
7019            [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
7020        );
7021    }
7022
7023    #[test]
7024    fn test_deeply_nested_self_closing() {
7025        let (fragments, _) = parse_and_get_fragments(
7026            r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
7027        );
7028        assert_fragments!(
7029            fragments,
7030            [raw(
7031                r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
7032            ),]
7033        );
7034    }
7035
7036    #[test]
7037    fn test_self_closing_vs_empty_regular_tags() {
7038        let (fragments, _) =
7039            parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
7040        assert_fragments!(
7041            fragments,
7042            [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
7043        );
7044    }
7045
7046    // ── Feature 1: Custom template attribute on <for> ────────────────────
7047
7048    #[test]
7049    fn test_for_custom_template_attribute() {
7050        // Port of: 'should process transient node for with template'
7051        let (fragments, records) = parse_and_get_fragments(
7052            r#"<for each="item in items" template="static"><span>Item</span></for>"#,
7053        );
7054        assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
7055        assert_stream!(records, "static", [raw("<span>Item</span>"),]);
7056    }
7057
7058    #[test]
7059    fn test_for_recursive_template() {
7060        // Port of: 'should process recursive transient nodes'
7061        let mut parser = HtmlParser::new();
7062        let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
7063        let result = parser.parse("index.html", html);
7064        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7065        let records = parser.into_fragment_records();
7066
7067        assert_fragments!(
7068            records["index.html"].fragments,
7069            [for_loop("outerItem", "outerItems", "static"),]
7070        );
7071
7072        assert_stream!(
7073            records,
7074            "static",
7075            [
7076                raw("<div><span>"),
7077                signal("outerItem.name"),
7078                raw("</span>"),
7079                for_loop("innerItem", "innerItems", "static"),
7080                raw("</div>"),
7081            ]
7082        );
7083    }
7084
7085    // ── Feature 2: <if> / <for> with multiple children ──────────────────
7086
7087    #[test]
7088    fn test_if_multiple_children() {
7089        // Port of: 'should handle <if> with multiple children'
7090        let (fragments, records) =
7091            parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
7092        assert_fragments!(fragments, [if_cond("if-1"),]);
7093        assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
7094    }
7095
7096    #[test]
7097    fn if_condition_with_double_quoted_literal_round_trips_through_parser() {
7098        // The FAST converter emits a single-quoted `<if condition='…'>` when the
7099        // expression contains a double-quoted literal. The WebUI parser must
7100        // extract the full expression (no truncation at the embedded quote), so
7101        // SSR and client see the same predicate.
7102        let (fragments, _) =
7103            parse_and_get_fragments(r#"<if condition='status == "ready"'><span>ok</span></if>"#);
7104        let condition = fragments
7105            .iter()
7106            .find_map(|fragment| match fragment.fragment.as_ref() {
7107                Some(webui_protocol::web_ui_fragment::Fragment::IfCond(if_cond)) => {
7108                    if_cond.condition.as_ref()
7109                }
7110                _ => None,
7111            })
7112            .expect("if-condition fragment with a parsed condition");
7113        match condition.expr.as_ref() {
7114            Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
7115                assert_eq!(pred.left, "status");
7116                assert_eq!(pred.right, "\"ready\"");
7117            }
7118            other => panic!("expected a predicate condition, got {other:?}"),
7119        }
7120    }
7121
7122    #[test]
7123    fn test_for_multiple_children() {
7124        // Port of: 'should handle <for> with multiple children'
7125        let (fragments, records) =
7126            parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
7127        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7128        assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
7129    }
7130
7131    // ── Feature 3: Handlebars at beginning/end of text ──────────────────
7132
7133    #[test]
7134    fn test_handlebars_at_beginning() {
7135        // Port of: 'should process handlebars from text at beginning'
7136        let (fragments, _) = parse_and_get_fragments("{{first}}");
7137        assert_fragments!(fragments, [signal("first"),]);
7138    }
7139
7140    #[test]
7141    fn test_handlebars_at_beginning_and_raw() {
7142        // Port of: 'should process handlebars from text at beginning and raw'
7143        let (fragments, _) = parse_and_get_fragments("{{first}}test");
7144        assert_fragments!(fragments, [signal("first"), raw("test"),]);
7145    }
7146
7147    #[test]
7148    fn test_handlebars_raw_and_end() {
7149        // Port of: 'should process handlebars from text at raw and end'
7150        let (fragments, _) = parse_and_get_fragments("test{{first}}");
7151        assert_fragments!(fragments, [raw("test"), signal("first"),]);
7152    }
7153
7154    // ── Feature 4: Handlebars edge cases ────────────────────────────────
7155
7156    #[test]
7157    fn test_handlebars_invalid_triple_open() {
7158        // Port of: 'should not process handlebars when invalid'
7159        let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
7160        assert_fragments!(fragments, [raw("{{{invalid}}"),]);
7161    }
7162
7163    #[test]
7164    fn test_handlebars_four_open_braces() {
7165        // Port of: 'should not process handlebars when invalid since triple exists'
7166        let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
7167        assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
7168    }
7169
7170    #[test]
7171    fn test_handlebars_five_open_with_valid_double() {
7172        // Port of: 'should not process handlebars when invalid but with valid triple'
7173        let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
7174        assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
7175    }
7176
7177    #[test]
7178    fn test_entities_preserved() {
7179        // Port of: 'should process entities correctly'
7180        let (fragments, _) = parse_and_get_fragments("<p>Hello&#125;World</p>");
7181        assert_fragments!(fragments, [raw("<p>Hello&#125;World</p>"),]);
7182    }
7183
7184    // ── Feature 5: DOCTYPE handling ─────────────────────────────────────
7185
7186    #[test]
7187    fn test_doctype_preserved() {
7188        // DOCTYPE should be preserved as raw content
7189        let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
7190        assert!(!fragments.is_empty());
7191        assert!(
7192            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
7193        );
7194    }
7195
7196    // ── Feature 6: Component attribute skip / multiple nested ───────────
7197
7198    #[test]
7199    fn test_component_attr_skip() {
7200        // Port of: 'should set attrSkip for skipped component attributes'
7201        let mut parser = HtmlParser::new();
7202        parser
7203            .component_registry
7204            .register_component(ComponentRegistration::new(
7205                "custom-element",
7206                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7207                None,
7208                true,
7209            ))
7210            .expect("register");
7211        let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
7212        let result = parser.parse("index.html", html);
7213        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7214        let records = parser.into_fragment_records();
7215
7216        // <custom-element, :config(attrStart), class(attrSkip), style(attrSkip),
7217        // role(attrSkip), data-test(attrSkip), aria-test(attrSkip), >, component, </custom-element>
7218        assert_fragments!(
7219            records["index.html"].fragments,
7220            [
7221                raw("<custom-element"),
7222                // :config with attrStart
7223                attr_complex_start(":config", "config"),
7224                // Skipped attrs
7225                attr_skip("class", "value0"),
7226                attr_skip("style", "value1"),
7227                attr_skip("role", "value2"),
7228                attr_skip("data-test", "value3"),
7229                attr_skip("aria-test", "value4"),
7230                structural_matcher("streaming_root:custom-element"),
7231                raw(">"),
7232                component("custom-element"),
7233                raw("</custom-element>"),
7234            ]
7235        );
7236    }
7237
7238    #[test]
7239    fn test_component_attr_skip_static_and_embedded() {
7240        // Skipped attrs with static values and embedded bindings should
7241        // emit fragments (not be silently dropped).
7242        let mut parser = HtmlParser::new();
7243        parser
7244            .component_registry
7245            .register_component(ComponentRegistration::new(
7246                "item-group",
7247                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7248                None,
7249                true,
7250            ))
7251            .expect("register");
7252
7253        let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
7254        let result = parser.parse("index.html", html);
7255        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7256        let records = parser.into_fragment_records();
7257
7258        assert_fragments!(
7259            records["index.html"].fragments,
7260            [
7261                raw("<item-group"),
7262                attr_skip_raw("role", "list"),
7263                attr_skip_template("aria-labelledby", "attr-1"),
7264                attr_skip_template("data-testid", "attr-2"),
7265                attr_skip_raw("class", "fixed-class"),
7266                structural_matcher("streaming_root:item-group"),
7267                raw(">"),
7268                component("item-group"),
7269                raw("</item-group>"),
7270            ]
7271        );
7272
7273        // Verify the embedded-binding template sub-streams exist and
7274        // contain the expected static + signal fragments.
7275        assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
7276        assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
7277    }
7278
7279    #[test]
7280    fn test_component_multiple_nested() {
7281        // Port of: 'handle multiple nested web components'
7282        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7283        parser
7284            .component_registry
7285            .register_component(ComponentRegistration::new(
7286                "custom-element",
7287                "<custom-child></custom-child><span></span>",
7288                None,
7289                true,
7290            ))
7291            .expect("register");
7292        parser
7293            .component_registry
7294            .register_component(ComponentRegistration::new(
7295                "custom-button",
7296                "<span></span>",
7297                None,
7298                true,
7299            ))
7300            .expect("register");
7301        parser
7302            .component_registry
7303            .register_component(ComponentRegistration::new(
7304                "custom-child",
7305                "<h1>Hello World!</h1>",
7306                None,
7307                true,
7308            ))
7309            .expect("register");
7310
7311        let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
7312        let result = parser.parse("index.html", html);
7313        assert!(result.is_ok(), "Parse error: {:?}", result.err());
7314        let records = parser.into_fragment_records();
7315
7316        // Entry stream
7317        assert_fragments!(
7318            records["index.html"].fragments,
7319            [for_loop("item", "items", "for-1"),]
7320        );
7321
7322        // For stream
7323        assert_stream!(
7324            records,
7325            "for-1",
7326            [
7327                raw("<custom-element"),
7328                structural_matcher("streaming_root:custom-element"),
7329                raw(">"),
7330                component("custom-element"),
7331                raw("<custom-button"),
7332                structural_matcher("streaming_root:custom-button"),
7333                raw(">"),
7334                component("custom-button"),
7335                raw("Ok</custom-button></custom-element>"),
7336            ]
7337        );
7338
7339        // Component streams — custom-element has contains() checks, keep manual
7340        let ce = &records["custom-element"].fragments;
7341        assert_eq!(ce.len(), 5);
7342        assert!(
7343            matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
7344        );
7345        assert!(
7346            matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
7347        );
7348        assert!(
7349            matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><span></span>"))
7350        );
7351
7352        assert_stream!(records, "custom-button", [raw("<span></span>"),]);
7353
7354        assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
7355    }
7356
7357    // ── Error handling tests ──────────────────────────────────────────
7358
7359    #[test]
7360    fn test_invalid_markup_returns_error() {
7361        // Port of: 'should fail with invalid markup'
7362        let mut parser = HtmlParser::new();
7363        let result = parser.parse("index.html", "<div><span>Unclosed div");
7364        assert!(matches!(result, Err(ParserError::Template(diag)) if
7365            diag.to_string().contains("unclosed <div> tag")
7366        ));
7367    }
7368
7369    #[test]
7370    fn test_unterminated_opening_tag_returns_error() {
7371        let mut parser = HtmlParser::new();
7372        let result = parser.parse("index.html", "before <div");
7373        assert!(matches!(result, Err(ParserError::Template(diag)) if
7374            diag.to_string().contains("malformed HTML tag")
7375        ));
7376    }
7377
7378    #[test]
7379    fn test_unterminated_html_comment_returns_error() {
7380        let mut parser = HtmlParser::new();
7381        let result = parser.parse("index.html", "<div><!-- missing close</div>");
7382        assert!(matches!(result, Err(ParserError::Template(diag)) if
7383            diag.to_string().contains("unclosed <div> tag")
7384                || diag.to_string().contains("unterminated HTML comment")
7385        ));
7386    }
7387
7388    #[test]
7389    fn test_unexpected_closing_tag_returns_error() {
7390        let mut parser = HtmlParser::new();
7391        let result = parser.parse("index.html", "</span>");
7392        assert!(matches!(result, Err(ParserError::Template(diag)) if
7393            diag.to_string().contains("unexpected closing tag </span>")
7394        ));
7395    }
7396
7397    #[test]
7398    fn test_rejects_templates_over_size_limit() {
7399        let mut parser = HtmlParser::new();
7400        let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
7401        let result = parser.parse("index.html", &html);
7402
7403        assert!(matches!(result, Err(ParserError::Html(message)) if
7404            message.contains("exceeds") && message.contains("parser limit")
7405        ));
7406    }
7407
7408    #[test]
7409    fn test_rejects_templates_over_depth_limit() {
7410        let mut parser = HtmlParser::new();
7411        let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
7412        for _ in 0..=MAX_TEMPLATE_DEPTH {
7413            html.push_str("<div>");
7414        }
7415        for _ in 0..=MAX_TEMPLATE_DEPTH {
7416            html.push_str("</div>");
7417        }
7418
7419        let result = parser.parse("index.html", &html);
7420
7421        assert!(matches!(result, Err(ParserError::Template(diag)) if
7422            diag.to_string().contains("nesting exceeds")
7423        ));
7424    }
7425
7426    #[test]
7427    fn test_rejects_routes_over_depth_limit() {
7428        let mut parser = HtmlParser::new();
7429        parser
7430            .component_registry
7431            .register_component(ComponentRegistration::new(
7432                "route-page",
7433                r#"<template shadowrootmode="open"><slot></slot></template>"#,
7434                None,
7435                true,
7436            ))
7437            .expect("register");
7438
7439        let depth = MAX_TEMPLATE_DEPTH + 2;
7440        let mut html = String::with_capacity(depth * 48);
7441        for _ in 0..depth {
7442            html.push_str(r#"<route path="x" component="route-page">"#);
7443        }
7444        for _ in 0..depth {
7445            html.push_str("</route>");
7446        }
7447
7448        let result = parser.parse("index.html", &html);
7449
7450        assert!(matches!(result, Err(ParserError::Template(diag)) if
7451            diag.error_code() == Some(codes::EXCESSIVE_NESTING)
7452                && diag.to_string().contains("route nesting exceeds")
7453        ));
7454    }
7455
7456    #[test]
7457    fn test_rejects_recursive_component_templates() {
7458        let mut parser = HtmlParser::new();
7459        parser
7460            .component_registry
7461            .register_component(ComponentRegistration::new(
7462                "self-card",
7463                "<self-card></self-card>",
7464                None,
7465                true,
7466            ))
7467            .expect("register");
7468
7469        let result = parser.parse("index.html", "<self-card></self-card>");
7470
7471        assert!(matches!(result, Err(ParserError::Template(diag)) if
7472            diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
7473                && diag.to_string().contains("recursive template reference")
7474        ));
7475    }
7476
7477    // ── Integration tests ─────────────────────────────────────────────
7478
7479    #[test]
7480    fn test_complex_raw_text_full_page() {
7481        // Port of: 'should process a complex raw text'
7482        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>"#;
7483        let (fragments, _) = parse_and_get_fragments(html);
7484
7485        // DOCTYPE + <head>, head_start, head content, head_end, </head><body>,
7486        // body_start, body content, body_end, </body></html>
7487        assert!(fragments.len() >= 9);
7488        assert!(
7489            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7490                raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
7491        );
7492        assert!(
7493            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
7494                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
7495        );
7496        assert!(
7497            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7498                raw.value.contains("<title>Test</title>"))
7499        );
7500        assert!(
7501            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
7502                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
7503        );
7504        assert!(
7505            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7506                raw.value.contains("</head>") && raw.value.ends_with("<body>"))
7507        );
7508        assert!(
7509            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
7510                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
7511        );
7512        assert!(
7513            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7514                raw.value.contains("<app-shell>"))
7515        );
7516        assert!(
7517            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
7518                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
7519        );
7520        assert!(
7521            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7522                raw.value.contains("</body>") && raw.value.contains("</html>"))
7523        );
7524    }
7525
7526    #[test]
7527    fn raw_bindings_in_html_text_contexts_do_not_own_html_ranges() {
7528        for element in ["title", "textarea", "script", "xmp"] {
7529            let html = format!("<{element}>{{{{{{value}}}}}}</{element}>");
7530            let (fragments, _) = parse_and_get_fragments(&html);
7531            let signal = fragments
7532                .iter()
7533                .find_map(|fragment| match fragment.fragment.as_ref() {
7534                    Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
7535                    _ => None,
7536                });
7537            let signal = signal
7538                .unwrap_or_else(|| panic!("expected raw signal inside <{element}>: {fragments:?}"));
7539            assert!(
7540                signal.raw,
7541                "<{element}> should preserve raw brace semantics"
7542            );
7543            assert!(
7544                signal.raw_text_context,
7545                "<{element}> should suppress HTML ownership markers"
7546            );
7547        }
7548    }
7549
7550    #[test]
7551    fn raw_bindings_in_template_content_own_html_ranges() {
7552        let (fragments, _) = parse_and_get_fragments("<template>{{{value}}}</template>");
7553        let signal = fragments
7554            .iter()
7555            .find_map(|fragment| match fragment.fragment.as_ref() {
7556                Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
7557                _ => None,
7558            });
7559        let signal =
7560            signal.unwrap_or_else(|| panic!("expected raw signal inside template: {fragments:?}"));
7561        assert!(signal.raw);
7562        assert!(!signal.raw_text_context);
7563    }
7564
7565    #[test]
7566    fn test_css_strategy_external_defers_link_to_server_closure() {
7567        let mut parser = HtmlParser::new();
7568        parser
7569            .component_registry_mut()
7570            .register_component(ComponentRegistration::new(
7571                "my-card",
7572                r#"<template shadowrootmode="open"><p>content</p></template>"#,
7573                Some("p { color: red; }"),
7574                true,
7575            ))
7576            .ok();
7577        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
7578        let records = parser.into_fragment_records();
7579        let my_card = &records["my-card"].fragments;
7580        let raw_text: String = my_card
7581            .iter()
7582            .filter_map(|f| match &f.fragment {
7583                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
7584                _ => None,
7585            })
7586            .collect();
7587        assert!(
7588            !raw_text.contains(r#"<link rel="stylesheet""#),
7589            "component templates must not own tree-local links: {}",
7590            raw_text
7591        );
7592        assert!(my_card.iter().any(|fragment| matches!(
7593            fragment.fragment.as_ref(),
7594            Some(Fragment::Signal(signal))
7595                if signal.value == "}}}webui:shadow_styles:my-card"
7596        )));
7597    }
7598
7599    #[test]
7600    fn test_uppercase_style_element_is_processed_as_css() {
7601        // HTML tag names are ASCII case-insensitive: an uppercase `<STYLE>`
7602        // block must be CSS-processed exactly like `<style>`, so a CSS signal
7603        // comment becomes a Signal fragment (not inert raw text) and the
7604        // reconstructed closing tag mirrors the source case.
7605        let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
7606        assert!(
7607            fragments.iter().any(|f| matches!(
7608                f.fragment.as_ref(),
7609                Some(Fragment::Signal(s))
7610                    if s.value == "tokens" && !s.raw && s.raw_text_context
7611            )),
7612            "uppercase <STYLE> should be CSS-processed, got: {:?}",
7613            fragments
7614        );
7615        let raw: String = fragments
7616            .iter()
7617            .filter_map(|f| match f.fragment.as_ref() {
7618                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
7619                _ => None,
7620            })
7621            .collect();
7622        assert!(
7623            raw.contains("</STYLE>"),
7624            "closing tag should preserve source case, got: {raw}"
7625        );
7626    }
7627
7628    #[test]
7629    fn test_css_strategy_inline_defers_style_to_server_closure() {
7630        let mut parser = HtmlParser::with_options(CssStrategy::Style);
7631        parser
7632            .component_registry_mut()
7633            .register_component(ComponentRegistration::new(
7634                "my-card",
7635                "<p>content</p>",
7636                Some("p { color: red; }"),
7637                true,
7638            ))
7639            .expect("register component");
7640        parser
7641            .parse("index.html", "<my-card>Hello</my-card>")
7642            .expect("parse component");
7643        let records = parser.into_fragment_records();
7644        let my_card = &records["my-card"].fragments;
7645        let raw_text: String = my_card
7646            .iter()
7647            .filter_map(|f| match &f.fragment {
7648                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
7649                _ => None,
7650            })
7651            .collect();
7652        assert!(
7653            !raw_text.contains("<style>"),
7654            "component templates must not own tree-local styles: {}",
7655            raw_text
7656        );
7657        assert!(
7658            !raw_text.contains("<link"),
7659            "Should not have <link> tag in inline mode: {}",
7660            raw_text
7661        );
7662    }
7663
7664    #[test]
7665    fn test_css_strategy_module_emits_adopted_stylesheets() {
7666        let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
7667        parser
7668            .component_registry_mut()
7669            .register_component(ComponentRegistration::new(
7670                "my-card",
7671                "<p>content</p>",
7672                Some("p { color: red; }"),
7673                true,
7674            ))
7675            .expect("register component");
7676        parser
7677            .parse("index.html", "<my-card>Hello</my-card>")
7678            .expect("parse component");
7679        let records = parser.into_fragment_records();
7680
7681        // Component template should have shadowrootadoptedstylesheets, no CSS
7682        // module in raw fragments (CSS lives on the component fragment's css field)
7683        let my_card = &records["my-card"].fragments;
7684        let template_text: String = my_card
7685            .iter()
7686            .filter_map(|f| match &f.fragment {
7687                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
7688                _ => None,
7689            })
7690            .collect();
7691        assert!(
7692            !template_text.contains("shadowrootadoptedstylesheets"),
7693            "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
7694        );
7695        assert!(
7696            !template_text.contains("<link"),
7697            "Should not have <link> in module mode: {template_text}"
7698        );
7699        // No CSS module baked into raw fragments — CSS is stored in
7700        // protocol.components, populated by the build system, and emitted
7701        // by the handler (SSR inline + SPA partials) as importmap scripts.
7702        assert!(
7703            !template_text.contains(r#"<style type="module""#),
7704            "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
7705        );
7706        assert!(
7707            !template_text.contains(r#"<script type="importmap""#),
7708            "CSS module importmap should NOT be in raw fragments: {template_text}"
7709        );
7710    }
7711
7712    #[test]
7713    fn light_component_css_remains_authored_and_unscoped() {
7714        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7715        parser
7716            .component_registry_mut()
7717            .register_component(ComponentRegistration::new(
7718                "my-card",
7719                "<p class=\"label\">content</p>",
7720                Some(".label{color:red}"),
7721                false,
7722            ))
7723            .expect("register component");
7724        parser
7725            .parse("index.html", "<my-card></my-card>")
7726            .expect("parse component");
7727
7728        let css = parser
7729            .component_registry()
7730            .get("my-card")
7731            .and_then(|component| component.css_content.as_deref())
7732            .expect("authored CSS");
7733        assert_eq!(css, ".label{color:red}");
7734    }
7735
7736    #[test]
7737    fn light_component_inline_styles_remain_unscoped_and_unmarked() {
7738        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7739        let built = parser
7740            .build_component_templates(ComponentTemplateInput {
7741                tag_name: "my-card",
7742                html: "<style>.label{color:red}</style><p class=\"label\">content</p>",
7743                artifact_html: None,
7744                authored_html: None,
7745                css_content: None,
7746                artifact_needed: false,
7747            })
7748            .expect("build Light component");
7749
7750        assert_eq!(
7751            built.ssr,
7752            "<style>.label{color:red}</style><p class=\"label\">content</p>"
7753        );
7754        assert!(!built.ssr.contains("data-wl"));
7755    }
7756
7757    #[test]
7758    fn light_component_rejects_shadow_only_selectors() {
7759        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7760        for selector in [":host", ":host-context(body.dark)", "::slotted(*)"] {
7761            let html = format!("<style>{selector}{{color:red}}</style><p>content</p>");
7762            let result = parser.build_component_templates(ComponentTemplateInput {
7763                tag_name: "my-card",
7764                html: &html,
7765                artifact_html: None,
7766                authored_html: None,
7767                css_content: None,
7768                artifact_needed: false,
7769            });
7770            let error = match result {
7771                Ok(_) => panic!("Shadow-only selector must fail in Light DOM"),
7772                Err(error) => error,
7773            };
7774            assert!(matches!(
7775                error,
7776                ParserError::Template(ref diagnostic)
7777                    if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
7778            ));
7779        }
7780    }
7781
7782    #[test]
7783    fn light_component_external_css_rejects_host_selector() {
7784        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7785        parser
7786            .component_registry_mut()
7787            .register_component(ComponentRegistration::new(
7788                "my-card",
7789                "<p>content</p>",
7790                Some(":host{display:block}"),
7791                false,
7792            ))
7793            .expect("register component");
7794        let error = parser
7795            .parse("index.html", "<my-card></my-card>")
7796            .expect_err("external :host must fail in Light DOM");
7797        assert!(matches!(
7798            error,
7799            ParserError::Template(ref diagnostic)
7800                if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
7801        ));
7802    }
7803
7804    #[test]
7805    fn shadow_component_inline_styles_remain_tree_local_and_unchanged() {
7806        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
7807        let built = parser
7808            .build_component_templates(ComponentTemplateInput {
7809                tag_name: "my-card",
7810                html: "<style>:host{display:block}.label{color:red}</style><p class=\"label\">content</p>",
7811                artifact_html: None,
7812                authored_html: None,
7813                css_content: None,
7814                artifact_needed: false,
7815            })
7816            .expect("build Shadow component");
7817
7818        assert!(built.ssr.contains(
7819            "<template shadowrootmode=\"open\"><style>:host{display:block}.label{color:red}</style>"
7820        ));
7821        assert!(!built.ssr.contains("data-wl"));
7822    }
7823
7824    #[test]
7825    fn nested_light_hosts_are_unmarked() {
7826        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7827        parser
7828            .component_registry_mut()
7829            .register_component(ComponentRegistration::new(
7830                "my-child",
7831                "<p class=\"inner\">child</p>",
7832                Some(".inner{color:green}"),
7833                false,
7834            ))
7835            .expect("register child");
7836        parser
7837            .component_registry_mut()
7838            .register_component(ComponentRegistration::new(
7839                "my-card",
7840                "<my-child></my-child>",
7841                Some("my-child{display:block}"),
7842                false,
7843            ))
7844            .expect("register parent");
7845        parser
7846            .parse("index.html", "<my-card></my-card>")
7847            .expect("parse component");
7848
7849        let records = parser.into_fragment_records();
7850        let raw: String = records["my-card"]
7851            .fragments
7852            .iter()
7853            .filter_map(|fragment| match &fragment.fragment {
7854                Some(web_ui_fragment::Fragment::Raw(raw)) => Some(raw.value.as_str()),
7855                _ => None,
7856            })
7857            .collect();
7858        assert!(raw.contains("<my-child>"), "nested host was changed: {raw}");
7859        assert!(!raw.contains("data-wl"), "Light markers remain: {raw}");
7860    }
7861
7862    #[test]
7863    fn raw_html_binding_does_not_change_global_css() {
7864        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7865        parser
7866            .component_registry_mut()
7867            .register_component(ComponentRegistration::new(
7868                "my-card",
7869                "<div class=\"body\">{{{descriptionHtml}}}</div>",
7870                Some(".body p{margin:0}"),
7871                false,
7872            ))
7873            .expect("register component");
7874        parser
7875            .parse("index.html", "<my-card></my-card>")
7876            .expect("parse component");
7877
7878        let css = parser
7879            .component_registry()
7880            .get("my-card")
7881            .and_then(|component| component.css_content.as_deref())
7882            .expect("authored CSS");
7883        assert_eq!(css, ".body p{margin:0}");
7884    }
7885
7886    #[test]
7887    fn test_css_strategy_module_no_css_no_adopted_attr() {
7888        let mut parser = HtmlParser::with_options(CssStrategy::Module);
7889        parser
7890            .component_registry_mut()
7891            .register_component(ComponentRegistration::new(
7892                "my-card",
7893                r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
7894                None,
7895                true,
7896            ))
7897            .ok();
7898        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
7899        let records = parser.into_fragment_records();
7900        let my_card = &records["my-card"].fragments;
7901        let template_text: String = my_card
7902            .iter()
7903            .filter_map(|f| match &f.fragment {
7904                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
7905                _ => None,
7906            })
7907            .collect();
7908        // No CSS → no shadowrootadoptedstylesheets attribute
7909        assert!(
7910            !template_text.contains("shadowrootadoptedstylesheets"),
7911            "Should not have adopted attr without CSS: {template_text}"
7912        );
7913    }
7914
7915    // ── Dev-authored <template> wrapper handling ────────────────────
7916    //
7917    // These tests distinguish an ordinary `<template>` element from a
7918    // declarative Shadow DOM root:
7919    //
7920    // An ordinary `<template ...>` is Light DOM content and is preserved
7921    // verbatim. Only a sole open declarative Shadow root selects Shadow DOM.
7922    //
7923    // Calls `process_component_template` directly so the assertions observe
7924    // the exact HTML string the framework emits for the component template
7925    // (this is what gets handed to the inner parse + plugin hooks).
7926
7927    #[test]
7928    fn light_preserves_dev_template_with_static_attrs() {
7929        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7930        let processed = parser
7931            .process_component_template(
7932                r#"<template foo="bar"><div>hi</div></template>"#,
7933                None,
7934                None,
7935            )
7936            .expect("process failed");
7937        assert!(
7938            processed.contains(r#"<template foo="bar">"#),
7939            "expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
7940        );
7941        assert!(
7942            processed.contains("</template>"),
7943            "expected closing </template> preserved, got: {processed}"
7944        );
7945    }
7946
7947    #[test]
7948    fn light_preserves_dev_template_with_signal_fragment_attrs() {
7949        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7950        let processed = parser
7951            .process_component_template(
7952                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
7953                None,
7954                None,
7955            )
7956            .expect("process failed");
7957        assert!(
7958            processed.contains(r#"<template foo="{{foo}}">"#),
7959            "expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
7960        );
7961    }
7962
7963    #[test]
7964    fn light_preserves_dev_template_with_multiple_attrs() {
7965        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7966        let processed = parser.process_component_template(
7967            r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
7968            None,
7969            None,
7970        )
7971        .expect("process failed");
7972        assert!(
7973            processed.contains("autofocus")
7974                && processed.contains(r#"tabindex="0""#)
7975                && processed.contains(r#"role="region""#)
7976                && processed.contains(r#"data-x="y""#),
7977            "expected all dev template attrs preserved, got: {processed}"
7978        );
7979    }
7980
7981    #[test]
7982    fn light_no_template_no_wrapper_added() {
7983        let mut parser = HtmlParser::with_options(DomStrategy::Light);
7984        let processed = parser
7985            .process_component_template("<div>hi</div>", None, None)
7986            .expect("process failed");
7987        assert!(
7988            !processed.contains("<template"),
7989            "framework must not add a <template> wrapper when dev omits one, got: {processed}"
7990        );
7991        assert!(
7992            processed.contains("<div>hi</div>"),
7993            "expected inner content emitted as-is, got: {processed}"
7994        );
7995    }
7996
7997    #[test]
7998    fn ordinary_template_remains_light_dom_content() {
7999        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8000        let processed = parser
8001            .process_component_template(
8002                r#"<template foo="bar"><div>hi</div></template>"#,
8003                None,
8004                None,
8005            )
8006            .expect("process failed");
8007        assert!(
8008            processed.contains(r#"<template foo="bar">"#),
8009            "ordinary template content must be preserved, got: {processed}"
8010        );
8011        assert!(
8012            !processed.contains("shadowrootmode"),
8013            "ordinary template must not gain a generated Shadow root, got: {processed}"
8014        );
8015    }
8016
8017    #[test]
8018    fn bare_template_is_unwrapped_as_explicit_light_dom() {
8019        let mut parser = HtmlParser::new();
8020        let built = parser
8021            .build_component_templates(ComponentTemplateInput {
8022                tag_name: "my-card",
8023                html: "<template><div class=\"label\">hi</div></template>",
8024                artifact_html: None,
8025                authored_html: None,
8026                css_content: Some(".label{color:red}"),
8027                artifact_needed: false,
8028            })
8029            .expect("bare template should select Light DOM");
8030        assert!(!built.uses_shadow_dom);
8031        assert_eq!(built.ssr, r#"<div class="label">hi</div>"#);
8032    }
8033
8034    #[test]
8035    fn attributed_template_keeps_the_shadow_fallback() {
8036        let mut parser = HtmlParser::new();
8037        let built = parser
8038            .build_component_templates(ComponentTemplateInput {
8039                tag_name: "my-card",
8040                html: r#"<template data-purpose="content"><div>hi</div></template>"#,
8041                artifact_html: None,
8042                authored_html: None,
8043                css_content: None,
8044                artifact_needed: false,
8045            })
8046            .expect("attributed template should remain ordinary content");
8047        assert!(built.uses_shadow_dom);
8048        assert_eq!(
8049            built.ssr,
8050            r#"<template shadowrootmode="open"><template data-purpose="content"><div>hi</div></template></template>"#
8051        );
8052    }
8053
8054    #[test]
8055    fn closed_shadow_wrapper_is_rejected_during_template_processing() {
8056        let mut parser = HtmlParser::new();
8057        let error = parser
8058            .process_component_template(
8059                r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
8060                None,
8061                None,
8062            )
8063            .expect_err("closed roots must fail");
8064        assert!(matches!(
8065            error,
8066            ParserError::Template(ref diagnostic)
8067                if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
8068        ));
8069    }
8070
8071    #[test]
8072    fn ordinary_template_preserves_signal_fragment_attrs() {
8073        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8074        let processed = parser
8075            .process_component_template(
8076                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8077                None,
8078                None,
8079            )
8080            .expect("process failed");
8081        assert!(
8082            processed.starts_with(r#"<template foo="{{foo}}">"#),
8083            "ordinary template must remain Light content, got: {processed}"
8084        );
8085    }
8086
8087    #[test]
8088    fn unwrapped_component_never_gains_shadow_wrapper() {
8089        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8090        let processed = parser
8091            .process_component_template("<div>hi</div>", None, None)
8092            .expect("process failed");
8093        assert!(
8094            !processed.contains("shadowrootmode"),
8095            "framework must not generate a Shadow root, got: {processed}"
8096        );
8097        assert!(
8098            processed.contains("<div>hi</div>"),
8099            "Light content must survive processing, got: {processed}"
8100        );
8101    }
8102
8103    #[test]
8104    fn default_unwrapped_component_gains_open_shadow_wrapper() {
8105        let mut parser = HtmlParser::new();
8106        let processed = parser
8107            .process_component_template("<div>hi</div>", None, None)
8108            .expect("process failed");
8109        assert_eq!(
8110            processed,
8111            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8112        );
8113    }
8114
8115    #[test]
8116    fn leading_comments_do_not_change_root_template_detection() {
8117        const COMMENT: &str = "<!-- Copyright (C) Corporation. All rights reserved. -->";
8118        let fixtures = [
8119            (
8120                format!(
8121                    "{COMMENT}\n<template shadowrootmode=\"open\">\n  <h1>Hello</h1>\n</template>"
8122                ),
8123                "<template shadowrootmode=\"open\">\n  <h1>Hello</h1>\n</template>",
8124            ),
8125            (format!("{COMMENT}\n<h1>Hello</h1>"), "<h1>Hello</h1>"),
8126            (format!("{COMMENT}\nHello"), "Hello"),
8127        ];
8128
8129        for (input, expected) in fixtures {
8130            let mut parser = HtmlParser::with_options(DomStrategy::Light);
8131            let processed = parser
8132                .process_component_template(&input, None, None)
8133                .expect("leading-comment fixture should process");
8134            assert_eq!(processed, expected);
8135        }
8136    }
8137
8138    #[test]
8139    fn component_policy_wrapper_is_build_only_and_unwrapped_for_light() {
8140        // A policy wrapper is authored on a plain `<template>`; the policy is
8141        // applied to the host through generated CSS, so the wrapper must not
8142        // survive into a Light template.
8143        let html = r#"<template w-hydrate="lazy"><div>hi</div></template>"#;
8144        let mut shadow = HtmlParser::with_options(DomStrategy::Shadow);
8145        let shadow_built = shadow
8146            .build_component_templates(ComponentTemplateInput {
8147                tag_name: "my-comp",
8148                html,
8149                artifact_html: None,
8150                authored_html: None,
8151                css_content: None,
8152                artifact_needed: true,
8153            })
8154            .expect("shadow policy wrapper should compile");
8155        assert_eq!(
8156            shadow_built.ssr,
8157            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8158        );
8159        assert!(!shadow_built.artifact().contains("w-hydrate"));
8160        let mut light = HtmlParser::with_options(DomStrategy::Light);
8161        let light_built = light
8162            .build_component_templates(ComponentTemplateInput {
8163                tag_name: "my-comp",
8164                html,
8165                artifact_html: None,
8166                authored_html: None,
8167                css_content: None,
8168                artifact_needed: true,
8169            })
8170            .expect("light policy wrapper should compile");
8171        assert_eq!(light_built.ssr, "<div>hi</div>");
8172        assert_eq!(light_built.artifact(), "<div>hi</div>");
8173
8174        let mut implicit_shadow = HtmlParser::new();
8175        let shadow_built = implicit_shadow
8176            .build_component_templates(ComponentTemplateInput {
8177                tag_name: "my-comp",
8178                html,
8179                artifact_html: None,
8180                authored_html: None,
8181                css_content: None,
8182                artifact_needed: true,
8183            })
8184            .expect("default policy wrapper should compile as Shadow");
8185        assert_eq!(
8186            shadow_built.ssr,
8187            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8188        );
8189        assert!(!shadow_built.artifact().contains("w-hydrate"));
8190
8191        // An authored Shadow component keeps its wrapper; only the
8192        // compiler-owned policy attributes are stripped.
8193        let shadow_html =
8194            r#"<template shadowrootmode="open" w-hydrate="lazy"><div>hi</div></template>"#;
8195        let mut shadow = HtmlParser::new();
8196        let authored_shadow_built = shadow
8197            .build_component_templates(ComponentTemplateInput {
8198                tag_name: "my-comp",
8199                html: shadow_html,
8200                artifact_html: None,
8201                authored_html: None,
8202                css_content: None,
8203                artifact_needed: true,
8204            })
8205            .expect("shadow policy wrapper should compile");
8206        assert_eq!(
8207            authored_shadow_built.ssr,
8208            r#"<template shadowrootmode="open"><div>hi</div></template>"#
8209        );
8210        assert!(!authored_shadow_built.artifact().contains("w-hydrate"));
8211    }
8212
8213    #[test]
8214    fn interaction_policy_marks_component_hosts_in_ssr_fragments() {
8215        let mut parser = HtmlParser::new();
8216        parser
8217            .component_registry
8218            .register_component(ComponentRegistration::new(
8219                "interaction-shell",
8220                r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
8221                None,
8222                true,
8223            ))
8224            .expect("component registration");
8225        parser
8226            .parse(
8227                "index.html",
8228                "<body><interaction-shell></interaction-shell></body>",
8229            )
8230            .expect("entry parse");
8231        let records = parser.into_fragment_records();
8232        let entry_html: String = records["index.html"]
8233            .fragments
8234            .iter()
8235            .filter_map(|fragment| match fragment.fragment.as_ref() {
8236                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8237                _ => None,
8238            })
8239            .collect();
8240        assert!(entry_html.contains("<interaction-shell data-webui-interaction"));
8241    }
8242
8243    #[test]
8244    fn combined_interaction_policy_marks_hosts_and_emits_render_css() {
8245        let mut parser = HtmlParser::new();
8246        parser
8247            .component_registry
8248            .register_component(ComponentRegistration::new(
8249                "interaction-panel",
8250                concat!(
8251                    r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
8252                    r#"w-hydrate="interaction"><button>Open</button></template>"#,
8253                ),
8254                None,
8255                true,
8256            ))
8257            .expect("component registration");
8258        parser
8259            .parse(
8260                "index.html",
8261                "<body><interaction-panel></interaction-panel></body>",
8262            )
8263            .expect("entry parse");
8264        assert!(parser
8265            .component_registry
8266            .render_policy_css(["interaction-panel"])
8267            .contains("content-visibility:auto"));
8268        let records = parser.into_fragment_records();
8269        let entry_html: String = records["index.html"]
8270            .fragments
8271            .iter()
8272            .filter_map(|fragment| match fragment.fragment.as_ref() {
8273                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8274                _ => None,
8275            })
8276            .collect();
8277        assert!(entry_html.contains("<interaction-panel data-webui-interaction"));
8278    }
8279
8280    // ── CSS-module adoption on dev-authored <template> wrappers ────────
8281    //
8282    // Regression guard: under `CssStrategy::Module`, the
8283    // `shadowrootadoptedstylesheets` attribute is the only wire between
8284    // the framework-emitted importmap/module-script and the shadow root.
8285    // When the developer supplies their own `<template>` wrapper (e.g.
8286    // to attach `@event` handlers), the framework preserves that wrapper
8287    // and appends the CSS-module adoption attribute when it is missing.
8288
8289    #[test]
8290    fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
8291        let mut parser = HtmlParser::new();
8292        let processed = parser
8293            .process_component_template(
8294                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
8295                None,
8296                Some("my-comp"),
8297            )
8298            .expect("process failed");
8299        assert!(
8300            processed.contains(r#"shadowrootmode="open""#),
8301            "dev-authored shadowrootmode must be preserved, got: {processed}"
8302        );
8303        assert!(
8304            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
8305            "module CSS should append adopted stylesheets, got: {processed}"
8306        );
8307    }
8308
8309    #[test]
8310    fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
8311        let mut parser = HtmlParser::new();
8312        let processed = parser
8313            .process_component_template(
8314                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
8315                None,
8316                Some("my-comp"),
8317            )
8318            .unwrap();
8319        assert!(
8320            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
8321            "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
8322        );
8323        assert_eq!(
8324            processed.matches("shadowrootadoptedstylesheets").count(),
8325            1,
8326            "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
8327        );
8328    }
8329
8330    #[test]
8331    fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
8332        for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
8333            let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
8334            let built = match parser.build_component_templates(ComponentTemplateInput {
8335                tag_name: "my-comp",
8336                html: r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
8337                artifact_html: None,
8338                authored_html: None,
8339                css_content: Some(":host { color: red; }"),
8340                artifact_needed: true,
8341            }) {
8342                Ok(built) => built,
8343                Err(err) => panic!(
8344                    "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
8345                ),
8346            };
8347            let artifact = built.artifact();
8348
8349            assert!(artifact.contains(r#"shadowrootmode="open""#));
8350            assert!(artifact.contains(r#"@click="{onClick()}""#));
8351            assert!(artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#));
8352            assert_eq!(artifact.matches("shadowrootadoptedstylesheets").count(), 1);
8353            assert!(!built.ssr.contains("@click"));
8354        }
8355    }
8356
8357    #[test]
8358    fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
8359        // Multi-specifier scenarios: the dev wants to adopt extra sheets
8360        // alongside their own component's module. Honored verbatim — the
8361        // framework's only job is to validate that *some*
8362        // `shadowrootadoptedstylesheets` is present.
8363        let mut parser = HtmlParser::new();
8364        let processed = parser
8365            .process_component_template(
8366                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
8367                None,
8368                Some("my-comp"),
8369            )
8370            .unwrap();
8371        assert!(
8372            processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
8373            "dev's multi-specifier list must be preserved verbatim, got: {processed}"
8374        );
8375    }
8376
8377    #[test]
8378    fn dev_template_no_adopted_specifier_does_not_validate() {
8379        // CssStrategy::Link or CssStrategy::Style pass `adopted_specifier=None`.
8380        // Dev's <template> must be preserved verbatim and the validation
8381        // must not fire.
8382        let mut parser = HtmlParser::new();
8383        let processed = parser
8384            .process_component_template(
8385                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
8386                None,
8387                None,
8388            )
8389            .unwrap();
8390        assert!(
8391            !processed.contains("shadowrootadoptedstylesheets"),
8392            "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
8393        );
8394    }
8395
8396    // test_signal_with_default_value — SKIPPED
8397    // The NodeJS `<f-signal value="testSignal">Default Text</f-signal>` feature
8398    // is not supported in the Rust parser. There is no `f-signal` element
8399    // handling in HtmlParser and no corresponding fragment type in
8400    // webui_protocol.
8401
8402    // test_estimated_buffer_size — SKIPPED
8403    // The NodeJS `estimatedBufferSize` field does not exist in the Rust
8404    // WebUIFragmentRecords / WebUIProtocol types. Buffer size estimation is
8405    // not part of the Rust parser output.
8406
8407    #[test]
8408    fn test_body_start_end_injection() {
8409        // Port of: 'should inject body_start and body_end signals around body content'
8410        // Verifies body_start appears immediately after <body> and body_end
8411        // appears immediately before </body> in a full HTML page.
8412        let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
8413        let (fragments, _) = parse_and_get_fragments(html);
8414
8415        assert_fragments!(
8416            fragments,
8417            [
8418                raw("<html><head>"),
8419                structural_matcher("head_start"),
8420                raw("<title>Test</title>"),
8421                structural_matcher("head_end"),
8422                raw("</head><body>"),
8423                structural_matcher("body_start"),
8424                raw("<div>Content</div><p>More</p>"),
8425                structural_matcher("body_end"),
8426                raw("</body></html>"),
8427            ]
8428        );
8429    }
8430
8431    #[test]
8432    fn test_body_preserves_static_attributes() {
8433        // The <body> tag's attributes must be carried through to the rendered
8434        // HTML, otherwise host frames (e.g. webui-press's `<body data-layout="…">`)
8435        // lose their styling hooks. Earlier versions of `process_body_with_signals`
8436        // emitted a bare `<body>` and dropped every attribute on the element.
8437        let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
8438        let (fragments, _) = parse_and_get_fragments(html);
8439
8440        assert_fragments!(
8441            fragments,
8442            [
8443                raw("<html><head>"),
8444                structural_matcher("head_start"),
8445                raw("<title>T</title>"),
8446                structural_matcher("head_end"),
8447                raw(r#"</head><body data-layout="doc" class="page">"#),
8448                structural_matcher("body_start"),
8449                raw("<p>x</p>"),
8450                structural_matcher("body_end"),
8451                raw("</body></html>"),
8452            ]
8453        );
8454    }
8455
8456    #[test]
8457    fn test_body_preserves_dynamic_attributes() {
8458        // Dynamic attributes on <body> (e.g. `data-layout="{{layout}}"`) must
8459        // emit attribute fragments rather than being inlined as literal text,
8460        // so the handler binds them to runtime state at render time.
8461        let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
8462        let (fragments, _) = parse_and_get_fragments(html);
8463
8464        assert_fragments!(
8465            fragments,
8466            [
8467                raw("<html><head>"),
8468                structural_matcher("head_start"),
8469                raw("<title>T</title>"),
8470                structural_matcher("head_end"),
8471                raw("</head><body"),
8472                attr("data-layout", "layout"),
8473                raw(">"),
8474                structural_matcher("body_start"),
8475                raw("<p>x</p>"),
8476                structural_matcher("body_end"),
8477                raw("</body></html>"),
8478            ]
8479        );
8480    }
8481
8482    #[test]
8483    fn test_head_start_signal_ordering_with_authored_script() {
8484        // `render_streaming` preflights on `head_start` before authored async
8485        // scripts in <head>, mirroring `body_start`. `head_start` must appear
8486        // immediately after the raw `<head>` opening fragment and before any
8487        // child content, including an authored `<script>`.
8488        let html = r#"<html><head><script async src="./analytics.js"></script></head><body></body></html>"#;
8489        let (fragments, _) = parse_and_get_fragments(html);
8490
8491        assert_fragments!(
8492            fragments,
8493            [
8494                raw("<html><head>"),
8495                structural_matcher("head_start"),
8496                raw(r#"<script async src="./analytics.js"></script>"#),
8497                structural_matcher("head_end"),
8498                raw("</head><body>"),
8499                structural_matcher("body_start"),
8500                structural_matcher("body_end"),
8501                raw("</body></html>"),
8502            ]
8503        );
8504    }
8505
8506    #[test]
8507    fn test_head_static_attributes_preserve_legacy_opening_bytes() {
8508        // Compatibility: origin/main emitted a literal `<head>` and ignored
8509        // authored head attributes. The structural signal must not alter that
8510        // ordinary protocol/output shape.
8511        let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
8512        let (fragments, _) = parse_and_get_fragments(html);
8513
8514        assert_fragments!(
8515            fragments,
8516            [
8517                raw("<html><head>"),
8518                structural_matcher("head_start"),
8519                raw("<title>T</title>"),
8520                structural_matcher("head_end"),
8521                raw("</head><body>"),
8522                structural_matcher("body_start"),
8523                structural_matcher("body_end"),
8524                raw("</body></html>"),
8525            ]
8526        );
8527    }
8528
8529    #[test]
8530    fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
8531        // Bound head attributes were also ignored by origin/main. In
8532        // particular, do not introduce an attribute fragment before
8533        // `head_start`, because ordinary rendering must remain byte-identical.
8534        let html =
8535            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
8536        let (fragments, _) = parse_and_get_fragments(html);
8537
8538        assert_fragments!(
8539            fragments,
8540            [
8541                raw("<html><head>"),
8542                structural_matcher("head_start"),
8543                raw("<title>T</title>"),
8544                structural_matcher("head_end"),
8545                raw("</head><body>"),
8546                structural_matcher("body_start"),
8547                structural_matcher("body_end"),
8548                raw("</body></html>"),
8549            ]
8550        );
8551    }
8552
8553    #[test]
8554    fn native_head_and_body_are_ascii_case_insensitive() {
8555        let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
8556        let (fragments, _) = parse_and_get_fragments(html);
8557
8558        assert_fragments!(
8559            fragments,
8560            [
8561                raw(r#"<html><HEAD data-theme="ignored">"#),
8562                structural_matcher("head_start"),
8563                raw("<title>T</title>"),
8564                structural_matcher("head_end"),
8565                raw("</HEAD><BODY"),
8566                attr("data-layout", "layout"),
8567                raw(">"),
8568                structural_matcher("body_start"),
8569                raw("<p>x</p>"),
8570                structural_matcher("body_end"),
8571                raw("</BODY></html>"),
8572            ]
8573        );
8574    }
8575
8576    // ── `<boundary>` / `<webui-hydrate>` tests (Progressive Streaming
8577    // Hydration) ───────────────────────────────────────────────────────────
8578
8579    #[test]
8580    fn entry_boundary_is_an_inline_start_end_tape() {
8581        let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
8582        let (fragments, records) = parse_and_get_fragments(html);
8583
8584        let boundary = match fragments[3].fragment.as_ref() {
8585            Some(Fragment::Boundary(boundary)) => boundary,
8586            other => panic!("expected typed boundary start, got {other:?}"),
8587        };
8588        assert_eq!(boundary.phase(), BoundaryPhase::Start);
8589        assert_eq!(boundary.declaration_id, 0);
8590        assert_eq!(boundary.owner_fragment_id, "index.html");
8591        assert_eq!(boundary.name, "counter-ready");
8592        assert_eq!(boundary.key, None);
8593        assert!(!boundary.may_repeat);
8594        // The body stays inline in the owner record, closed by an end marker,
8595        // so ordinary rendering never looks up a second record.
8596        assert_stream!(
8597            records,
8598            "index.html",
8599            [
8600                raw("<body>"),
8601                structural_matcher("body_start"),
8602                raw("<div>"),
8603                boundary_matcher("counter-ready", 0),
8604                raw("<my-counter></my-counter>"),
8605                boundary_end_matcher(0),
8606                raw("</div>"),
8607                structural_matcher("body_end"),
8608                raw("</body>"),
8609            ]
8610        );
8611        assert!(records["index.html"].contains_boundary);
8612        assert!(
8613            !records.contains_key("boundary-1"),
8614            "an inline tape must not create a body record"
8615        );
8616    }
8617
8618    #[test]
8619    fn boundary_names_are_scoped_per_owner_and_declaration_ids_are_protocol_wide() {
8620        let mut parser = HtmlParser::new();
8621        parser
8622            .parse(
8623                "first.html",
8624                r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
8625            )
8626            .expect("first entry parses");
8627        parser
8628            .parse(
8629                "second.html",
8630                r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
8631            )
8632            .expect("a second owner may reuse the name");
8633        let records = parser.into_fragment_records();
8634        let first = records["first.html"]
8635            .fragments
8636            .iter()
8637            .find_map(|fragment| match fragment.fragment.as_ref() {
8638                Some(Fragment::Boundary(boundary)) => Some(boundary),
8639                _ => None,
8640            })
8641            .expect("first boundary");
8642        let second = records["second.html"]
8643            .fragments
8644            .iter()
8645            .find_map(|fragment| match fragment.fragment.as_ref() {
8646                Some(Fragment::Boundary(boundary)) => Some(boundary),
8647                _ => None,
8648            })
8649            .expect("second boundary");
8650        assert_eq!((first.declaration_id, first.name.as_str()), (0, "a"));
8651        assert_eq!((second.declaration_id, second.name.as_str()), (1, "a"));
8652    }
8653
8654    #[test]
8655    fn boundary_missing_name_errors() {
8656        for html in [
8657            "<body><boundary><p>x</p></boundary></body>",
8658            r#"<body><boundary name=""><p>x</p></boundary></body>"#,
8659            r#"<body><boundary name="   "><p>x</p></boundary></body>"#,
8660        ] {
8661            let mut parser = HtmlParser::new();
8662            let err = parser
8663                .parse("index.html", html)
8664                .expect_err("a boundary without a non-empty name must error");
8665            let ParserError::Template(diag) = err else {
8666                panic!("expected ParserError::Template, got {err:?}");
8667            };
8668            assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
8669        }
8670    }
8671
8672    #[test]
8673    fn boundary_dynamic_name_errors() {
8674        let mut parser = HtmlParser::new();
8675        let err = parser
8676            .parse(
8677                "index.html",
8678                r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
8679            )
8680            .expect_err("a dynamic boundary name must error");
8681        let ParserError::Template(diag) = err else {
8682            panic!("expected ParserError::Template, got {err:?}");
8683        };
8684        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
8685    }
8686
8687    #[test]
8688    fn boundary_names_are_free_form_and_live_on_typed_fragments() {
8689        let mut parser = HtmlParser::new();
8690        let html = concat!(
8691            "<body>",
8692            r#"<boundary name="above the fold"><p>1</p></boundary>"#,
8693            r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
8694            r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
8695            "</body>",
8696        );
8697        parser
8698            .parse("index.html", html)
8699            .expect("free-form boundary names must be accepted");
8700        let records = parser.into_fragment_records();
8701        let names: Vec<&str> = records["index.html"]
8702            .fragments
8703            .iter()
8704            .filter_map(|fragment| match fragment.fragment.as_ref() {
8705                Some(Fragment::Boundary(boundary)) if boundary.phase() == BoundaryPhase::Start => {
8706                    Some(boundary.name.as_str())
8707                }
8708                _ => None,
8709            })
8710            .collect();
8711        assert_eq!(names, ["above the fold", "feed/items #2", "ダッシュボード"]);
8712    }
8713
8714    #[test]
8715    fn module_entry_srcs_records_only_preloadable_critical_entries() {
8716        let mut parser = HtmlParser::new();
8717        parser
8718            .component_registry
8719            .register_component(ComponentRegistration::new(
8720                "weather-island",
8721                r#"<script type="module" src="/nested-weather.js"></script>"#,
8722                None,
8723                true,
8724            ))
8725            .expect("register");
8726        let html = concat!(
8727            "<head>",
8728            // Recorded: a static, module-typed, non-boundary entry.
8729            r#"<script type="module" async src="/index.js"></script>"#,
8730            // Recorded: mixed case on the tag, and a padded/mixed-case `type`
8731            // value, still identify a module. (Attribute *names* are matched
8732            // case-sensitively here, as everywhere else in this parser.)
8733            r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
8734            // Skipped: classic scripts have no ES module graph to preload.
8735            r#"<script src="/legacy.js"></script>"#,
8736            // Skipped: no `src` means nothing to resolve.
8737            r#"<script type="module">import "./x.js";</script>"#,
8738            // Skipped: a per-request binding matches no build artifact.
8739            r#"<script type="module" src="/{{bundle}}.js"></script>"#,
8740            "</head>",
8741            "<body>",
8742            // Skipped: an island loader is deferred on purpose.
8743            r#"<boundary name="weather">"#,
8744            r#"<script type="module" async src="/weather-panel.js"></script>"#,
8745            "<weather-island></weather-island>",
8746            "</boundary>",
8747            // Recorded once: a bottom-of-body entry is still critical, and a
8748            // repeat of an already-seen src must not duplicate a hint.
8749            r#"<script type="module" src="/index.js"></script>"#,
8750            "</body>",
8751        );
8752        parser.parse("index.html", html).expect("parse");
8753
8754        assert_eq!(
8755            parser.module_entry_srcs(),
8756            ["/index.js", "/late.js"],
8757            "only static module entries outside boundaries are preloadable"
8758        );
8759    }
8760
8761    #[test]
8762    fn module_entry_srcs_reset_between_top_level_parses() {
8763        let mut parser = HtmlParser::new();
8764        parser
8765            .parse(
8766                "a.html",
8767                r#"<head><script type="module" src="/a.js"></script></head>"#,
8768            )
8769            .expect("parse a");
8770        assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
8771
8772        parser
8773            .parse(
8774                "b.html",
8775                r#"<head><script type="module" src="/b.js"></script></head>"#,
8776            )
8777            .expect("parse b");
8778        assert_eq!(
8779            parser.module_entry_srcs(),
8780            ["/b.js"],
8781            "each entry owns its own critical modules"
8782        );
8783    }
8784
8785    #[test]
8786    fn boundary_duplicate_name_errors() {
8787        let mut parser = HtmlParser::new();
8788        let html = concat!(
8789            "<body>",
8790            r#"<boundary name="dup"><p>1</p></boundary>"#,
8791            r#"<boundary name="dup"><p>2</p></boundary>"#,
8792            "</body>",
8793        );
8794        let err = parser
8795            .parse("index.html", html)
8796            .expect_err("a duplicate boundary name must error");
8797        let ParserError::Template(diag) = err else {
8798            panic!("expected ParserError::Template, got {err:?}");
8799        };
8800        assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
8801    }
8802
8803    #[test]
8804    fn boundary_nested_boundary_errors() {
8805        let mut parser = HtmlParser::new();
8806        let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
8807        let err = parser
8808            .parse("index.html", html)
8809            .expect_err("a nested boundary must error");
8810        let ParserError::Template(diag) = err else {
8811            panic!("expected ParserError::Template, got {err:?}");
8812        };
8813        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
8814    }
8815
8816    #[test]
8817    fn boundary_transitively_nested_through_component_errors() {
8818        let mut parser = HtmlParser::new();
8819        parser
8820            .component_registry
8821            .register_component(ComponentRegistration::new(
8822                "inner-panel",
8823                r#"<boundary name="inner"><p>x</p></boundary>"#,
8824                None,
8825                true,
8826            ))
8827            .expect("register");
8828        let err = parser
8829            .parse(
8830                "index.html",
8831                r#"<body><boundary name="outer"><inner-panel></inner-panel></boundary></body>"#,
8832            )
8833            .expect_err("transitively nested boundaries must error");
8834        let ParserError::Template(diag) = err else {
8835            panic!("expected ParserError::Template, got {err:?}");
8836        };
8837        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
8838        assert!(diag
8839            .help_text()
8840            .is_some_and(|help| help.contains("runtime branches")));
8841    }
8842
8843    #[test]
8844    fn boundary_inside_if_preserves_false_branch_structure() {
8845        let mut parser = HtmlParser::new();
8846        let html =
8847            r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
8848        parser.parse("index.html", html).expect("parse");
8849        let records = parser.into_fragment_records();
8850        let if_fragment_id = records["index.html"]
8851            .fragments
8852            .iter()
8853            .find_map(|fragment| match fragment.fragment.as_ref() {
8854                Some(Fragment::IfCond(if_cond)) => Some(if_cond.fragment_id.as_str()),
8855                _ => None,
8856            })
8857            .expect("if reference");
8858        let boundary = records[if_fragment_id]
8859            .fragments
8860            .iter()
8861            .find_map(|fragment| match fragment.fragment.as_ref() {
8862                Some(Fragment::Boundary(boundary)) => Some(boundary),
8863                _ => None,
8864            })
8865            .expect("boundary remains behind the runtime if");
8866        assert_eq!(boundary.owner_fragment_id, "index.html");
8867        assert!(!boundary.may_repeat);
8868        assert!(records["index.html"].contains_boundary);
8869        assert!(records[if_fragment_id].contains_boundary);
8870        assert_stream!(
8871            records,
8872            if_fragment_id,
8873            [
8874                boundary_matcher("x", 0),
8875                raw("<p>x</p>"),
8876                boundary_end_matcher(0),
8877            ]
8878        );
8879    }
8880
8881    #[test]
8882    fn boundary_directly_inside_for_is_rejected() {
8883        let mut parser = HtmlParser::new();
8884        let err = parser
8885            .parse(
8886                "index.html",
8887                r#"<body><for each="item in items"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></for></body>"#,
8888            )
8889            .expect_err("a boundary inside a repeat must error");
8890        let ParserError::Template(diag) = err else {
8891            panic!("expected ParserError::Template, got {err:?}");
8892        };
8893        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
8894        assert!(
8895            diag.help_text()
8896                .is_some_and(|help| help.contains("wrap the whole <for>")),
8897            "{diag}"
8898        );
8899    }
8900
8901    #[test]
8902    fn boundary_under_if_inside_for_is_rejected() {
8903        let mut parser = HtmlParser::new();
8904        let err = parser
8905            .parse(
8906                "index.html",
8907                r#"<body><for each="item in items"><if condition="item.ready"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></if></for></body>"#,
8908            )
8909            .expect_err("a boundary behind a runtime branch in a repeat must error");
8910        let ParserError::Template(diag) = err else {
8911            panic!("expected ParserError::Template, got {err:?}");
8912        };
8913        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
8914    }
8915
8916    #[test]
8917    fn component_boundary_used_inside_for_is_rejected() {
8918        let mut parser = HtmlParser::new();
8919        parser
8920            .component_registry
8921            .register_component(ComponentRegistration::new(
8922                "row-item",
8923                r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
8924                None,
8925                true,
8926            ))
8927            .expect("register");
8928        let err = parser
8929            .parse(
8930                "index.html",
8931                r#"<body><for each="item in items"><row-item></row-item></for></body>"#,
8932            )
8933            .expect_err("a component-local boundary reached from a repeat must error");
8934        let ParserError::Template(diag) = err else {
8935            panic!("expected ParserError::Template, got {err:?}");
8936        };
8937        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
8938        // The diagnostic names both ends of the chain: the repeat that would
8939        // execute it and the template that declares it.
8940        assert!(diag.to_string().contains("row-ready"), "{diag}");
8941        assert_eq!(diag.component_name(), Some("index.html"));
8942        assert!(diag.to_string().contains("item in items"), "{diag}");
8943    }
8944
8945    #[test]
8946    fn transitive_repeat_boundary_diagnostic_uses_lowest_repeat_site() {
8947        for _ in 0..20 {
8948            let mut parser = HtmlParser::new();
8949            for (tag, template) in [
8950                (
8951                    "left-list",
8952                    r#"<for each="a in xs"><row-card></row-card></for>"#,
8953                ),
8954                (
8955                    "right-list",
8956                    r#"<for each="b in ys"><row-card></row-card></for>"#,
8957                ),
8958                (
8959                    "row-card",
8960                    r#"<boundary name="row-ready"><p>row</p></boundary>"#,
8961                ),
8962            ] {
8963                parser
8964                    .component_registry
8965                    .register_component(ComponentRegistration::new(tag, template, None, true))
8966                    .expect("register");
8967            }
8968            let err = parser
8969                .parse(
8970                    "index.html",
8971                    r#"<body><left-list></left-list><right-list></right-list></body>"#,
8972                )
8973                .expect_err("both repeats reach the same boundary declaration");
8974            let ParserError::Template(diag) = err else {
8975                panic!("expected ParserError::Template, got {err:?}");
8976            };
8977            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
8978            assert_eq!(diag.component_name(), Some("left-list"));
8979            assert!(diag.to_string().contains("a in xs"), "{diag}");
8980        }
8981    }
8982
8983    #[test]
8984    fn route_boundary_reached_from_for_is_rejected() {
8985        let mut parser = HtmlParser::with_options(DomStrategy::Light);
8986        parser
8987            .component_registry
8988            .register_component(ComponentRegistration::new(
8989                "row-page",
8990                r#"<boundary name="row-ready"><p>row</p></boundary>"#,
8991                None,
8992                true,
8993            ))
8994            .expect("register");
8995        let err = parser
8996            .parse(
8997                "index.html",
8998                r#"<body><for each="item in items"><route path="/" component="row-page"></route></for></body>"#,
8999            )
9000            .expect_err("a route-mounted boundary inside a repeat must error");
9001        let ParserError::Template(diag) = err else {
9002            panic!("expected ParserError::Template, got {err:?}");
9003        };
9004        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9005    }
9006
9007    #[test]
9008    fn outlet_mounted_boundary_reached_from_for_is_rejected() {
9009        // The child route renders at the parent component's <outlet />, which
9010        // the repeat body reaches through <shell-page>. That edge is a runtime
9011        // mount with no lexical nesting, so only the mount analysis catches it.
9012        let mut parser = HtmlParser::with_options(DomStrategy::Light);
9013        for (tag, template) in [
9014            (
9015                "shell-page",
9016                r#"<div><for each="item in items"><outlet></outlet></for></div>"#,
9017            ),
9018            (
9019                "child-page",
9020                r#"<boundary name="child-ready"><p>child</p></boundary>"#,
9021            ),
9022        ] {
9023            parser
9024                .component_registry
9025                .register_component(ComponentRegistration::new(tag, template, None, true))
9026                .expect("register");
9027        }
9028        let err = parser
9029            .parse(
9030                "index.html",
9031                r#"<body><route path="/" component="shell-page"><route path="child" component="child-page"></route></route></body>"#,
9032            )
9033            .expect_err("an outlet-mounted boundary inside a repeat must error");
9034        let ParserError::Template(diag) = err else {
9035            panic!("expected ParserError::Template, got {err:?}");
9036        };
9037        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9038        assert!(diag.to_string().contains("child-ready"), "{diag}");
9039    }
9040
9041    #[test]
9042    fn for_inside_one_boundary_is_allowed_and_stays_atomic() {
9043        // The whole finite list is one atomic checkpoint, so the repeat body
9044        // record must not be marked as carrying a boundary of its own.
9045        let mut parser = HtmlParser::new();
9046        parser
9047            .parse(
9048                "index.html",
9049                r#"<body><boundary name="feed"><for each="item in items"><p>{{item.label}}</p></for></boundary></body>"#,
9050            )
9051            .expect("a repeat inside one boundary parses");
9052        let records = parser.into_fragment_records();
9053        assert!(records["index.html"].contains_boundary);
9054        assert!(
9055            !records["for-1"].contains_boundary,
9056            "the repeat body itself declares no boundary"
9057        );
9058        let boundary = records["index.html"]
9059            .fragments
9060            .iter()
9061            .find_map(|fragment| match fragment.fragment.as_ref() {
9062                Some(Fragment::Boundary(boundary)) => Some(boundary),
9063                _ => None,
9064            })
9065            .expect("boundary");
9066        assert!(!boundary.may_repeat);
9067    }
9068
9069    #[test]
9070    fn boundary_after_for_is_allowed() {
9071        let mut parser = HtmlParser::new();
9072        parser
9073            .parse(
9074                "index.html",
9075                r#"<body><for each="item in items"><p>{{item.label}}</p></for><boundary name="tail"><p>tail</p></boundary></body>"#,
9076            )
9077            .expect("a boundary following a repeat parses");
9078        let records = parser.into_fragment_records();
9079        assert!(records["index.html"].contains_boundary);
9080        assert!(!records["for-1"].contains_boundary);
9081    }
9082
9083    #[test]
9084    fn boundary_free_for_still_renders_without_boundary_metadata() {
9085        let mut parser = HtmlParser::new();
9086        parser
9087            .parse(
9088                "index.html",
9089                r#"<body><for each="item in items"><p>{{item.label}}</p></for></body>"#,
9090            )
9091            .expect("an ordinary repeat parses");
9092        let records = parser.into_fragment_records();
9093        assert!(!records["index.html"].contains_boundary);
9094        assert!(!records["for-1"].contains_boundary);
9095    }
9096
9097    #[test]
9098    fn empty_boundary_key_is_still_rejected() {
9099        let mut parser = HtmlParser::new();
9100        let err = parser
9101            .parse(
9102                "invalid.html",
9103                r#"<body><boundary name="x" key=""><p>x</p></boundary></body>"#,
9104            )
9105            .expect_err("an empty key must error");
9106        let ParserError::Template(diag) = err else {
9107            panic!("expected ParserError::Template, got {err:?}");
9108        };
9109        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_KEY));
9110    }
9111
9112    #[test]
9113    fn boundary_inside_reusable_component_is_component_local() {
9114        let mut parser = HtmlParser::new();
9115        parser
9116            .component_registry
9117            .register_component(ComponentRegistration::new(
9118                "my-widget",
9119                r#"<boundary name="x"><p>x</p></boundary>"#,
9120                None,
9121                true,
9122            ))
9123            .expect("register");
9124
9125        parser
9126            .parse(
9127                "index.html",
9128                r#"<body><my-widget></my-widget><boundary name="x"><p>entry</p></boundary></body>"#,
9129            )
9130            .expect("component-local boundary parses");
9131        let records = parser.into_fragment_records();
9132        let boundary = records["my-widget"]
9133            .fragments
9134            .iter()
9135            .find_map(|fragment| match fragment.fragment.as_ref() {
9136                Some(Fragment::Boundary(boundary)) => Some(boundary),
9137                _ => None,
9138            })
9139            .expect("component boundary");
9140        assert_eq!(boundary.owner_fragment_id, "my-widget");
9141        assert_eq!(boundary.name, "x");
9142        assert!(records["my-widget"].contains_boundary);
9143        assert!(records["index.html"].contains_boundary);
9144        let owners: HashSet<&str> = records
9145            .values()
9146            .flat_map(|list| list.fragments.iter())
9147            .filter_map(|fragment| match fragment.fragment.as_ref() {
9148                Some(Fragment::Boundary(boundary)) if boundary.name == "x" => {
9149                    Some(boundary.owner_fragment_id.as_str())
9150                }
9151                _ => None,
9152            })
9153            .collect();
9154        assert_eq!(owners, HashSet::from(["index.html", "my-widget"]));
9155    }
9156
9157    #[test]
9158    fn component_boundary_repeated_from_static_callsites_requires_a_key() {
9159        // `<for>` can no longer produce multiple occurrences, but two static
9160        // callsites of the same boundary-bearing component still can, so the
9161        // declaration keeps its key requirement.
9162        let mut parser = HtmlParser::new();
9163        parser
9164            .component_registry
9165            .register_component(ComponentRegistration::new(
9166                "row-item",
9167                r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9168                None,
9169                true,
9170            ))
9171            .expect("register");
9172        let err = parser
9173            .parse(
9174                "index.html",
9175                r#"<body><row-item></row-item><row-item></row-item></body>"#,
9176            )
9177            .expect_err("a repeated declaration without a key must error");
9178        let ParserError::Template(diag) = err else {
9179            panic!("expected ParserError::Template, got {err:?}");
9180        };
9181        assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_KEY));
9182        assert_eq!(diag.component_name(), Some("row-item"));
9183
9184        let mut parser = HtmlParser::new();
9185        parser
9186            .component_registry
9187            .register_component(ComponentRegistration::new(
9188                "row-item",
9189                r#"<boundary name="row-ready" key="{{rowId}}"><span>{{label}}</span></boundary>"#,
9190                None,
9191                true,
9192            ))
9193            .expect("register");
9194        parser
9195            .parse(
9196                "index.html",
9197                r#"<body><row-item row-id="a"></row-item><row-item row-id="b"></row-item></body>"#,
9198            )
9199            .expect("a keyed repeated declaration parses");
9200        let records = parser.into_fragment_records();
9201        let boundary = records["row-item"]
9202            .fragments
9203            .iter()
9204            .find_map(|fragment| match fragment.fragment.as_ref() {
9205                Some(Fragment::Boundary(boundary)) => Some(boundary),
9206                _ => None,
9207            })
9208            .expect("component boundary");
9209        assert!(boundary.may_repeat);
9210        assert_eq!(boundary.key.as_deref(), Some("{{rowId}}"));
9211    }
9212
9213    #[test]
9214    fn component_boundary_once_per_independent_entry_is_not_repeatable() {
9215        let mut parser = HtmlParser::new();
9216        parser
9217            .component_registry
9218            .register_component(ComponentRegistration::new(
9219                "shared-panel",
9220                r#"<boundary name="ready"><span>x</span></boundary>"#,
9221                None,
9222                true,
9223            ))
9224            .expect("register");
9225        for entry in ["first.html", "second.html"] {
9226            parser
9227                .parse(entry, "<body><shared-panel></shared-panel></body>")
9228                .expect("entry parses");
9229        }
9230        let records = parser.into_fragment_records();
9231        let boundary = records["shared-panel"]
9232            .fragments
9233            .iter()
9234            .find_map(|fragment| match fragment.fragment.as_ref() {
9235                Some(Fragment::Boundary(boundary)) => Some(boundary),
9236                _ => None,
9237            })
9238            .expect("component boundary");
9239        assert!(!boundary.may_repeat);
9240    }
9241
9242    #[test]
9243    fn boundary_inside_component_host_content_errors() {
9244        let mut parser = HtmlParser::new();
9245        parser
9246            .component_registry
9247            .register_component(ComponentRegistration::new(
9248                "my-widget",
9249                "<div>content</div>",
9250                None,
9251                true,
9252            ))
9253            .expect("register");
9254
9255        let err = parser
9256            .parse(
9257                "index.html",
9258                r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
9259            )
9260            .expect_err("a boundary inside component host content must error");
9261        let ParserError::Template(diag) = err else {
9262            panic!("expected ParserError::Template, got {err:?}");
9263        };
9264        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
9265        assert!(
9266            diag.to_string().contains("component host content"),
9267            "{diag}"
9268        );
9269    }
9270
9271    #[test]
9272    fn boundary_inside_raw_or_inert_html_context_errors() {
9273        for (element, expected_scope) in [
9274            ("textarea", "<textarea> text content"),
9275            ("title", "<title> text content"),
9276            ("script", "<script> raw-text content"),
9277            ("xmp", "<xmp> raw-text content"),
9278            ("iframe", "<iframe> raw-text content"),
9279            ("noembed", "<noembed> raw-text content"),
9280            ("noframes", "<noframes> raw-text content"),
9281            ("noscript", "<noscript> inert content"),
9282            ("plaintext", "<plaintext> text content"),
9283            ("template", "<template> inert content"),
9284        ] {
9285            let mut parser = HtmlParser::new();
9286            let html = format!(
9287                r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
9288            );
9289            let err = parser
9290                .parse("index.html", &html)
9291                .expect_err("a boundary in raw or inert content must error");
9292            let ParserError::Template(diag) = err else {
9293                panic!("expected ParserError::Template, got {err:?}");
9294            };
9295            assert_eq!(
9296                diag.error_code(),
9297                Some(codes::BOUNDARY_CROSSES_SCOPE),
9298                "unexpected code for <{element}>"
9299            );
9300            assert!(
9301                diag.to_string().contains(expected_scope),
9302                "unexpected scope for <{element}>: {diag}"
9303            );
9304        }
9305    }
9306
9307    #[test]
9308    fn route_contained_boundary_has_runtime_content_fragment() {
9309        let mut parser = HtmlParser::with_options(DomStrategy::Light);
9310        parser
9311            .component_registry
9312            .register_component(ComponentRegistration::new(
9313                "home-page",
9314                "<h1>Home</h1>",
9315                None,
9316                true,
9317            ))
9318            .expect("register");
9319        parser
9320            .parse(
9321                "index.html",
9322                r#"<route path="/" component="home-page"><boundary name="route-ready"><aside>Ready</aside></boundary></route>"#,
9323            )
9324            .expect("route-contained boundary parses");
9325        let records = parser.into_fragment_records();
9326        assert!(records["index.html"].contains_boundary);
9327        let route = records["index.html"]
9328            .fragments
9329            .iter()
9330            .find_map(|fragment| match fragment.fragment.as_ref() {
9331                Some(Fragment::Route(route)) => Some(route),
9332                _ => None,
9333            })
9334            .expect("route");
9335        assert!(!route.content_fragment_id.is_empty());
9336        let boundary = records[&route.content_fragment_id]
9337            .fragments
9338            .iter()
9339            .find_map(|fragment| match fragment.fragment.as_ref() {
9340                Some(Fragment::Boundary(boundary)) => Some(boundary),
9341                _ => None,
9342            })
9343            .expect("route boundary");
9344        assert_eq!(boundary.owner_fragment_id, "index.html");
9345        assert_eq!(boundary.name, "route-ready");
9346        assert_stream!(
9347            records,
9348            route.content_fragment_id.as_str(),
9349            [
9350                boundary_matcher("route-ready", 0),
9351                raw("<aside>Ready</aside>"),
9352                boundary_end_matcher(0),
9353            ]
9354        );
9355    }
9356
9357    #[test]
9358    fn route_boundary_nested_in_ignored_markup_errors() {
9359        let mut parser = HtmlParser::new();
9360        let err = parser
9361            .parse(
9362                "index.html",
9363                r#"<route path="/" component="home-page"><div><boundary name="late"><p>x</p></boundary></div></route>"#,
9364            )
9365            .expect_err("route boundaries must be direct children");
9366        let ParserError::Template(diag) = err else {
9367            panic!("expected ParserError::Template, got {err:?}");
9368        };
9369        assert_eq!(
9370            diag.error_code(),
9371            Some(codes::INVALID_ROUTE_BOUNDARY_PLACEMENT)
9372        );
9373    }
9374
9375    #[test]
9376    fn boundary_must_be_inside_an_open_body() {
9377        for html in [
9378            r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
9379            r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
9380        ] {
9381            let mut parser = HtmlParser::new();
9382            let err = parser
9383                .parse("index.html", html)
9384                .expect_err("a boundary outside body must error");
9385            let ParserError::Template(diag) = err else {
9386                panic!("expected ParserError::Template, got {err:?}");
9387            };
9388            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
9389            assert_eq!(
9390                diag.help_text(),
9391                Some("move the <boundary> between the opening <body> and its matching </body>")
9392            );
9393        }
9394
9395        let mut parser = HtmlParser::new();
9396        parser
9397            .parse(
9398                "index.html",
9399                r#"<BODY><boundary name="inside"></boundary></BODY>"#,
9400            )
9401            .expect("mixed-case native body keeps the boundary inside an open body");
9402    }
9403
9404    #[test]
9405    fn boundary_inside_foster_parenting_context_errors() {
9406        // Each of these insertion modes moves an unknown element (the
9407        // generated <webui-hydrate> sentinel) out of the subtree, which would
9408        // split it from its payload script and halt hydration at runtime.
9409        for html in [
9410            r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
9411            r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
9412            r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
9413            r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
9414            r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
9415            r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
9416            r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
9417            r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
9418            // Mixed case must be rejected identically: HTML tag names are
9419            // case-insensitive, so the browser foster-parents these too.
9420            r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
9421        ] {
9422            let mut parser = HtmlParser::new();
9423            let err = parser
9424                .parse("index.html", html)
9425                .expect_err("a boundary in a foster-parenting context must error");
9426            let ParserError::Template(diag) = err else {
9427                panic!("expected ParserError::Template, got {err:?}");
9428            };
9429            assert_eq!(
9430                diag.error_code(),
9431                Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
9432                "unexpected code for {html}"
9433            );
9434            assert!(
9435                diag.help_text()
9436                    .is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
9437                "help should suggest a safe placement for {html}"
9438            );
9439        }
9440    }
9441
9442    #[test]
9443    fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
9444        // <td>/<th>/<caption> switch back to "in body" rules, so an unknown
9445        // element is *not* foster-parented and the boundary is safe even
9446        // though <table>/<tr> ancestors are hostile.
9447        for html in [
9448            r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
9449            r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
9450            r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
9451            // Wrapping the whole table is the other documented fix.
9452            r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
9453            // A <td> barrier resets the depth, an inner table raises it again,
9454            // and closing that inner table must restore the barrier's zero —
9455            // not the outer table's non-zero depth.
9456            concat!(
9457                "<body><table><tbody><tr><td>",
9458                "<table><tbody><tr><td>inner</td></tr></tbody></table>",
9459                r#"<boundary name="after-inner"><p>x</p></boundary>"#,
9460                "</td></tr></tbody></table></body>",
9461            ),
9462            // Void and self-closing elements must not leak restore ops.
9463            r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
9464        ] {
9465            let mut parser = HtmlParser::new();
9466            parser
9467                .parse("index.html", html)
9468                .unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
9469        }
9470    }
9471
9472    #[test]
9473    fn foster_context_depth_is_restored_after_leaving_a_table() {
9474        // Regression guard for the save/restore op: a boundary *after* a table
9475        // must not inherit the table's hostile depth.
9476        let mut parser = HtmlParser::new();
9477        parser
9478            .parse(
9479                "index.html",
9480                concat!(
9481                    "<body>",
9482                    "<table><tbody><tr><td>x</td></tr></tbody></table>",
9483                    r#"<boundary name="after-table"><p>y</p></boundary>"#,
9484                    "</body>",
9485                ),
9486            )
9487            .expect("a boundary after a closed table must be accepted");
9488    }
9489
9490    #[test]
9491    fn authored_raw_bindings_cannot_forge_structural_signals() {
9492        let html = concat!(
9493            "<html><head></head><body>",
9494            "{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
9495            "{{{boundary_start:0}}}{{{boundary_end:0}}}",
9496            "{{{streaming_root:forged}}}",
9497            "</body></html>",
9498        );
9499        let (fragments, _) = parse_and_get_fragments(html);
9500        let raw_signals: Vec<&str> = fragments
9501            .iter()
9502            .filter_map(|fragment| match fragment.fragment.as_ref() {
9503                Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
9504                _ => None,
9505            })
9506            .collect();
9507
9508        assert_eq!(
9509            raw_signals,
9510            [
9511                "}}}webui:head_start",
9512                "}}}webui:head_end",
9513                "}}}webui:body_start",
9514                "head_start",
9515                "head_end",
9516                "body_start",
9517                "body_end",
9518                "boundary_start:0",
9519                "boundary_end:0",
9520                "streaming_root:forged",
9521                "}}}webui:body_end",
9522            ]
9523        );
9524    }
9525
9526    #[test]
9527    fn authored_webui_hydrate_errors_at_top_level() {
9528        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
9529            let mut parser = HtmlParser::new();
9530            let html = format!("<div><{tag}></{tag}></div>");
9531            let err = parser
9532                .parse("index.html", &html)
9533                .expect_err("an authored <webui-hydrate> must error in any ASCII casing");
9534            let ParserError::Template(diag) = err else {
9535                panic!("expected ParserError::Template, got {err:?}");
9536            };
9537            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
9538        }
9539    }
9540
9541    #[test]
9542    fn authored_webui_hydrate_errors_inside_route() {
9543        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
9544            let mut parser = HtmlParser::new();
9545            let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
9546            let err = parser
9547                .parse("index.html", &html)
9548                .expect_err("an authored <webui-hydrate> inside <route> must error");
9549            let ParserError::Template(diag) = err else {
9550                panic!("expected ParserError::Template, got {err:?}");
9551            };
9552            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
9553        }
9554    }
9555
9556    #[test]
9557    fn boundary_directive_remains_case_sensitive() {
9558        let (fragments, _) =
9559            parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
9560
9561        assert_fragments!(
9562            fragments,
9563            [raw(
9564                r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
9565            ),]
9566        );
9567    }
9568
9569    #[test]
9570    fn templates_without_webui_boundary_are_unaffected() {
9571        // Regression: ordinary templates with no `<boundary>` directive
9572        // must parse exactly as before — no boundary signals, no behavior
9573        // change for the non-streaming path.
9574        let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
9575        let (fragments, _) = parse_and_get_fragments(html);
9576
9577        assert_fragments!(
9578            fragments,
9579            [
9580                raw("<div><my-counter"),
9581                attr("count", "count"),
9582                raw("></my-counter></div>"),
9583            ]
9584        );
9585    }
9586
9587    #[test]
9588    fn test_fail_with_invalid_markup() {
9589        // Port of: 'should fail with invalid markup'
9590        let mut parser = HtmlParser::new();
9591        let result = parser.parse("index.html", "<div><span></div></span>");
9592
9593        assert!(matches!(result, Err(ParserError::Template(diag)) if
9594            diag.to_string().contains("unclosed <span> tag")
9595        ));
9596    }
9597
9598    #[test]
9599    fn test_complex_raw_text_page() {
9600        // Port of: 'should process a complex raw text page with DOCTYPE,
9601        // meta tags, styles, and scripts'
9602        let html = concat!(
9603            "<!DOCTYPE html>",
9604            "<html lang=\"en\">",
9605            "<head>",
9606            "<meta charset=\"utf-8\">",
9607            "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
9608            "<title>Complex Page</title>",
9609            "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
9610            "<link rel=\"stylesheet\" href=\"styles.css\">",
9611            "</head>",
9612            "<body>",
9613            "<h1>Hello World</h1>",
9614            "<script type=\"module\" src=\"./app.js\"></script>",
9615            "</body>",
9616            "</html>",
9617        );
9618        let (fragments, _) = parse_and_get_fragments(html);
9619
9620        // Should have: raw(DOCTYPE+<head>), head_start, raw(head content),
9621        // head_end, raw(</head><body>), body_start, raw(body content),
9622        // body_end, raw(</body></html>)
9623        assert!(
9624            fragments.len() >= 9,
9625            "Expected at least 9 fragments, got {}",
9626            fragments.len()
9627        );
9628
9629        // First fragment: DOCTYPE through the opening <head> tag
9630        assert!(
9631            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
9632                raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
9633            "First fragment should contain DOCTYPE and end with <head>, got: {:?}",
9634            fragments[0]
9635        );
9636
9637        // head_start signal
9638        assert!(
9639            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
9640                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
9641            "Second fragment should be head_start signal"
9642        );
9643
9644        // Head content (meta, title, style, link)
9645        assert!(
9646            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
9647                raw.value.contains("<meta charset=\"utf-8\">") &&
9648                raw.value.contains("<meta name=\"viewport\"") &&
9649                raw.value.contains("<title>Complex Page</title>") &&
9650                raw.value.contains("<style>") &&
9651                raw.value.contains("body { margin: 0; padding: 0; }")),
9652            "Third fragment should contain all head content, got: {:?}",
9653            fragments[2]
9654        );
9655
9656        // head_end signal
9657        assert!(
9658            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
9659                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
9660            "Fourth fragment should be head_end signal"
9661        );
9662
9663        // </head><body>
9664        assert!(
9665            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
9666                raw.value.contains("</head>") && raw.value.ends_with("<body>")),
9667            "Fifth fragment should contain </head><body>"
9668        );
9669
9670        // body_start signal
9671        assert!(
9672            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
9673                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
9674            "Sixth fragment should be body_start signal"
9675        );
9676
9677        // Body content (h1 and script)
9678        assert!(
9679            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
9680                raw.value.contains("<h1>Hello World</h1>") &&
9681                raw.value.contains("<script")),
9682            "Seventh fragment should contain body content"
9683        );
9684
9685        // body_end signal
9686        assert!(
9687            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
9688                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
9689            "Eighth fragment should be body_end signal"
9690        );
9691
9692        // Closing tags
9693        assert!(
9694            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
9695                raw.value.contains("</body>") && raw.value.contains("</html>")),
9696            "Ninth fragment should contain closing tags"
9697        );
9698    }
9699
9700    // --- Binding count tests (with mock plugin) ---
9701
9702    /// Mock plugin that records the binding attribute count for each element.
9703    struct BindingCountPlugin {
9704        counts: Vec<u32>,
9705    }
9706
9707    impl BindingCountPlugin {
9708        fn new() -> Self {
9709            Self { counts: Vec::new() }
9710        }
9711    }
9712
9713    impl crate::plugin::ParserPlugin for BindingCountPlugin {
9714        fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
9715            let attr_name = context.name;
9716            if attr_name.starts_with('@') || attr_name == "f-ref" {
9717                AttributeAction::SkipAndCountBinding
9718            } else {
9719                AttributeAction::Keep
9720            }
9721        }
9722
9723        fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
9724            let binding_attribute_count = context.binding_count;
9725            self.counts.push(binding_attribute_count);
9726            if binding_attribute_count > 0 {
9727                Some(binding_attribute_count.to_le_bytes().to_vec())
9728            } else {
9729                None
9730            }
9731        }
9732    }
9733
9734    struct TemplateCapturePlugin {
9735        template: Option<String>,
9736        uses_shadow_dom: bool,
9737    }
9738
9739    impl TemplateCapturePlugin {
9740        fn new() -> Self {
9741            Self {
9742                template: None,
9743                uses_shadow_dom: false,
9744            }
9745        }
9746    }
9747
9748    impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
9749        fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
9750            self.template = Some(context.template.to_string());
9751            self.uses_shadow_dom = context.uses_shadow_dom;
9752            Ok(())
9753        }
9754
9755        fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
9756            let attr_name = context.name;
9757            if attr_name.starts_with('@') || attr_name == "f-ref" {
9758                AttributeAction::SkipAndCountBinding
9759            } else {
9760                AttributeAction::Keep
9761            }
9762        }
9763
9764        fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
9765            match self.template {
9766                Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
9767                    crate::plugin::ComponentTemplateArtifact::template(
9768                        "todo-app".to_string(),
9769                        template,
9770                        self.uses_shadow_dom,
9771                    ),
9772                ])),
9773                None => Ok(ParserPluginArtifacts::None),
9774            }
9775        }
9776    }
9777
9778    #[test]
9779    fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
9780        let mut parser = HtmlParser::with_plugin_options(
9781            Box::new(TemplateCapturePlugin::new()),
9782            CssStrategy::Link,
9783        );
9784        parser
9785            .component_registry_mut()
9786            .register_component(ComponentRegistration::new(
9787                "todo-app",
9788                r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
9789                Some(":host { display: block; }"),
9790             true,))
9791            .expect("register todo-app");
9792
9793        parser
9794            .parse("index.html", "<todo-app></todo-app>")
9795            .expect("parse failed");
9796
9797        let artifacts = parser.take_plugin_artifacts().expect("artifacts");
9798        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
9799            panic!("expected captured component template");
9800        };
9801        let template = &templates[0].template;
9802
9803        assert!(
9804            template.contains(r#"shadowrootmode="open""#),
9805            "parser must pass dev-authored root template attrs to plugins, got: {template}"
9806        );
9807        assert!(
9808            template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
9809            "parser must pass dev-authored root event attrs to plugins, got: {template}"
9810        );
9811        assert!(
9812            template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
9813            "parser must pass dev-authored root event attrs to plugins, got: {template}"
9814        );
9815        assert!(
9816            template.contains(r#"f-ref="{root}""#),
9817            "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
9818        );
9819        assert!(
9820            !template.contains("shadowrootadoptedstylesheets"),
9821            "link CSS must not add CSS module adoption attrs, got: {template}"
9822        );
9823    }
9824
9825    #[test]
9826    fn test_component_static_attrs_not_counted_as_bindings() {
9827        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
9828        parser
9829            .component_registry
9830            .register_component(ComponentRegistration::new(
9831                "my-btn",
9832                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
9833                None,
9834                true,
9835            ))
9836            .expect("register");
9837
9838        // All attributes are static — binding count should be 0
9839        let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
9840        let result = parser.parse("index.html", html);
9841        assert!(result.is_ok());
9842
9843        let records = parser.into_fragment_records();
9844
9845        // No Plugin fragment should appear (binding count = 0)
9846        let frags = &records["index.html"].fragments;
9847        let plugin_count = frags
9848            .iter()
9849            .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
9850            .count();
9851        assert_eq!(
9852            plugin_count, 0,
9853            "Static-only component attributes should not emit a Plugin fragment"
9854        );
9855    }
9856
9857    #[test]
9858    fn test_component_dynamic_attr_counted_as_binding() {
9859        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
9860        parser
9861            .component_registry
9862            .register_component(ComponentRegistration::new(
9863                "my-btn",
9864                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
9865                None,
9866                true,
9867            ))
9868            .expect("register");
9869
9870        // One dynamic attribute ({{...}}) — binding count should be 1
9871        let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
9872        let result = parser.parse("index.html", html);
9873        assert!(result.is_ok());
9874
9875        let records = parser.into_fragment_records();
9876        let frags = &records["index.html"].fragments;
9877
9878        // Exactly one Plugin fragment with count = 1
9879        let plugin_frags: Vec<_> = frags
9880            .iter()
9881            .filter_map(|f| match f.fragment.as_ref() {
9882                Some(Fragment::Plugin(p)) => Some(&p.data),
9883                _ => None,
9884            })
9885            .collect();
9886        assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
9887        let count = u32::from_le_bytes([
9888            plugin_frags[0][0],
9889            plugin_frags[0][1],
9890            plugin_frags[0][2],
9891            plugin_frags[0][3],
9892        ]);
9893        assert_eq!(count, 1, "Binding attribute count should be 1");
9894    }
9895
9896    #[test]
9897    fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
9898        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
9899        parser
9900            .component_registry
9901            .register_component(ComponentRegistration::new(
9902                "my-btn",
9903                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
9904                None,
9905                true,
9906            ))
9907            .expect("register");
9908
9909        // 2 static, 1 dynamic, 1 skipped-with-plugin (@click) — only dynamic + skipped counted
9910        let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
9911        let result = parser.parse("index.html", html);
9912        assert!(result.is_ok());
9913
9914        let records = parser.into_fragment_records();
9915        let frags = &records["index.html"].fragments;
9916
9917        let plugin_frags: Vec<_> = frags
9918            .iter()
9919            .filter_map(|f| match f.fragment.as_ref() {
9920                Some(Fragment::Plugin(p)) => Some(&p.data),
9921                _ => None,
9922            })
9923            .collect();
9924        assert_eq!(plugin_frags.len(), 1);
9925        let count = u32::from_le_bytes([
9926            plugin_frags[0][0],
9927            plugin_frags[0][1],
9928            plugin_frags[0][2],
9929            plugin_frags[0][3],
9930        ]);
9931        // 1 dynamic (appearance) + 1 skipped-but-counted (@click) = 2
9932        assert_eq!(
9933            count, 2,
9934            "Binding count should include dynamic + plugin-skipped attrs, not static"
9935        );
9936    }
9937
9938    #[test]
9939    fn test_component_only_skipped_plugin_attrs_counted() {
9940        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
9941        parser
9942            .component_registry
9943            .register_component(ComponentRegistration::new(
9944                "my-btn",
9945                r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
9946                None,
9947                true,
9948            ))
9949            .expect("register");
9950
9951        // Only plugin-skipped attrs (@click, f-ref) plus static — only skipped counted
9952        let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
9953        let result = parser.parse("index.html", html);
9954        assert!(result.is_ok());
9955
9956        let records = parser.into_fragment_records();
9957        let frags = &records["index.html"].fragments;
9958
9959        let plugin_frags: Vec<_> = frags
9960            .iter()
9961            .filter_map(|f| match f.fragment.as_ref() {
9962                Some(Fragment::Plugin(p)) => Some(&p.data),
9963                _ => None,
9964            })
9965            .collect();
9966        assert_eq!(plugin_frags.len(), 1);
9967        let count = u32::from_le_bytes([
9968            plugin_frags[0][0],
9969            plugin_frags[0][1],
9970            plugin_frags[0][2],
9971            plugin_frags[0][3],
9972        ]);
9973        // @click + f-ref = 2, title is static = not counted
9974        assert_eq!(
9975            count, 2,
9976            "Only plugin-skipped attrs should be counted, not static title"
9977        );
9978    }
9979
9980    // ── Comment stripping tests ──────────────────────────────────────
9981
9982    #[test]
9983    fn test_comment_handlebars_signal_is_stripped() {
9984        let mut parser = HtmlParser::new();
9985        let html = "<!--{{tokens}}-->";
9986        parser.parse("test.html", html).expect("parse failed");
9987        let records = parser.into_fragment_records();
9988
9989        assert_stream!(records, "test.html", []);
9990    }
9991
9992    #[test]
9993    fn test_comment_triple_brace_raw_signal_is_stripped() {
9994        let mut parser = HtmlParser::new();
9995        let html = "<!--{{{tokens}}}-->";
9996        parser.parse("test.html", html).expect("parse failed");
9997        let records = parser.into_fragment_records();
9998
9999        assert_stream!(records, "test.html", []);
10000    }
10001
10002    #[test]
10003    fn test_comment_regular_stripped() {
10004        let mut parser = HtmlParser::new();
10005        let html = "<!-- regular comment -->";
10006        parser.parse("test.html", html).expect("parse failed");
10007        let records = parser.into_fragment_records();
10008
10009        assert_stream!(records, "test.html", []);
10010    }
10011
10012    #[test]
10013    fn test_comment_dotted_signal_is_stripped() {
10014        let mut parser = HtmlParser::new();
10015        let html = "<!--{{tokens.light}}-->";
10016        parser.parse("test.html", html).expect("parse failed");
10017        let records = parser.into_fragment_records();
10018
10019        assert_stream!(records, "test.html", []);
10020    }
10021
10022    #[test]
10023    fn test_comment_arbitrary_identifier_is_stripped() {
10024        let mut parser = HtmlParser::new();
10025        let html = "<!--{{someOtherBinding}}-->";
10026        parser.parse("test.html", html).expect("parse failed");
10027        let records = parser.into_fragment_records();
10028
10029        assert_stream!(records, "test.html", []);
10030    }
10031
10032    #[test]
10033    fn test_comment_with_surrounding_content_is_stripped() {
10034        let mut parser = HtmlParser::new();
10035        let html = "<div><!--{{tokens}}--></div>";
10036        parser.parse("test.html", html).expect("parse failed");
10037        let records = parser.into_fragment_records();
10038
10039        assert_stream!(records, "test.html", [raw("<div></div>")]);
10040    }
10041
10042    #[test]
10043    fn test_component_template_unterminated_comment_returns_error() {
10044        let mut parser = HtmlParser::new();
10045        parser
10046            .component_registry_mut()
10047            .register_component(ComponentRegistration::new(
10048                "x-bad",
10049                "<span>ok</span><!-- missing close",
10050                None,
10051                true,
10052            ))
10053            .expect("register failed");
10054
10055        let result = parser.parse("index.html", "<x-bad></x-bad>");
10056
10057        assert!(matches!(result, Err(ParserError::Template(diag)) if
10058            diag.to_string().contains("unterminated HTML comment")
10059        ));
10060    }
10061
10062    #[test]
10063    fn test_webui_plugin_strips_component_comments_before_metadata() {
10064        let mut parser =
10065            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10066        parser
10067            .component_registry_mut()
10068            .register_component(ComponentRegistration::new(
10069                "x-bleed",
10070                r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
10071                None,
10072                true,
10073            ))
10074            .expect("register failed");
10075
10076        parser
10077            .parse("index.html", "<x-bleed></x-bleed>")
10078            .expect("parse failed");
10079
10080        let artifacts = parser.take_plugin_artifacts().unwrap();
10081        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10082            panic!("expected component templates");
10083        };
10084        let template = &templates[0].template_json;
10085        assert!(template.contains("<div>hello</div>"));
10086        assert!(!template.contains("{{path}}"));
10087        assert!(!template.contains("@click"));
10088        assert!(!template.contains("?hidden"));
10089        assert!(!template.contains("-->"));
10090    }
10091
10092    #[test]
10093    fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
10094        let mut parser =
10095            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10096        parser
10097            .component_registry_mut()
10098            .register_component(ComponentRegistration::new(
10099                "x-style",
10100                "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
10101                None,
10102                true,
10103            ))
10104            .expect("register failed");
10105
10106        parser
10107            .parse("index.html", "<x-style></x-style>")
10108            .expect("parse failed");
10109
10110        let artifacts = parser.take_plugin_artifacts().unwrap();
10111        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10112            panic!("expected component templates");
10113        };
10114        let template = &templates[0].template_json;
10115        assert!(template.contains(".x { color: red; }"));
10116        assert!(!template.contains("ignored"));
10117        assert!(!template.contains("//"));
10118    }
10119
10120    // ── Token collection tests ───────────────────────────────────────
10121
10122    #[test]
10123    fn test_tokens_from_style_tag() {
10124        let mut parser = HtmlParser::new();
10125        let html = r#"<style>
10126            .btn { color: var(--colorPrimary); background: var(--bgColor); }
10127        </style>"#;
10128        parser.parse("test.html", html).expect("parse failed");
10129        let tokens = parser.take_tokens();
10130
10131        assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
10132    }
10133
10134    #[test]
10135    fn test_tokens_from_malformed_style_error_on_unclosed_var() {
10136        let mut parser = HtmlParser::new();
10137        let html = r#"<style>
10138            .bad { color: var(--dangling; }
10139            .ok { color: var(--valid); }
10140        </style>"#;
10141        let result = parser.parse("test.html", html);
10142
10143        assert!(matches!(result, Err(ParserError::Template(diag)) if
10144            diag.error_code() == Some(codes::INVALID_CSS)
10145                && diag.to_string().contains("Unterminated CSS var() call")
10146        ));
10147    }
10148
10149    #[test]
10150    fn test_tokens_from_component_css() {
10151        let mut parser = HtmlParser::new();
10152        parser
10153            .component_registry_mut()
10154            .register_component(ComponentRegistration::new(
10155                "my-button",
10156                "<button>Click</button>",
10157                Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
10158                true,
10159            ))
10160            .expect("register failed");
10161
10162        let html = "<my-button></my-button>";
10163        parser.parse("test.html", html).expect("parse failed");
10164        let tokens = parser.take_tokens();
10165
10166        assert_eq!(tokens, vec!["borderWidth", "textColor"]);
10167    }
10168
10169    #[test]
10170    fn test_token_requirements_preserve_fallback_chains() {
10171        let mut parser = HtmlParser::new();
10172        parser
10173            .component_registry_mut()
10174            .register_component(ComponentRegistration::new(
10175                "my-card",
10176                "<div>Card</div>",
10177                Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
10178                true,
10179            ))
10180            .expect("register failed");
10181
10182        parser
10183            .parse("test.html", "<my-card></my-card>")
10184            .expect("parse failed");
10185        let analysis = parser.token_analysis();
10186
10187        assert_eq!(analysis.fallback_chains.len(), 1);
10188        assert_eq!(
10189            analysis.fallback_chains[0].tokens,
10190            vec!["token-a", "token-b", "token-c"]
10191        );
10192        assert!(!analysis.fallback_chains[0].has_literal_fallback);
10193    }
10194
10195    #[test]
10196    fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
10197        let mut parser = HtmlParser::new();
10198        parser
10199            .component_registry_mut()
10200            .register_component(ComponentRegistration::new(
10201                "my-card",
10202                "<div>Card</div>",
10203                Some(
10204                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10205                ),
10206             true,))
10207            .expect("register failed");
10208
10209        parser
10210            .parse("test.html", "<my-card></my-card>")
10211            .expect("parse failed");
10212        let analysis = parser.token_analysis();
10213
10214        assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
10215        assert_eq!(analysis.fallback_chains.len(), 1);
10216        assert_eq!(
10217            analysis.fallback_chains[0].tokens,
10218            vec!["token-b", "token-c"]
10219        );
10220    }
10221
10222    #[test]
10223    fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
10224        let mut parser = HtmlParser::new();
10225        parser
10226            .component_registry_mut()
10227            .register_component(ComponentRegistration::new(
10228                "my-card",
10229                "<div>Card</div>",
10230                Some(
10231                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10232                ),
10233             true,))
10234            .expect("register failed");
10235        parser
10236            .parse("test.html", "<my-card></my-card>")
10237            .expect("parse failed");
10238
10239        let analysis = parser.token_analysis();
10240        let theme = webui_tokens::TokenFile {
10241            themes: HashMap::from([(
10242                "light".to_string(),
10243                HashMap::from([("token-b".to_string(), "green".to_string())]),
10244            )]),
10245        };
10246
10247        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
10248            panic!("missing --token-c in the theme must fail parser validation");
10249        };
10250        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
10251        // The help is concise: a typo suggestion plus the local-definition
10252        // escape hatch — it does not restate "add --token-c to theme".
10253        let help = diagnostic.help_text().expect("help text");
10254        assert!(help.contains("did you mean --token-b?"), "help: {help}");
10255        assert!(help.contains("define it locally"), "help: {help}");
10256        assert!(!help.contains("add --token"), "help: {help}");
10257    }
10258
10259    #[test]
10260    fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
10261        let mut parser = HtmlParser::new();
10262        parser
10263            .component_registry_mut()
10264            .register_component(ComponentRegistration::new(
10265                "my-card",
10266                "<div>Card</div>",
10267                Some(":host { color: var(--brand, #000); }"),
10268                true,
10269            ))
10270            .expect("register failed");
10271        parser
10272            .parse("test.html", "<my-card></my-card>")
10273            .expect("parse failed");
10274
10275        let analysis = parser.token_analysis();
10276        // The token is still hoisted so the runtime resolves it when a theme
10277        // does provide it.
10278        assert_eq!(analysis.protocol_tokens, vec!["brand"]);
10279
10280        // A theme without `--brand` must NOT fail the build: the CSS literal
10281        // fallback (`#000`) already provides a value.
10282        let theme = webui_tokens::TokenFile {
10283            themes: HashMap::from([("light".to_string(), HashMap::new())]),
10284        };
10285        analysis
10286            .validate_theme_tokens(&theme)
10287            .expect("literal fallback must exempt --brand from theme validation");
10288    }
10289
10290    #[test]
10291    fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
10292        let mut parser = HtmlParser::new();
10293        parser
10294            .component_registry_mut()
10295            .register_component(ComponentRegistration::new(
10296                "my-card",
10297                "<div>Card</div>",
10298                // One usage has a literal fallback, the other does not. The
10299                // bare `var(--brand)` makes the token genuinely required.
10300                Some(":host { color: var(--brand, #000); background: var(--brand); }"),
10301                true,
10302            ))
10303            .expect("register failed");
10304        parser
10305            .parse("test.html", "<my-card></my-card>")
10306            .expect("parse failed");
10307
10308        let analysis = parser.token_analysis();
10309        let theme = webui_tokens::TokenFile {
10310            themes: HashMap::from([("light".to_string(), HashMap::new())]),
10311        };
10312
10313        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
10314            panic!("a bare var(--brand) usage must require --brand in the theme");
10315        };
10316        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
10317        assert!(diagnostic.to_string().contains("--brand"));
10318    }
10319
10320    #[test]
10321    fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
10322        let mut parser = HtmlParser::new();
10323        parser
10324            .component_registry_mut()
10325            .register_component(ComponentRegistration::new(
10326                "my-card",
10327                "<div>Card</div>",
10328                Some(
10329                    ":host { \
10330                       color: var(--colr-brand, #000); \
10331                       border: var(--present, 1px); \
10332                       margin: var(--required); \
10333                     }",
10334                ),
10335                true,
10336            ))
10337            .expect("register failed");
10338        parser
10339            .parse("test.html", "<my-card></my-card>")
10340            .expect("parse failed");
10341
10342        let analysis = parser.token_analysis();
10343        let theme = webui_tokens::TokenFile {
10344            themes: HashMap::from([(
10345                "light".to_string(),
10346                HashMap::from([
10347                    ("present".to_string(), "2px".to_string()),
10348                    ("required".to_string(), "8px".to_string()),
10349                ]),
10350            )]),
10351        };
10352
10353        // `colr-brand`: literal-only and absent from every theme → warned.
10354        // `present`: literal-only but defined in the theme → not warned.
10355        // `required`: has no literal fallback → a validation concern, not a warning.
10356        assert_eq!(
10357            analysis.unthemed_literal_fallback_tokens(&theme),
10358            vec!["colr-brand".to_string()]
10359        );
10360    }
10361
10362    #[test]
10363    fn test_theme_token_error_reports_location_and_suggestion() {
10364        let mut parser = HtmlParser::new();
10365        parser
10366            .component_registry_mut()
10367            .register_component(ComponentRegistration::new(
10368                "my-card",
10369                "<div>Card</div>",
10370                ":host {\n  color: var(--color-neutral-2000);\n}".into(),
10371                true,
10372            ))
10373            .expect("register failed");
10374        parser
10375            .parse("test.html", "<my-card></my-card>")
10376            .expect("parse failed");
10377
10378        let analysis = parser.token_analysis();
10379        let theme = webui_tokens::TokenFile {
10380            themes: HashMap::from([(
10381                "dark".to_string(),
10382                HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
10383            )]),
10384        };
10385
10386        let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
10387            panic!("missing --color-neutral-2000 must fail validation");
10388        };
10389        assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
10390        // File + line:column, like other authoring diagnostics.
10391        let location = diag.location().expect("a source location");
10392        assert!(
10393            location.contains("my-card.css:2:14"),
10394            "location: {location}"
10395        );
10396        // Snippet shows the offending CSS line.
10397        assert!(
10398            diag.snippet_text()
10399                .is_some_and(|s| s.contains("--color-neutral-2000")),
10400            "snippet: {:?}",
10401            diag.snippet_text()
10402        );
10403        // Did-you-mean from the theme's own tokens.
10404        assert!(
10405            diag.help_text()
10406                .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
10407            "help: {:?}",
10408            diag.help_text()
10409        );
10410    }
10411
10412    #[test]
10413    fn test_theme_token_warning_reports_location_and_suggestion() {
10414        let mut parser = HtmlParser::new();
10415        parser
10416            .component_registry_mut()
10417            .register_component(ComponentRegistration::new(
10418                "my-card",
10419                "<div>Card</div>",
10420                ":host {\n  color: var(--colr-brand, #000);\n}".into(),
10421                true,
10422            ))
10423            .expect("register failed");
10424        parser
10425            .parse("test.html", "<my-card></my-card>")
10426            .expect("parse failed");
10427
10428        let analysis = parser.token_analysis();
10429        let theme = webui_tokens::TokenFile {
10430            themes: HashMap::from([(
10431                "light".to_string(),
10432                HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
10433            )]),
10434        };
10435
10436        let warnings = analysis.theme_token_warnings(&theme);
10437        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
10438        let body = warnings[0].body();
10439        assert!(body.contains("my-card.css:2:"), "warning: {body}");
10440        assert!(body.contains("--colr-brand"), "warning: {body}");
10441        assert!(
10442            body.contains("did you mean --color-brand?"),
10443            "warning: {body}"
10444        );
10445    }
10446
10447    #[test]
10448    fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
10449        let mut parser = HtmlParser::new();
10450        let result =
10451            parser
10452                .component_registry_mut()
10453                .register_component(ComponentRegistration::new(
10454                    "my-card",
10455                    "<div>Card</div>",
10456                    Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
10457                    true,
10458                ));
10459
10460        assert!(matches!(result, Err(ParserError::Css(message)) if
10461            message.contains("Unterminated CSS var() call")
10462        ));
10463    }
10464
10465    #[test]
10466    fn test_tokens_merged_from_style_and_components() {
10467        let mut parser = HtmlParser::new();
10468        parser
10469            .component_registry_mut()
10470            .register_component(ComponentRegistration::new(
10471                "my-widget",
10472                "<div>Widget</div>",
10473                Some(".w { padding: var(--spacingM); }"),
10474                true,
10475            ))
10476            .expect("register failed");
10477
10478        let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
10479        parser.parse("test.html", html).expect("parse failed");
10480        let tokens = parser.take_tokens();
10481
10482        assert_eq!(tokens, vec!["spacingM", "textColor"]);
10483    }
10484
10485    #[test]
10486    fn test_tokens_deduplicated() {
10487        let mut parser = HtmlParser::new();
10488        parser
10489            .component_registry_mut()
10490            .register_component(ComponentRegistration::new(
10491                "my-btn",
10492                "<button>B</button>",
10493                Some(".b { color: var(--shared); }"),
10494                true,
10495            ))
10496            .expect("register failed");
10497
10498        let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
10499        parser.parse("test.html", html).expect("parse failed");
10500        let tokens = parser.take_tokens();
10501
10502        assert_eq!(tokens, vec!["shared"]);
10503    }
10504
10505    #[test]
10506    fn test_tokens_empty_when_no_vars() {
10507        let mut parser = HtmlParser::new();
10508        let html = "<div>Hello</div>";
10509        parser.parse("test.html", html).expect("parse failed");
10510        let tokens = parser.take_tokens();
10511
10512        assert!(tokens.is_empty());
10513    }
10514
10515    #[test]
10516    fn test_tokens_exclude_locally_defined_in_component() {
10517        let mut parser = HtmlParser::new();
10518        parser
10519            .component_registry_mut()
10520            .register_component(ComponentRegistration::new(
10521                "my-card",
10522                "<div>Card</div>",
10523                Some(":host { --local: 5px; width: var(--external); }"),
10524                true,
10525            ))
10526            .expect("register failed");
10527
10528        let html = "<my-card></my-card>";
10529        parser.parse("test.html", html).expect("parse failed");
10530        let tokens = parser.take_tokens();
10531
10532        assert_eq!(tokens, vec!["external"]);
10533    }
10534
10535    #[test]
10536    fn test_tokens_exclude_entry_root_definitions() {
10537        let mut parser = HtmlParser::new();
10538        parser
10539            .component_registry_mut()
10540            .register_component(ComponentRegistration::new(
10541                "my-btn",
10542                "<button>B</button>",
10543                Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
10544                true,
10545            ))
10546            .expect("register failed");
10547
10548        // Entry HTML defines --color-primary and --radius-m in :root
10549        // Components use them — they should NOT appear in hoisted tokens
10550        let html = r#"<style>
10551            :root {
10552                --color-primary: #0078d4;
10553                --radius-m: 6px;
10554            }
10555            body { color: var(--color-primary); }
10556        </style>
10557        <my-btn></my-btn>"#;
10558        parser.parse("test.html", html).expect("parse failed");
10559        let tokens = parser.take_tokens();
10560
10561        // Both tokens are defined in entry :root, so neither should be hoisted
10562        assert!(
10563            tokens.is_empty(),
10564            "Tokens defined in entry :root should be excluded: {tokens:?}"
10565        );
10566    }
10567
10568    #[test]
10569    fn test_tokens_entry_defs_exclude_but_external_kept() {
10570        let mut parser = HtmlParser::new();
10571        parser
10572            .component_registry_mut()
10573            .register_component(ComponentRegistration::new(
10574                "my-card",
10575                "<div>Card</div>",
10576                Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
10577                true,
10578            ))
10579            .expect("register failed");
10580
10581        // Entry defines --color-primary but NOT --external-spacing
10582        let html = r#"<style>
10583            :root { --color-primary: #0078d4; }
10584        </style>
10585        <my-card></my-card>"#;
10586        parser.parse("test.html", html).expect("parse failed");
10587        let tokens = parser.take_tokens();
10588
10589        // Only --external-spacing should be hoisted (not defined in entry)
10590        assert_eq!(tokens, vec!["external-spacing"]);
10591    }
10592
10593    // ── Route parsing tests ─────────────────────────────────────────────
10594
10595    #[test]
10596    fn test_parse_simple_route() {
10597        let mut parser = HtmlParser::new();
10598        let html = r#"<route path="/profile" component="profile-page" exact />"#;
10599        parser.parse("test.html", html).expect("parse failed");
10600
10601        // Routes are emitted as Fragment::Route
10602        let records = parser.into_fragment_records();
10603        let frags = &records["test.html"].fragments;
10604        assert_eq!(frags.len(), 1);
10605        match frags[0].fragment.as_ref() {
10606            Some(web_ui_fragment::Fragment::Route(r)) => {
10607                assert_eq!(r.path, "/profile");
10608                assert_eq!(r.fragment_id, "profile-page");
10609                assert!(r.exact);
10610            }
10611            other => panic!("expected Fragment::Route, got {:?}", other),
10612        }
10613    }
10614
10615    #[test]
10616    fn test_parse_route_with_params() {
10617        let mut parser = HtmlParser::new();
10618        let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
10619        parser.parse("test.html", html).expect("parse failed");
10620
10621        // Route is emitted as Fragment::Route with correct path
10622        let records = parser.into_fragment_records();
10623        let frags = &records["test.html"].fragments;
10624        match frags[0].fragment.as_ref() {
10625            Some(web_ui_fragment::Fragment::Route(r)) => {
10626                assert_eq!(r.path, "/profile/:id/view/:section");
10627            }
10628            other => panic!("expected Fragment::Route, got {:?}", other),
10629        }
10630    }
10631
10632    #[test]
10633    fn test_parse_route_requires_component() {
10634        let mut parser = HtmlParser::new();
10635        let html = r#"<route path="/old" />"#;
10636        let result = parser.parse("test.html", html);
10637        assert!(result.is_err());
10638    }
10639
10640    #[test]
10641    fn test_parse_multiple_routes() {
10642        let mut parser = HtmlParser::new();
10643        let html = r#"<route path="/" component="app-layout" />
10644            <route path="/dashboard" component="dash-page" exact />
10645            <route path="/contacts" component="contacts-page" exact />"#;
10646        parser.parse("test.html", html).expect("parse failed");
10647
10648        let records = parser.into_fragment_records();
10649        let frags = &records["test.html"].fragments;
10650        assert_eq!(frags.len(), 3);
10651        // All should be Fragment::Route
10652        for frag in frags {
10653            assert!(matches!(
10654                frag.fragment.as_ref(),
10655                Some(web_ui_fragment::Fragment::Route(_))
10656            ));
10657        }
10658    }
10659
10660    #[test]
10661    fn test_parse_route_requires_component_with_body() {
10662        let mut parser = HtmlParser::new();
10663        let html = r#"<route path="/error">
10664            <div class="error"><h1>Not Found</h1></div>
10665        </route>"#;
10666        let result = parser.parse("test.html", html);
10667        assert!(
10668            result.is_err(),
10669            "Route without component attribute should fail"
10670        );
10671    }
10672
10673    #[test]
10674    fn test_parse_multiple_routes_with_fragments() {
10675        let mut parser = HtmlParser::new();
10676        let html = r#"<route path="/" component="home-page" exact />
10677            <route path="/about" component="about-page" exact />
10678            <route path="/contact/:id" component="contact-page" />"#;
10679        parser.parse("test.html", html).expect("parse failed");
10680
10681        let records = parser.into_fragment_records();
10682        let frags = &records["test.html"].fragments;
10683        assert_eq!(frags.len(), 3);
10684
10685        // Verify individual routes
10686        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
10687            assert_eq!(r.path, "/");
10688        }
10689        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
10690            assert_eq!(r.path, "/about");
10691        }
10692        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
10693            assert_eq!(r.path, "/contact/:id");
10694        }
10695    }
10696
10697    #[test]
10698    fn test_parse_route_with_query_allowlist() {
10699        let mut parser = HtmlParser::new();
10700        let html =
10701            r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
10702        parser.parse("test.html", html).expect("parse failed");
10703
10704        let records = parser.into_fragment_records();
10705        let frags = &records["test.html"].fragments;
10706        assert_eq!(frags.len(), 1);
10707        match frags[0].fragment.as_ref() {
10708            Some(web_ui_fragment::Fragment::Route(r)) => {
10709                assert_eq!(r.path, "/compose");
10710                assert_eq!(r.fragment_id, "compose-page");
10711                assert!(r.exact);
10712                assert_eq!(r.allowed_query, "action,to,subject");
10713            }
10714            other => panic!("expected Fragment::Route, got {:?}", other),
10715        }
10716    }
10717
10718    #[test]
10719    fn test_parse_route_without_query_has_empty_allowed_query() {
10720        let mut parser = HtmlParser::new();
10721        let html = r#"<route path="/profile" component="profile-page" exact />"#;
10722        parser.parse("test.html", html).expect("parse failed");
10723
10724        let records = parser.into_fragment_records();
10725        let frags = &records["test.html"].fragments;
10726        match frags[0].fragment.as_ref() {
10727            Some(web_ui_fragment::Fragment::Route(r)) => {
10728                assert!(r.allowed_query.is_empty());
10729            }
10730            other => panic!("expected Fragment::Route, got {:?}", other),
10731        }
10732    }
10733
10734    #[test]
10735    fn test_route_body_rejects_malformed_non_route_html() {
10736        let mut parser = HtmlParser::new();
10737        let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
10738        let result = parser.parse("test.html", html);
10739
10740        assert!(matches!(result, Err(ParserError::Template(diag)) if
10741            diag.to_string().contains("unclosed <span> tag")
10742        ));
10743    }
10744
10745    #[test]
10746    fn test_route_body_rejects_malformed_style_css() {
10747        let mut parser = HtmlParser::new();
10748        let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
10749        let result = parser.parse("test.html", html);
10750
10751        assert!(matches!(result, Err(ParserError::Template(diag)) if
10752            diag.error_code() == Some(codes::INVALID_CSS)
10753                && diag.to_string().contains("Unterminated CSS var() call")
10754        ));
10755    }
10756
10757    #[test]
10758    fn test_outlet_not_captured_by_for_loop() {
10759        let mut parser = HtmlParser::new();
10760        let html = r#"<template shadowrootmode="open">
10761  <ul>
10762    <for each="item in items">
10763      <li>{{item.name}}</li>
10764    </for>
10765  </ul>
10766  <main>
10767    <outlet />
10768  </main>
10769</template>"#;
10770        parser.parse("comp.html", html).expect("parse failed");
10771
10772        let records = parser.into_fragment_records();
10773        let frags = &records["comp.html"].fragments;
10774
10775        // The outlet should be a top-level fragment, NOT inside the for-loop's body
10776        let outlet_count = frags
10777            .iter()
10778            .filter(|f| {
10779                matches!(
10780                    f.fragment.as_ref(),
10781                    Some(web_ui_fragment::Fragment::Outlet(_))
10782                )
10783            })
10784            .count();
10785        assert_eq!(
10786            outlet_count, 1,
10787            "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
10788        );
10789
10790        // Verify outlet comes AFTER the raw "</ul>" text
10791        let outlet_idx = frags
10792            .iter()
10793            .position(|f| {
10794                matches!(
10795                    f.fragment.as_ref(),
10796                    Some(web_ui_fragment::Fragment::Outlet(_))
10797                )
10798            })
10799            .expect("no outlet found");
10800        let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
10801            Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
10802            _ => false,
10803        });
10804        if let Some(ul_idx) = close_ul_idx {
10805            assert!(
10806                outlet_idx > ul_idx,
10807                "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
10808            );
10809        }
10810    }
10811
10812    #[test]
10813    fn test_outlet_position_after_for_not_inside() {
10814        let mut parser = HtmlParser::new();
10815        let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
10816        parser.parse("test.html", html).expect("parse failed");
10817
10818        let records = parser.into_fragment_records();
10819        let frags = &records["test.html"].fragments;
10820
10821        // Outlet should be its own fragment at top level
10822        let has_outlet = frags.iter().any(|f| {
10823            matches!(
10824                f.fragment.as_ref(),
10825                Some(web_ui_fragment::Fragment::Outlet(_))
10826            )
10827        });
10828        assert!(
10829            has_outlet,
10830            "outlet should be in top-level fragments: {frags:?}"
10831        );
10832
10833        // The for-loop body should NOT contain the outlet
10834        let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
10835            Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
10836            _ => None,
10837        });
10838        if let Some(id) = for_id {
10839            let for_frags = &records[&id].fragments;
10840            let outlet_in_for = for_frags.iter().any(|f| {
10841                matches!(
10842                    f.fragment.as_ref(),
10843                    Some(web_ui_fragment::Fragment::Outlet(_))
10844                )
10845            });
10846            assert!(
10847                !outlet_in_for,
10848                "outlet should NOT be inside for-loop body: {for_frags:?}"
10849            );
10850        }
10851    }
10852
10853    #[test]
10854    fn test_style_element_with_handlebars_signal_comment_emits_signal() {
10855        let mut parser = HtmlParser::new();
10856        let html = r#"<html><head><style>
10857:root {
10858    /*{{{tokens.light}}}*/
10859}
10860</style></head><body></body></html>"#;
10861
10862        let result = parser.parse("test.html", html);
10863        assert!(result.is_ok(), "Parse error: {:?}", result.err());
10864        let fragment_records = parser.into_fragment_records();
10865
10866        assert_stream!(
10867            fragment_records,
10868            "test.html",
10869            [
10870                raw("<html><head>"),
10871                structural_matcher("head_start"),
10872                raw("<style>\n:root {\n    "),
10873                raw_text_signal("tokens.light", true),
10874                raw("\n}\n</style>"),
10875                structural_matcher("head_end"),
10876                raw("</head><body>"),
10877                structural_matcher("body_start"),
10878                structural_matcher("body_end"),
10879                raw("</body></html>"),
10880            ]
10881        );
10882    }
10883
10884    #[test]
10885    fn test_style_comment_signal_with_spaces_emits_signal() {
10886        let mut parser = HtmlParser::new();
10887        let html = r#"<html><head><style>
10888:root {
10889    /* {{{tokens.light}}} */
10890}
10891</style></head><body></body></html>"#;
10892
10893        let result = parser.parse("test.html", html);
10894        assert!(result.is_ok(), "Parse error: {:?}", result.err());
10895        let fragment_records = parser.into_fragment_records();
10896
10897        assert_stream!(
10898            fragment_records,
10899            "test.html",
10900            [
10901                raw("<html><head>"),
10902                structural_matcher("head_start"),
10903                raw("<style>\n:root {\n    "),
10904                raw_text_signal("tokens.light", true),
10905                raw("\n}\n</style>"),
10906                structural_matcher("head_end"),
10907                raw("</head><body>"),
10908                structural_matcher("body_start"),
10909                structural_matcher("body_end"),
10910                raw("</body></html>"),
10911            ]
10912        );
10913    }
10914
10915    #[test]
10916    fn test_style_comment_signal_double_brace_emits_signal() {
10917        let mut parser = HtmlParser::new();
10918        let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
10919
10920        let result = parser.parse("test.html", html);
10921        assert!(result.is_ok(), "Parse error: {:?}", result.err());
10922        let fragment_records = parser.into_fragment_records();
10923
10924        assert_stream!(
10925            fragment_records,
10926            "test.html",
10927            [
10928                raw("<html><head>"),
10929                structural_matcher("head_start"),
10930                raw("<style>"),
10931                raw_text_signal("themeCss", false),
10932                raw("</style>"),
10933                structural_matcher("head_end"),
10934                raw("</head><body>"),
10935                structural_matcher("body_start"),
10936                structural_matcher("body_end"),
10937                raw("</body></html>"),
10938            ]
10939        );
10940    }
10941
10942    #[test]
10943    fn test_style_comment_with_extra_text_is_stripped() {
10944        let mut parser = HtmlParser::new();
10945        let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
10946
10947        let result = parser.parse("test.html", html);
10948        assert!(result.is_ok(), "Parse error: {:?}", result.err());
10949        let fragment_records = parser.into_fragment_records();
10950
10951        assert_stream!(
10952            fragment_records,
10953            "test.html",
10954            [
10955                raw("<html><head>"),
10956                structural_matcher("head_start"),
10957                raw("<style></style>"),
10958                structural_matcher("head_end"),
10959                raw("</head><body>"),
10960                structural_matcher("body_start"),
10961                structural_matcher("body_end"),
10962                raw("</body></html>"),
10963            ]
10964        );
10965    }
10966
10967    #[test]
10968    fn test_style_comment_with_multiple_signals_is_stripped() {
10969        let mut parser = HtmlParser::new();
10970        let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
10971
10972        let result = parser.parse("test.html", html);
10973        assert!(result.is_ok(), "Parse error: {:?}", result.err());
10974        let fragment_records = parser.into_fragment_records();
10975
10976        assert_stream!(
10977            fragment_records,
10978            "test.html",
10979            [
10980                raw("<html><head>"),
10981                structural_matcher("head_start"),
10982                raw("<style></style>"),
10983                structural_matcher("head_end"),
10984                raw("</head><body>"),
10985                structural_matcher("body_start"),
10986                structural_matcher("body_end"),
10987                raw("</body></html>"),
10988            ]
10989        );
10990    }
10991
10992    #[test]
10993    fn test_style_bare_handlebars_stays_raw() {
10994        let mut parser = HtmlParser::new();
10995        // Handlebars outside a CSS comment must NOT be parsed as signals.
10996        let html =
10997            "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
10998
10999        let result = parser.parse("test.html", html);
11000        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11001        let fragment_records = parser.into_fragment_records();
11002
11003        assert_stream!(
11004            fragment_records,
11005            "test.html",
11006            [
11007                raw("<html><head>"),
11008                structural_matcher("head_start"),
11009                raw("<style>body { color: {{textColor}}; }</style>"),
11010                structural_matcher("head_end"),
11011                raw("</head><body>"),
11012                structural_matcher("body_start"),
11013                structural_matcher("body_end"),
11014                raw("</body></html>"),
11015            ]
11016        );
11017    }
11018
11019    #[test]
11020    fn test_style_mixed_css_and_comment_signal_emits_signal() {
11021        let mut parser = HtmlParser::new();
11022        let html = r#"<html><head><style>
11023  .a { color: red; }
11024  /*{{themeCss}}*/
11025  .b { color: blue; }
11026</style></head><body></body></html>"#;
11027
11028        let result = parser.parse("test.html", html);
11029        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11030        let fragment_records = parser.into_fragment_records();
11031
11032        assert_stream!(
11033            fragment_records,
11034            "test.html",
11035            [
11036                raw("<html><head>"),
11037                structural_matcher("head_start"),
11038                raw("<style>\n  .a { color: red; }\n  "),
11039                raw_text_signal("themeCss", false),
11040                raw("\n  .b { color: blue; }\n</style>"),
11041                structural_matcher("head_end"),
11042                raw("</head><body>"),
11043                structural_matcher("body_start"),
11044                structural_matcher("body_end"),
11045                raw("</body></html>"),
11046            ]
11047        );
11048    }
11049
11050    #[test]
11051    fn test_style_legal_comment_preserved_inline_by_default() {
11052        let mut parser = HtmlParser::new();
11053        let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
11054
11055        parser.parse("test.html", html).expect("parse failed");
11056        let fragment_records = parser.into_fragment_records();
11057
11058        assert_stream!(
11059            fragment_records,
11060            "test.html",
11061            [
11062                raw("<html><head>"),
11063                structural_matcher("head_start"),
11064                raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
11065                structural_matcher("head_end"),
11066                raw("</head><body>"),
11067                structural_matcher("body_start"),
11068                structural_matcher("body_end"),
11069                raw("</body></html>"),
11070            ]
11071        );
11072    }
11073
11074    #[test]
11075    fn test_style_legal_comments_none_strips_legal_comment() {
11076        let mut parser = HtmlParser::with_options(ParserOptions {
11077            legal_comments: LegalComments::None,
11078            ..ParserOptions::default()
11079        });
11080        let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
11081
11082        parser.parse("test.html", html).expect("parse failed");
11083        let fragment_records = parser.into_fragment_records();
11084
11085        assert_stream!(
11086            fragment_records,
11087            "test.html",
11088            [
11089                raw("<html><head>"),
11090                structural_matcher("head_start"),
11091                raw("<style> .x { color: red; }</style>"),
11092                structural_matcher("head_end"),
11093                raw("</head><body>"),
11094                structural_matcher("body_start"),
11095                structural_matcher("body_end"),
11096                raw("</body></html>"),
11097            ]
11098        );
11099    }
11100
11101    #[test]
11102    fn test_style_element_plain_css_unchanged() {
11103        let mut parser = HtmlParser::new();
11104        let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
11105
11106        let result = parser.parse("test.html", html);
11107        assert!(result.is_ok(), "Parse error: {:?}", result.err());
11108        let fragment_records = parser.into_fragment_records();
11109
11110        // Plain CSS with no handlebars should remain as a single raw fragment.
11111        assert_stream!(
11112            fragment_records,
11113            "test.html",
11114            [
11115                raw("<html><head>"),
11116                structural_matcher("head_start"),
11117                raw("<style>body { margin: 0; }</style>"),
11118                structural_matcher("head_end"),
11119                raw("</head><body>"),
11120                structural_matcher("body_start"),
11121                structural_matcher("body_end"),
11122                raw("</body></html>"),
11123            ]
11124        );
11125    }
11126
11127    #[test]
11128    fn test_unclosed_style_element_returns_error() {
11129        let mut parser = HtmlParser::new();
11130        let result = parser.parse("index.html", "<style>.x { color: red; ");
11131        assert!(matches!(result, Err(ParserError::Template(diag)) if
11132            diag.to_string().contains("unclosed <style> tag")
11133        ));
11134    }
11135
11136    #[test]
11137    fn test_text_with_binding_after_void_element() {
11138        // Text nodes containing {{binding}} after void elements like <br>
11139        // must be preserved. Regression test for content being dropped.
11140        let (fragments, _) = parse_and_get_fragments(
11141            "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
11142        );
11143        assert_fragments!(
11144            fragments,
11145            [
11146                raw("Part A: "),
11147                signal("value1"),
11148                raw("<button>Click</button><br>Part B: "),
11149                signal("value2"),
11150                raw("<button>Click2</button>"),
11151            ]
11152        );
11153    }
11154}